diff --git a/.github/workflows/tui-tests.yml b/.github/workflows/cli-tests.yml similarity index 69% rename from .github/workflows/tui-tests.yml rename to .github/workflows/cli-tests.yml index 7267f9fec..3c2bbd4d3 100644 --- a/.github/workflows/tui-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -4,7 +4,7 @@ on: pull_request: concurrency: - group: tui-tests-${{ github.event.pull_request.number || github.ref }} + group: cli-tests-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: @@ -39,6 +39,7 @@ jobs: - 'src/index.ts' - 'tests/**/*.test.ts' - 'test/**/*.test.ts' + - 'scripts/wl-process-healthcheck.sh' docs-only: runs-on: ubuntu-latest @@ -75,3 +76,17 @@ jobs: - name: Run CLI tests run: npm test + + - name: Assert no orphaned tsx src/cli.ts processes (regression gate) + run: | + # Regression gate: after the test suite, assert zero orphaned + # "tsx src/cli.ts" processes remain (covers WL-0MSB447TJ000R3N8). + count=$(ps -eo args | grep -c '[t]sx .*src/cli[.]ts' || true) + echo "Orphaned tsx src/cli.ts processes: ${count}" + if [ "${count}" -gt 0 ]; then + echo "Orphaned tsx src/cli.ts processes:" >&2 + ps -eo pid,ppid,etime,args | grep '[t]sx .*src/cli[.]ts' || true + echo "ERROR: ${count} orphaned tsx src/cli.ts processes remain (regression of WL-0MSB447TJ000R3N8)" >&2 + exit 1 + fi + echo "OK: no orphaned tsx src/cli.ts processes" diff --git a/.github/workflows/install-and-smoke-test.yml b/.github/workflows/install-and-smoke-test.yml index 4f5c0e326..3adc6849a 100644 --- a/.github/workflows/install-and-smoke-test.yml +++ b/.github/workflows/install-and-smoke-test.yml @@ -42,37 +42,32 @@ jobs: node ./dist/cli.js list -n 1 --json > /dev/null echo "✓ wl CLI works" - # Verify the TUI module loads without errors (using tsx for TypeScript imports) + # Verify the Pi extension entry point and retained agent-side modules load + # without errors (using tsx for TypeScript imports) npx tsx -e " (async () => { - const { ChatPane } = await import('./packages/tui/extensions/Worklog/chatPane.ts'); - const { ActionPalette } = await import('./packages/tui/extensions/Worklog/actionPalette.ts'); + const { createWorklogPiExtension } = await import('./packages/tui/extensions/Worklog/index.ts'); + if (typeof createWorklogPiExtension !== 'function') throw new Error('createWorklogPiExtension is not a function'); const { runWl } = await import('./packages/tui/extensions/wl-integration.ts'); - console.log('✓ TUI modules load successfully'); + const { INSTALL_GUARDRAILS } = await import('./packages/tui/extensions/Worklog/lib/guardrails.ts'); + const { registerSkillPathTool } = await import('./packages/tui/extensions/Worklog/lib/skill-path.ts'); + const { registerLeaseRelease } = await import('./packages/tui/extensions/Worklog/lease-release.ts'); + console.log('✓ Pi extension modules load successfully'); })(); " - # Verify chat pane can send messages + # Verify the extension registers its feature modules (dry run, no pi runtime) npx tsx -e " (async () => { - const { ChatPane } = await import('./packages/tui/extensions/Worklog/chatPane.ts'); - const pane = new ChatPane(); - pane.clear(); - console.log('✓ ChatPane instantiated'); - })(); - " - - # Verify action palette has default actions - npx tsx -e " - (async () => { - const { ChatPane } = await import('./packages/tui/extensions/Worklog/chatPane.ts'); - const { ActionPalette } = await import('./packages/tui/extensions/Worklog/actionPalette.ts'); - const chat = new ChatPane(); - const palette = new ActionPalette(chat); - palette.open(); - const actions = palette.getFilteredActions(); - if (actions.length < 5) throw new Error('Expected at least 5 default actions, got ' + actions.length); - console.log('✓ ActionPalette has ' + actions.length + ' default actions'); + const { createWorklogPiExtension } = await import('./packages/tui/extensions/Worklog/index.ts'); + const registered: string[] = []; + const piStub = { + on: () => {}, registerCommand: () => {}, registerTool: () => {}, + ui: { setStatus: () => {}, setFooter: () => {}, notify: () => {} }, + }; + const register = createWorklogPiExtension(); + register(piStub); + console.log('✓ Extension registers without errors'); })(); " @@ -86,9 +81,3 @@ jobs: - name: Run full test suite run: npm test - - - name: Run E2E agent flow tests - run: npx vitest run tests/e2e/agent-flow.test.ts - - - name: Run E2E headless TUI tests - run: npx vitest run tests/e2e/headless-tui.test.ts diff --git a/AGENTS.md b/AGENTS.md index 1909b3daa..acd97f047 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,257 +1,5 @@ -Follow the global AGENTS.md in addition to the rules below. The local rules below take priority in the event of a conflict. +## Global agent guidance - +Read the global agent instructions at `~/.pi/agent/AGENTS.md` — they define the core principles, the Worklog (wl) work-item workflow, and the coding disciplines that apply to every project. That file is installed from this repository's `AGENTS_GLOBAL.md` by `scripts/install_pi.sh`, which symlinks it into place. -## work-item Tracking with Worklog (wl) - -IMPORTANT: This project uses Worklog (wl) for ALL work-item tracking. Do NOT use markdown TODOs, task lists, or other tracking methods. - -## CRITICAL RULES - -- Use Worklog (wl), described below, for ALL task tracking, do NOT use markdown TODOs, task lists, or other tracking methods -- When mentioning a work item always use its title followed by its ID in parentheses, e.g. "Fix login bug (WL-1234)" -- Always keep work items up to date with accurate status, priority, stage, and assignee -- Whenever you are provided with, or discover, a new work item create it in wl immediately -- Whenever you are provided with or discover important context (specifications, designs, user-stories) ensure the information is added to the description of the relevant work item(s) OR create a new work item if none exist -- Whenever you create a planning document (PRD, spec, design doc) add references to the document in the description of any work item that is directly related to the document -- Work items cannot be closed until all child items are closed, all blocking dependencies resolved and a Producer has reviewed and approved the work -- Never commit changes without associating them with a work item -- Never commit changes without ensuring all tests and quality checks pass -- Whenever a commit is made add a comment to impacted the work item(s) describing the changes, the files affected, and including the commit hash. -- If push fails, resolve and retry until it succeeds -- When using backticks in arguments to shell commands, escape them properly to avoid errors - -### Important Rules - -- Use wl as a primary source of truth, only the source code is more authoritative -- Always use `--json` flag for programmatic use -- When new work items are discovered or prompted while working on an existing item create a new work item with `wl create` - - If the item must be completed before the current work item can be completed add it as a child of the current item (`wl create --parent `) - - If the item is related to the current work item but not blocking its completion add a reference to the current item in the description (`discovered-from:`) -- Check `wl next` before asking "what should I work on?" and always offer the response as a next steps suggestion, with an explanation -- Run `wl --help` and `wl --help` to learn about the capabilities of WorkLog (wl) and discover available flags -- Use work items to track all significant work, including bugs, features, tasks, epics, chores -- Use clear, concise titles and detailed descriptions for all work items -- Use parent/child relationships to track dependencies and subtasks -- Use priorities to indicate the importance of work items -- Use stages to track workflow progress -- Do NOT clutter repo root with planning documents - -### work-item Types - -Track work-item types with `--issue-type`: - -- bug - Something broken -- feature - New functionality -- task - Work item (tests, docs, refactoring) -- epic - Large feature with subtasks -- chore - Maintenance (dependencies, tooling) - -### Work Item Descriptions - -- Use clear, concise titles summarizing the work item. -- Do not escape special characters -- The description must provide sufficient context for understanding and implementing the work item. -- At a minimum include: - - A summary of the problem or feature. - - Example User Stories if applicable. - - Expected behaviour and outcomes. - - Steps to reproduce (for bugs). - - Suggested implementation approach if relevant. - - Links to related work items or documentation. - - Measurable and testable acceptance criteria. - -### Priorities - -Worklog uses named priorities: - -- critical - Security, data loss, broken builds -- high - Major features, important bugs -- medium - Default, nice-to-have -- low - Polish, optimization - -### Dependencies - -Use parent/child relationships to track blocking dependencies. - -- Child items must be completed before the parent can be closed. -- If a work item blocks another, make it a child of the blocked item. -- If a work item blocks multiple items, create the parent/child relationships with the highest priority item as the parent unless one of the items is in-progress, in which case that item should be the parent. - - If in doubt raise for product manager review. - -For non-hierarchical blocking relationships, prefer dependency edges over description-based conventions. Dependency edges are the recommended way to track blockers: - -```bash -wl dep add -wl dep list --json -wl dep rm -``` - -Description-based conventions (`discovered-from:`, `related-to:`, `blocked-by:`) remain supported for informal cross-references and planning notes, but dependency edges should be used for any relationship that affects scheduling or blocking. - -### Workflow management - -- Use the `--stage` flag to track workflow stages according to your particular process, - - e.g. `idea`, `prd_complete`, `milestones_defined`, `plan_complete`, `in_progress``done`. -- Use the `--assignee` flag to assign work items to agents. -- Use the `--tags` flag to add arbitrary tags for filtering and organization. Though avoid over-tagging. -- Use comments to document progress, decisions, and context. -- Use `risk` and `effort` fields to track complexity and potential issues. - - If available use the `effort_and_risk` agent skill to estimate these values. - -1. Check ready work: `wl next` -2. Claim your task: `wl update --status in-progress` -3. Work on it: implement, test, document -4. Discover new work? Create a linked issue: - -- `wl create "Found bug" --priority high --tags "discovered-from:"` - -5. Complete: `wl close --reason "PR #123 merged"` -6. Sync: run `wl sync` before ending the session - -### Work-Item Management - -```bash -# Create work items -wl create --help # Show help for creating work items -wl create --title "Bug title" --description "
" --priority high --issue-type bug --json -wl create --title "Feature title" --description "
" --priority medium --issue-type feature --json -wl create --title "Epic title" --description "
" --priority high --issue-type epic --json -wl create --title "Subtask" --parent --priority medium --json -wl create --title "Found bug" --priority high --tags "discovered-from:WL-123" --json - -# Update work items -wl update --help # Show help for updating work items -wl update --status in-progress --json -wl update --priority high --json - -# Comments -wl comment --help # Show help for comment commands -wl comment list --json -wl comment show -C1 --json -wl comment update -C1 --comment "Revised" --json -wl comment delete -C1 --json - -# Close or delete -# wl close: provide -r reason for closing; can close multiple ids -wl close --reason "PR #123 merged" --json -wl close --json - -# *Destructive command ask for confirmation before running* Dekete a work item permanently -wl delete --json - -# Dependencies -wl dep --help # Show help for dependency commands -wl dep add -wl dep list --json -wl dep rm -``` - -### Project Status - -```bash -# Show the next ready work items (JSON output) -# Display a recommendation for the next item to work on in JSON -wl next --json -# Display a recommendation for the next item assigned to `agent-name` to work on -wl next --assignee "agent-name" --json -# Display a recommendation for the next item to work on that matches a keyword (in title/description/comments) -wl next --search "keyword" --json - -# Show all items with status `in-progress` in JSON -wl in-progress --json -# Show in-progress items assigned to `agent-name` -wl in-progress --assignee "agent-name" --json - -# Show recently created or updated work items -wl recent --json -# Show the 10 most recently created or updated items -wl recent --number 10 --json -# Include child/subtask items when showing recent items -wl recent --children --json - -# List all work items except those in a completed state -wl list --json -# Limit list output -wl list -n 5 --json -# List items filtered by status (open, in-progress, closed, etc.) -wl list --status open --json -wl list --status open,in-progress --json # comma-separated: matches any listed status -# List items filtered by priority (critical, high, medium, low) -wl list --priority high --json -# List items filtered by comma-separated tags -wl list --tags "frontend,bug" --json -# List items filtered by assignee (short or full name) -wl list --assignee alice --json -# List items filtered by stage (e.g. triage, review, done) -wl list --stage review --json - -# Full-text search across all work items (title, description, comments, tags) -wl search --json -# Search with status filter -wl search --status open --json -# Search by work item ID (exact, unprefixed, or partial >= 8 chars) -wl search --json -wl search --json - -# Show full details for a specific work item -wl show --format full --json -``` - -#### Team - -```bash - # Sync local worklog data with the remote (shares changes) - wl sync - # Import issues from GitHub into the worklog (GitHub -> worklog) - wl github import - # Push worklog changes to GitHub issues (worklog -> GitHub) - wl github push -``` - -#### Plugins - -Depending on your setup, you may have additional wl plugins installed. Check available plugins with `wl --help` (See plugins section) to view more information about the features provided by each plugin run `wl --help` - -#### Help - -Run `wl --help` to see general help text and available commands. -Run `wl --help` to see help text and all available flags for any command. - - - -## Architecture Notes for Agents - -### Data Storage Architecture - -Worklog uses **SQLite as the runtime source of truth** with an **ephemeral JSONL pattern** for Git sync: - -- **SQLite** (`.worklog/worklog.db`): All runtime reads/writes happen here -- **JSONL** (`.worklog/worklog-data.jsonl`): Only exists transiently during sync operations -- **Git**: Persistent storage for collaboration - -### Important Rules for Agents - -1. **Work with SQLite, not JSONL** - - Never manually edit JSONL files - - Use the database API for all data operations - - JSONL is only for Git transport, not for data manipulation - -2. **Migration Complete** - - The old `autoExport` feature has been removed - - No automatic JSONL exports after database writes - - TUI is now responsive regardless of data size - -3. **Sync Behavior** - - `wl sync` exports SQLite → JSONL → pushes to Git → deletes local JSONL - - JSONL only exists during the sync window (seconds) - - Working directory should not have persistent JSONL files - -4. **Legacy JSONL Files** - - If you encounter a persistent JSONL file, it may be from an older version - - Use `wl doctor migrate` to import it into SQLite - - Use `wl doctor migrate --delete` to import and remove the file - -### For More Information - -See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for detailed architecture documentation. +## Project-specific guidance \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index ca190f8e1..9321f385a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,79 @@ # Changelog +## v1.0.9 (2026-08-08) +### Features +- Herdr to consume local LLM in downtime (WL-0MSF49FMW009M06K) +- Idle tracker & dispatch orchestration (WL-0MSG80AG700429M8) +- Downtime worker test suite (test-first) (WL-0MSG7ZTC000163FL) +- Downtime settings & proxy status poller (WL-0MSG80254005ZNE9) +- Log herdr downtime dispatches (item comment + rolling .worklog log) (WL-0MSGPI4AR000YOK8) +- Central cache for wl read results to cut Herdr refresh spawn churn (WL-0MSAZQEQB008O7H3) +- Read-cache core module with WAL-aware invalidation (WL-0MSGAE35E001557V) +- Wire read commands through cache; spawn instrumentation (WL-0MSGAEC5N006W5QA) +- Sync invalidation and cross-instance heartbeat (WL-0MSGAEJQA005QG3W) +- Herdr in-process memoization for identical fetches (WL-0MSGAEPOJ002824S) +- Detail view ToC for Related Docs (WL-0MSHWHULZ001FL8I) +- Add Related Docs metadata row (WL-0MSHWHRIF001YHF8) +- Add issue-type gating to herdr shortcuts; limit code-focused shortcuts to code work item types (WL-0MSKH1J0R003BM2M) +- Allow /skill:audit on complete/in_review items without a valid audit (WL-0MSI8H3HP000K0RG) +- Wire audit tier and docs (WL-0MSI9FXJH000GKIW) +- Audit candidate selection (WL-0MSI9FK2Q0074C31) +- [test-failure] sync → read-cache invalidation (F3 AC1) > AC3: a failed sync does not create/refresh the heartbeat marker — failing test (WL-0MSIYAH9D003O1HH) +- Install script: insert herdr shortcut key into herdr config (runs on npm run build) (WL-0MSAPMGLS0034BTR) +- Implement install-herdr.sh script (WL-0MSG6OA2K003GY27) +- Wire showIcons setting and add settings reload mechanism for herdr worklog plugin (WL-0MSBV4RYO008JL70) +- Herdr worklist: expanded items momentarily collapse on list refresh (flicker) (WL-0MSBVBNGH002RDP5) +- Loader: bundled + project-local merge (WL-0MSHXQJW10082U8M) +### Bug Fixes +- [test-failure] should have no legacy skill/ path references (Skill: ship, Skill: test) — failing test (WL-0MSGQ1AWE009G0JT) +- Code Freeze: herdr plugin blocks implement actions (banner + dialog) during ship-it process (WL-0MSBU4KMA004PKSR) +- herdr downtime worker: hung wl exec (no timeout) permanently wedges the dispatch task until pane restart (WL-0MSJIPHD0001L1J9) +- herdr plugin md viewer fails: Key Files paths resolved against plugin CWD, not worklog root (WL-0MSGEA9AY0080V4Q) +- [test-failure] read-cache spawn reduction (6-pane simulation) cuts work spawns by ≥60% across a simulated 6-pane refresh — failing test (WL-0MSHJ2NWZ000P3X9) +- [test-failure] mock-timeout.test.ts > mock-bin/git timeout guard > self-terminates when execution exceeds the configured timeout — failing test (WL-0MSHLCOCR0031GAF) +- [test-failure] should create semaphore when config exists but semaphore does not — failing test (WL-0MSGLOMLL004JFSW) +- wl sync fails: duplicate 'git worktree add' in withTempWorktree ('path already exists') (WL-0MSGNDSCR0096MCR) +- [test-failure] install-herdr.test.ts (keybinding block mismatch) — failing test (WL-0MSI59JKW0077XPM) +- [test-failure] tests/herdr/settings.test.ts > defaultSettings > has default browseItemCount of 10 — failing test (WL-0MSHYDQFK004F8P8) +- [test-failure] defaultSettings clamps browseItemCount to the [1, 50] range at load time — failing test (WL-0MSHWHIWX003IQHO) +- [test-failure] defaultSettings has default browseItemCount of 10 — failing test (WL-0MSHWHFAK005WKDB) +- [test-failure] worklist.test.ts > detail view ToC for Related Docs (WL-0MSHWHULZ001FL8I) > renders a ToC at the top listing every Related Doc when the item has .md Key Files — failing test (WL-0MSI11MFU009BHD8) +- [test-failure] install-herdr script inserts the keybinding block exactly once when run twice (idempotent) — failing test (WL-0MSI3IU56007TDFY) +- [test-failure] downtime-worker.test.ts AuditCandidate fixtures missing title (tsc build error) — failing test (WL-0MSIVJGHO000ASG9) +- [test-failure] json-output-shape.test.ts next --json uses {success, workItem} for single result — failing test (WL-0MSJ07OIP005W6N5) +- [test-failure] skill-path-conventions.test.ts should have no legacy skill/ path references (Skill: audit) — failing test (WL-0MSJ1DMXI0082DZ9) +### Other +- Consolidate herdr plugin timers into a single loop (WL-0MSG4NMF0000YBRP) +- Tests: Related Docs row, detail ToC and open-in-viewer (WL-0MSHWHP0S0036DDU) +- Documentation updates (WL-0MSHWHULZ004A8PQ) +- Quality Improvement - Refactoring (WL-0MSG80EF20071ET2) +- [HIGH] /home/rgardler/projects/ContextHub/packages/herdr/shared/grid.py:1 — Shebang is present but file is not executable (EXE001) (WL-0MSG80F9L005O13U) +- [MEDIUM] /home/rgardler/projects/ContextHub/packages/herdr/shared/tests/test_grid.py:717 — `subprocess.run` without explicit `check` argument (PLW1510) (WL-0MSG80G61004TL0R) +- [MEDIUM] /home/rgardler/projects/ContextHub/packages/herdr/shared/grid.py:500 — Do not catch blind exception: `Exception` (BLE001) (WL-0MSGGQVGH008Z3FG) +- [MEDIUM] /home/rgardler/projects/ContextHub/packages/herdr/shared/tests/test_grid.py:729 — `subprocess.run` without explicit `check` argument (PLW1510) (WL-0MSG80GMN002X1A9) +- [MEDIUM] /home/rgardler/projects/ContextHub/packages/herdr/shared/tests/test_grid.py:791 — `subprocess.run` without explicit `check` argument (PLW1510) (WL-0MSGGQVVP009G5YW) +- [MEDIUM] /home/rgardler/projects/ContextHub/packages/herdr/shared/tests/test_grid.py:803 — `subprocess.run` without explicit `check` argument (PLW1510) (WL-0MSGGQWAO000Y46E) +- [MEDIUM] /home/rgardler/projects/ContextHub/packages/herdr/shared/grid.py:465 — Do not catch blind exception: `Exception` (BLE001) (WL-0MSG80FQ9000C8GE) +- [LOW] /home/rgardler/projects/ContextHub/packages/herdr/shared/grid.py:1 — Shebang is present but file is not executable (EXE001) (WL-0MSGBU13N002YFR9) +- Remove the Pi-based TUI from the repo; keep the agent-side Pi plugin features (WL-0MSGI7PV9004UD0N) +- Herdr worklist: remove blank line and redundant filter bar; show filter in header (WL-0MSGTSPXK007POB1) +- Audit-tier downtime dispatch tests (WL-0MSI9FDPI005Z0BV) +- Audit dispatch tier (WL-0MSI9WPNI0044RL8) +- Standardize worklog root resolution: share one strategy between wl CLI and herdr plugin (WL-0MS7TQVK2001X4EG) +- CLI delegates to shared resolver (WL-0MSAWAR1U008VRY3) +- Tests for shared resolveWorklogRoot (WL-0MSAWAPV5006VDUG) +- Herdr plugin: change default browseItemCount from 10 to 20 (WL-0MSHUDQXR009T29L) +- Add documentation work-item type (WL-0MSKDFSHA008CSCO) +- Tests for install-herdr script behaviors (WL-0MSG6O6Z5005DAZV) +- Wire postbuild hook and update docs (WL-0MSG6ODD5002X2OF) +- implement.py worktrees lack node_modules: read-cache e2e tests fail on every finish (WL-0MSGQ64N2006TQB2) +- Herdr plugin: eliminate the 'Other' group in the selection list by improving grouping rules (WL-0MSAR110S002V26K) +- wl process health check watchdog (scripts/wl-process-healthcheck.sh + CI regression gate) (WL-0MSBVYH3W0066KJS) +- Test-first: local-override merge tests (WL-0MSHXQJZE008WE45) +- Call-site wiring + documentation (WL-0MSHXQJEW00153EZ) +- Simplify project AGENTS.md to pointer to global agent instructions (WL-0MSIXSH8P009IZGG) +- AC3: Lock in standalone wl init workflow behavior (N/B/M) with regression tests (WL-0MSIXU7710038U22) + ## v1.0.8 (2026-08-05) ## v1.0.7 (2026-08-03) diff --git a/CLI.md b/CLI.md index dfd1cb0be..1060e4800 100644 --- a/CLI.md +++ b/CLI.md @@ -492,7 +492,7 @@ Options: `--stage ` — Filter by stage: `idea`, `intake_complete`, `plan_complete`, `in_progress`, `in_review`, `done` (optional). `--search ` (optional) `-n, --number ` — Number of items to return (optional; default: `1`). -`-g, --groups ` — Number of parallel-safe groups to identify (optional; default: `3`). Only meaningful when `-n > 1`. Groups items by priority, stage and file-path conflicts extracted from their descriptions, placing items that affect different files in the same group and conflicting items in separate groups. Items with priority `critical` are partitioned into `Critical Group N` file-path conflict groups at the top. Items with unknown/other stages are grouped together in a single "Other" group. See "Parallel-safe grouping" below. +`-g, --groups ` — Number of parallel-safe groups to identify (optional; default: `3`). Only meaningful when `-n > 1`. Groups items by priority, stage and file-path conflicts extracted from their descriptions, placing items that affect different files in the same group and conflicting items in separate groups. Items with priority `critical` are partitioned into `Critical Group N` file-path conflict groups at the top. Non-critical `in_progress`/`plan_complete`/`intake_complete` items are partitioned into `Group N` file-path conflict groups; items with unknown/custom stages are grouped together in a single "Other" group. See "Parallel-safe grouping" below. `--include-blocked` — Include dependency-blocked items (excluded by default). `--no-re-sort` — Skip automatic re-sort before selection, preserving current `sort_index` order (optional). `--re-sort-sync` — Force a synchronous (blocking) re-sort when automatic re-sort is triggered. By default automatic re-sorts are run asynchronously to avoid blocking interactive commands. @@ -530,23 +530,23 @@ The grouping algorithm uses a greedy first-fit strategy: 1. Extract file paths from each item's description using a `**Key Files:**` section convention (see [docs/FILE_PATH_CONVENTION.md](docs/FILE_PATH_CONVENTION.md) for the full specification). 2. **Critical priority items** are partitioned first into `Critical Group N` file-path conflict groups at the very top. -3. Non-critical items with stage `plan_complete` or `intake_complete` are partitioned into `Group N` file-path conflict groups. +3. Non-critical items with stage `in_progress`, `plan_complete`, or `intake_complete` are partitioned into `Group N` file-path conflict groups. 4. `idea` items are placed together in a single "Idea" group. -5. Items with unknown/other stages (and not critical) are placed together in a single "Other" group (no file-overlap splitting). +5. Items with unknown/custom stages (and not critical) are placed together in a single "Other" group (no file-overlap splitting). This is a safety net for unknown/custom stages; it is empty for all canonical stages in the default selection list. 6. `in_review` items are placed in a single "In Review" group, last. The group display order is: 1. **Critical Group N** — critical items partitioned by file-path conflicts (items sharing a file path land in different groups; items with unknown paths get singleton groups). -2. **Group N** — plan_complete + intake_complete items partitioned by file-path conflicts. Within each group, `plan_complete` items appear first, then `intake_complete` items (no headings between the sub-groups), each sub-group sorted by priority (high → medium → low). The same stage sub-sort applies inside each Critical Group N. +2. **Group N** — in_progress + plan_complete + intake_complete items partitioned by file-path conflicts. Within each group, `in_progress` items appear first, then `plan_complete`, then `intake_complete` items (no headings between the sub-groups), each sub-group sorted by priority (high → medium → low). The same within-group stage sub-sort applies inside each Critical Group N. 3. **Idea** — single group, sorted by priority. -4. **Other** — single group for items with unknown/other stages. +4. **Other** — single group for items with unknown/custom stages. 5. **In Review** — single group (last). In JSON output (`--json` with `-n > 1`), each result entry includes a `group` field (integer, 1-indexed) indicating the group assignment. In human-readable output, group headings (e.g., `── Critical Group 1 ──`, `── Group 1 ──`, `── Idea ──`, `── Other ──`, `── In Review ──`) are displayed between groups. -The Pi TUI selection list renders group separator lines between items in different groups, helping you quickly identify items you can work on in parallel. +The Herdr plugin's Worklog selection list renders group separator lines between items in different groups, helping you quickly identify items you can work on in parallel. To specify a custom number of groups: @@ -1056,46 +1056,6 @@ wl init wl init --project-name "My Project" --prefix PROJ --auto-export yes --auto-sync no ``` -### `tui` [options] - -Launch the terminal UI for browsing and filtering work items. - -Options: - -- `--in-progress` — Show only in-progress items. -- `--all` — Include completed/deleted items in the list. -- `--prefix ` — Override the default prefix. - -Example: - -```sh -wl tui --in-progress -``` - -Example (JSON): - -```sh -wl --json init -``` - -### `piman` | `pi` [options] - -Launch the Pi-based TUI for browsing and managing work items with agent chat and action palette. This is the agent-centric TUI that replaces the legacy Opencode-based interface. All Worklog reads/writes use the wl CLI (no direct database access). - -Options: - -- `--in-progress` — Show only in-progress items. -- `--all` — Include completed/deleted items in the list. -- `--prefix ` — Override the default prefix. -- `--perf` — Enable performance instrumentation. -- `--headless` — Run in headless mode for CI scripting and automated tests. - -Example: - -```sh -wl piman --in-progress -``` - ### `status` [options] Show Worklog system and database status (counts, configuration values). diff --git a/CONFIG.md b/CONFIG.md index 2cce644d3..26f6e44d7 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -62,16 +62,6 @@ The system loads configuration in this order: If no configuration exists at all, the system defaults to using `WI` as the prefix. -## TUI Auto-Sync Settings - -Optional TUI auto-sync settings (edit `.worklog/config.yaml` or configure via `/wl settings`): - -- `autoSyncIntervalSeconds`: Number of seconds between automatic background syncs in the Pi TUI browse widget (default: `10`, range: 0–300, 0 = disabled). - - When the TUI browse widget auto-refreshes (every 5 seconds), it checks if the time since the last successful sync exceeds this interval. If so, a background `wl sync` is triggered (fire-and-forget, non-blocking). An in-flight guard prevents concurrent syncs. - - This setting is separate from the `autoSync` boolean config key, which controls automatic sync after database write operations. - ## GitHub Settings Optional GitHub settings (edit `.worklog/config.yaml` manually): diff --git a/DATA_SYNCING.md b/DATA_SYNCING.md index 5bb5da084..a1ef0e6d2 100644 --- a/DATA_SYNCING.md +++ b/DATA_SYNCING.md @@ -44,7 +44,7 @@ Auto-sync is off by default to avoid unexpected git operations during local edit ### Auto-Sync Concurrency Guard (`wl sync --if-idle`) -Interactive UIs (the Pi TUI worklog extension and Herdr worklist panes) run background auto-syncs on a timer. Multiple panes/TUI instances can fire syncs at the same time; without a guard they queue up waiting for the file lock, which can produce a self-sustaining "lock storm" of stuck `wl sync` processes (60+ observed). +Interactive UIs (Herdr worklist panes) run background auto-syncs on a timer. Multiple panes can fire syncs at the same time; without a guard they queue up waiting for the file lock, which can produce a self-sustaining "lock storm" of stuck `wl sync` processes (60+ observed). To prevent this, auto-sync spawners pass `wl sync --if-idle`: diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md index 5a0b0d053..533faab0f 100644 --- a/IMPLEMENTATION_SUMMARY.md +++ b/IMPLEMENTATION_SUMMARY.md @@ -55,11 +55,14 @@ All requirements from the problem statement have been successfully implemented: - `GET /health` - Health check ### CLI Tool (`src/cli.ts`, `src/commands/*`) -- Command modules for create, list, show, update, delete, close, search, next, in-progress, recent, comment, dep, reviewed, import/export, sync, github, doctor, re-sort, migrate, unlock, init, status, tui, and plugins +- Command modules for create, list, show, update, delete, close, search, next, in-progress, recent, comment, dep, reviewed, import/export, sync, github, doctor, re-sort, migrate, unlock, init, status, and plugins - Shared helpers for ordering, filtering, tree rendering, and output formatting -### TUI (`src/tui/*`) -- Interactive terminal UI with tree view, details pane, and OpenCode integration +### Pi extension (`packages/tui/extensions/Worklog/`) +- Agent-side Pi plugin: activity indicator, session health/model display, + guardrails, skill-path tool, error recovery (`/retry`), lease release +- The Pi-based TUI browse UI (chat pane, action palette, `/wl` command) was + removed; work item browsing is provided by the Herdr plugin ## File Structure @@ -67,7 +70,6 @@ All requirements from the problem statement have been successfully implemented: Worklog/ ├── src/ │ ├── commands/ # CLI command implementations -│ ├── tui/ # Terminal UI components │ ├── types.ts # Type definitions │ ├── database.ts # Worklog database facade │ ├── persistent-store.ts # SQLite persistence @@ -95,7 +97,6 @@ Worklog/ ├── GIT_WORKFLOW.md # Team collaboration guide ├── DATA_SYNCING.md # Git sync and GitHub mirroring ├── PLUGIN_GUIDE.md # Plugin development guide -├── TUI.md # Terminal UI documentation └── .gitignore # Git ignore rules ``` diff --git a/LOCAL_LLM.md b/LOCAL_LLM.md index d50ae9d35..24bcb34ae 100644 --- a/LOCAL_LLM.md +++ b/LOCAL_LLM.md @@ -10,7 +10,9 @@ This document uses two concrete examples so the steps are easy to follow. There' This includes: -- The **TUI OpenCode dialog** today (press `O` in `wl tui`) +- The **TUI OpenCode dialog** (legacy, removed — the Pi-based TUI that + replaced it was also removed; work item browsing is now provided by the + Herdr plugin) - Future **LLM-powered CLI commands** (e.g., issue/work-item management helpers) The goal is to make it easy for agents to leverage **local compute** for tasks that don’t require a massive cloud-hosted model running on a huge GPU, while still allowing optional cloud providers when they’re genuinely needed. @@ -24,7 +26,7 @@ Worklog does **not** call any model provider directly. 1. Worklog starts (or connects to) an LLM provider. By default it does this through an **OpenCode server** (`opencode serve`) 2. OpenCode server talks to a **model provider** (Ollama locally, Foundry Local, cloud providers, or any other provider you configure) -See [docs/opencode-tui.md](docs/opencode-tui.md) for the current TUI integration details. +See the [Herdr plugin](packages/herdr/) for the current work item UI. --- @@ -198,17 +200,14 @@ Document your chosen OpenCode settings here once confirmed: - Base URL: `http://localhost:11434/...` (TBD) - Model: `llama3.1` (example) -### Run Worklog TUI with OpenCode (Ollama) +### Run Worklog with a local LLM (Ollama) + +The `wl` CLI itself does not call a model provider directly. Local models can +be used by agents via standard provider configuration (e.g. Ollama +`http://localhost:11434/`): ```powershell $env:OPENCODE_SERVER_PORT = 51625 -wl tui -``` - -Press `O`, wait for `[OK]`, then try: - -``` -Summarize the selected work item in 3 bullets. ``` --- diff --git a/README.md b/README.md index f1c7a227f..5422c3174 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # Worklog -A lightweight, Git-friendly issue tracker designed for AI agents and development teams. Track hierarchical work items with a CLI, interactive TUI, or REST API -- all backed by SQLite with JSONL-based Git syncing. +A lightweight, Git-friendly issue tracker designed for AI agents and development teams. Track hierarchical work items with a CLI or REST API -- all backed by SQLite with JSONL-based Git syncing. ## Features -- **CLI + TUI + API**: Manage work items from the command line, an interactive terminal UI, or a REST API +- **CLI + API**: Manage work items from the command line or a REST API - **Git-Friendly Syncing**: JSONL format enables seamless team collaboration via Git with automatic conflict resolution - **Hierarchical Work Items**: Parent-child relationships for organizing epics, features, and tasks - **Plugin System**: Extend the CLI with custom commands (see [Plugin Guide](PLUGIN_GUIDE.md)) -- **AI Agent Integration**: Built-in Pi agent with real-time streaming, interactive agent chat pane, and agent-driven action palette. +- **AI Agent Integration**: Pi agent plugin modules that auto-load into every session — activity indicator, session health, model/provider display, guardrails, error recovery (`/retry`), and skill-path tool (see [Pi extension](packages/tui/extensions/README.md)). - **Heartbeat Skill**: Automated work item monitoring via the Pi agent — run `/skill:heartbeat` to flag items needing producer review or to audit completed items for closure readiness (see [skill/heartbeat/SKILL.md](skill/heartbeat/SKILL.md)). - **Multi-Project Support**: Custom prefixes for issue IDs per project @@ -59,26 +59,17 @@ wl github push # add --verbose for a per-item synced list + timing brea wl github import ``` -### Using the TUI +### Pi agent plugin -```bash -wl tui # Launch Pi-based TUI (interactive browse + agent chat) -wl tui --in-progress # Launch Pi-based TUI, show only in-progress items -wl tui --perf # Enable performance instrumentation and write diagnostics artifacts under .worklog/ -wl tui --perf # Launch Pi-based TUI with performance instrumentation -``` - -Press `O` in the TUI to access the Pi agent chat pane. The Pi-based TUI provides natural language chat, an action palette with agent-driven flows, and all work item operations through the `wl` CLI. See [TUI.md](TUI.md) for controls, including quick stage filters (`Alt+T` for `intake_complete`, `Alt+P` for `plan_complete`) that exclude closed items. - -For the Pi-based TUI design checklist, see [docs/ux/design-checklist.md](docs/ux/design-checklist.md). - -For freeze triage and profiling details (including `TUI_CHORD_DEBUG`, `strace`, and artifact locations), see [docs/TUI_PROFILING.md](docs/TUI_PROFILING.md). - -### Install the Pi Worklog browse extension - -The repository includes a Pi extension that adds a Worklog browse flow (`/wl` and `Ctrl+Shift+B`) which lists the next 5 recommended work items (`wl next -n 5`) and previews the selected item in a widget above the editor as selection changes (`title `, `Priority/Stage/Status`, `Risk/Effort`, and the first 7 description lines). The top-level list is root-only: child work items are hidden and only appear under their parent via Tab drill-down (children are reachable through `wl list --parent `). Pressing Enter on a selected item opens a focused scrollable detail view backed by `wl show --format markdown`, with keyboard navigation support for Up/Down, PageUp/PageDown, Space, `g` (top), `G` (bottom), and `Esc` (close). +The repository includes a Pi extension that auto-loads into every pi session +(via a global symlink under `~/.pi/agent/extensions`). It provides the +agent-side capabilities that complement the Herdr plugin: an activity +indicator and session health/model display in the pi footer, guardrails that +protect worklog database files, a `skill_path` tool, automatic error recovery +(`/retry`), and proactive Local Proxy model lease release. Work item browsing +and management is provided by the [Herdr plugin](packages/herdr/). -Install it globally by creating a symlink under `~/.pi/agent/extensions`: +Install the extension globally: ```bash npm run install:pi-extension @@ -117,7 +108,6 @@ You can get a lot of value from using Worklog as a memory for your agents. But y | Document | Description | |----------|-------------| | [Heartbeat Skill](skill/heartbeat/SKILL.md) | Automated work item monitoring and audit orchestration for the Pi agent | -| [TUI.md](TUI.md) | Interactive terminal UI controls and features | | [PLUGIN_GUIDE.md](PLUGIN_GUIDE.md) | Plugin development guide and API reference | | [LOCAL_LLM.md](LOCAL_LLM.md) | Configure local LLM providers (Ollama, Foundry) | | [MULTI_PROJECT_GUIDE.md](MULTI_PROJECT_GUIDE.md) | Multi-project setup with custom prefixes | @@ -141,12 +131,11 @@ You can get a lot of value from using Worklog as a memory for your agents. But y | Document | Description | |----------|-------------| | [docs/opencode-to-pi-migration.md](docs/opencode-to-pi-migration.md) | Migration guide from OpenCode to Pi framework | -| [docs/ux/design-checklist.md](docs/ux/design-checklist.md) | Pi-based TUI design checklist | -| [docs/tui-ci.md](docs/tui-ci.md) | Headless TUI testing for CI | | [docs/migrations.md](docs/migrations.md) | Database migration system | | [docs/prd/sort_order_PRD.md](docs/prd/sort_order_PRD.md) | Sort order product requirements | | [docs/validation/status-stage-inventory.md](docs/validation/status-stage-inventory.md) | Status/stage validation rules | | [docs/SKILL_AUTHORING.md](docs/SKILL_AUTHORING.md) | Skill authoring guide with script best practices | +| [docs/dev/wl-process-healthcheck.md](docs/dev/wl-process-healthcheck.md) | wl process healthcheck watchdog usage (cron/systemd, thresholds) | ## Tutorials @@ -157,7 +146,6 @@ Step-by-step guides for learning Worklog: | [Your First Work Item](docs/tutorials/01-your-first-work-item.md) | New users | Install, init, create, update, and close work items | | [Team Collaboration](docs/tutorials/02-team-collaboration.md) | Team leads | Git sync, GitHub mirroring, multi-user workflow | | [Building a Plugin](docs/tutorials/03-building-a-plugin.md) | Developers | Plugin API, database access, testing | -| [Using the TUI](docs/tutorials/04-using-the-tui.md) | Any user | Interactive tree view, keyboard shortcuts, Pi agent chat, action palette | | [Planning an Epic](docs/tutorials/05-planning-an-epic.md) | Project leads | Epics, child items, dependencies, wl next | See [docs/tutorials/README.md](docs/tutorials/README.md) for the full tutorial index. @@ -198,7 +186,6 @@ npm run dev # Development mode with auto-reload npm test # Run all tests npm run test:watch # Tests in watch mode npm run test:coverage # Tests with coverage report -npm run test:tui # TUI tests only (CI/headless) ``` ### Vitest Configuration diff --git a/TUI.md b/TUI.md deleted file mode 100644 index ea5abc1e1..000000000 --- a/TUI.md +++ /dev/null @@ -1,80 +0,0 @@ -# Worklog TUI - -The Worklog TUI is a Pi-based interactive terminal UI that provides a unified -agent chat + work item management experience. It is available via the `wl tui` -and `wl piman` commands (they are aliases for each other). - -## Overview - -The TUI launches the [Pi coding agent](https://github.com/earendil-works/pi-coding-agent) -with worklog extensions pre-loaded, providing: - -- **Browse view**: list and select recommended work items with keyboard-driven navigation -- **Detail view**: full work item details, comments, and audit results -- **Shortcut keys**: configurable keyboard shortcuts for common workflows (implement, plan, audit, intake) -- **Agent chat**: natural language interaction for work item management - -## Usage - -```bash -# Launch the TUI -wl tui -wl piman # same as wl tui - -# Show only in-progress items -wl tui --in-progress -wl piman --in-progress - -# Include completed/deleted items -wl tui --all - -# Override the default prefix -wl tui --prefix PREFIX - -# Enable performance instrumentation -wl tui --perf -``` - -## Architecture - -The TUI is implemented as a Pi extension located in `packages/tui/`: - -- `packages/tui/pi.json` — Extension configuration and entry points -- `packages/tui/extensions/index.ts` — Main extension that registers the `/wl` browser command -- `packages/tui/extensions/Worklog/chatPane.ts` — Chat pane for natural language work item management -- `packages/tui/extensions/Worklog/actionPalette.ts` — Keyboard-first action palette -- `packages/tui/extensions/wl-integration.ts` — Integration layer for executing wl CLI commands -- `packages/tui/extensions/Worklog/shortcut-config.ts` — Config-driven keyboard shortcut system -- `packages/tui/extensions/Worklog/shortcuts.json` — Default shortcut definitions - -## Features - -### Browse & Select - -On launch, the TUI shows a list of recommended next work items (root items only — child work items are hidden and appear only under their parent via drill-down). Navigate with -Up/Down arrows, press Enter to see full details, or use shortcut keys: - -- **i** — insert `implement ` into the editor -- **p** — insert `plan ` into the editor -- **n** — insert `intake ` into the editor -- **a** — insert `audit ` into the editor - -### Agent Chat - -Natural language interaction for work item operations. Type commands like: -- "list work items" -- "show WL-123" -- "create a work item: fix login bug" -- "claim next task" - -### Settings - -Press `/wl settings` to open the settings overlay where you can configure: -- Number of items to browse (3, 5, 10, 15, or 20) -- Show/hide icons in the browse list - -## See Also - -- [Pi TUI Extensions README](packages/tui/extensions/README.md) — Details on the - config-driven shortcut system and extension architecture -- `docs/tutorials/04-using-the-tui.md` — Tutorial for getting started with the TUI diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bb5807919..1f8dc6356 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -206,6 +206,124 @@ consumers agree because they share the same resolver. - **Git push**: Depends on network (typically 1-5s) - **Import**: ~100ms per 1000 items +## Read Cache (src/read-cache.ts, src/read-cache-cli.ts) + +Pure-read commands (`list`, `next`, `show`, `search`, `status` in JSON mode) +cache their results on disk so repeated, byte-identical queries are served +without re-spawning work. + +### Cache layout + +- **Location**: XDG cache dir — `$WL_CACHE_DIR` if set, else + `$XDG_CACHE_HOME/wl`, else `~/.cache/wl`. +- **Entry files**: `/<64-hex-sha256>.json`, one per query. +- **State counters**: `/state/<64-hex-sha256>.json` — one + monotonic write counter per worklog dir (see below). +- **Cache key**: SHA-256 over (wl version, resolved absolute worklog dir, + argv elements in order). The version is included so a wl upgrade with a + changed output schema never serves stale-shaped entries. +- **Entry header** stores the key inputs plus a DB-state fingerprint captured + at write time. + +### Fingerprint model + +Two fingerprint providers exist; the CLI uses the counter-based one: + +- **File fingerprint (module default, `computeDbFingerprint`)**: `[mtimeMs, + size]` of `worklog.db`, `worklog.db-wal` and `worklog.db-shm`. This is + WAL-aware (writes land in the WAL before checkpoint) but **not stable + across processes**: the app's own `journal_mode = WAL` pragma + schema/FTS + init rewrite the DB header on every open/close, so consecutive read + processes see different file states. +- **State counter (CLI, `counterFingerprint`)**: a monotonic per-worklog-dir + integer in the cache dir. It is stable across reads (reads never touch it) + and changes exactly when a write lands, so it is the CLI's fingerprint of + record. The file fingerprint remains available for direct module use. + +### Invalidation semantics (CLI wiring) + +- **Writes invalidate**: write commands (`create`, `update`, `close`, + `delete`, `comment`, `dep`, `import`, `sync`, `github`, …) bump the state + counter in a `preAction` hook *before* their action mutates the DB, and + drop entry files — so an identical read immediately after a write returns + fresh data. +- **Sync invalidates again after success** (F3): a successful `wl sync` + bumps the counter a second time *after* the pull/merge/push completes + (`src/commands/sync.ts`), so entries cached by concurrent readers DURING + the sync window (at the post-preAction counter) are also never served — + post-pull reads always return the merged data. Skipped (lock-busy) and + failed syncs exit before this point and do not invalidate. +- **Read write-byproducts count as writes**: `next`'s auto re-sort bumps the + counter after it lands (only when it actually changed sort indices), so + cached `next`/`list` results stay sound; `search --rebuild-index` is + excluded from caching outright and invalidates. +- **Mid-action race guard**: a backfill is only stored if the state counter + is unchanged since the pre-action lookup (a concurrent write bumped it). +- **TTL bounding safety net**: entries older than the TTL (default 30s, + aligned with the Herdr 30s refresh cadence) are never served. This also + bounds staleness from writes the counter misses (e.g. external tools + writing the DB directly). +- **JSON mode only**: text output is env/TTY dependent and never cached. + `--semantic`/`--semantic-only` (external embedding API) are never cached. + +### CLI dispatch + +`src/cli.ts` checks the cache for cacheable read invocations *before* +`program.parse()` — on a hit it prints the cached payload (byte-identical to +`output.json` formatting) and exits without running any action or opening the +DB. Misses run normally; the JSON payload emitted via `output.json` is routed +through `ReadCacheCli.onJsonOutput` for backfill. + +### Cross-instance sync heartbeat (F3, herdr auto-sync) + +`wl sync` writes `/last-sync-time` (ISO timestamp) only on +success — this doubles as the per-worklog-dir sync heartbeat. Herdr's +background auto-sync (`packages/herdr/src/auto-sync.ts`) checks the heartbeat +before spawning `wl sync` and skips — without spawning a process — when a +sync succeeded within the freshness window (`heartbeatTtlMs`). + +- The TTL is the sync interval minus a 15s margin + (`heartbeatTtlForInterval`), so the sync cadence still lands ~once per + interval across all panes while only the first pane per window spawns + (6 panes → 1 spawn, measured in `auto-sync.test.ts`). +- Failed or skipped syncs never refresh the marker (the CLI writes it only on + success; dry-runs return before the write), so a stale heartbeat always + falls out of the window and a real sync eventually runs again (no + indefinite skip). +- Manual user syncs (pane `S`) bypass the heartbeat check and always run. + +### In-process fetch memoization (F4, herdr fetcher) + +`packages/herdr/src/fetcher.ts` dedupes concurrent identical `runWl` READ +fetches within one pane process via an in-flight promise memo keyed by +(worklog-dir, args, json-mode): racing refresh ticks that issue the same +query spawn `wl` once and share the result. Writes are never memoized — a +write clears the memo so a read issued after it cannot share a pre-write +in-flight result. The memo is per-process, bounded (64 in-flight entries), +and cleared on `setWorklogDir`/`resetWorklogDir`. + +### Spawn instrumentation (spawn-reduction measurement) + +Env-gated counters (`src/spawn-counter.ts`): `WL_SPAWN_COUNT_FILE=` +appends one `\t\t` line per recorded spawn; +`WL_SPAWN_COUNT=1` (no file) logs `[wl:spawn]` lines to stderr. A cache +miss records `read-work`; a cache hit records `cache-hit`. With the cache +disabled (`WL_CACHE_DISABLED=1`), cacheable reads record `read-work` too, so +the baseline is measurable. `tests/cli/read-cache-spawn-reduction.test.ts` +simulates a 6-pane refresh and asserts ≥60% fewer work spawns. + +### Concurrency & bounding + +- **Atomic writes**: entries are written to a temp file (`.json.tmp-*`) + then renamed into place, so concurrent readers/writers across processes + never observe partial entries. +- **LRU bound**: when the entry count exceeds `maxEntries` (default 1000), + TTL-expired entries are dropped first, then least-recently-accessed entries + are evicted. +- **Observability**: set `WL_CACHE_DEBUG=1` to log cache hits/misses/reasons to + stderr (`[wl:cache]`). `ReadCache.stats()` exposes hit/miss counters for + spawn-reduction instrumentation. + ## Future Considerations ### Potential Enhancements diff --git a/docs/FILE_PATH_CONVENTION.md b/docs/FILE_PATH_CONVENTION.md index c48719626..99b60f2d0 100644 --- a/docs/FILE_PATH_CONVENTION.md +++ b/docs/FILE_PATH_CONVENTION.md @@ -4,7 +4,7 @@ The `wl next --groups/-g` grouping feature determines parallel-work safety by extracting file paths from work item descriptions. To make this work reliably, -all `intake_complete` and `plan_complete` work items **should** include a +all `in_progress`, `intake_complete`, and `plan_complete` work items **should** include a `**Key Files:**` or `## Key Files` section listing the files the work item will touch. @@ -113,9 +113,9 @@ Section exists but no valid paths: | Stage | Required? | |---|---| | `idea` | No (optional) | +| `in_progress` | **Should** include (groups items into `Group N`) | | `intake_complete` | **Should** include (groups items into `Group N`) | | `plan_complete` | **Should** include (groups items into `Group N`) | -| `in_progress` | Optional | | `in_review` | No | ### Group display order @@ -123,9 +123,9 @@ Section exists but no valid paths: `wl next -n N` displays groups in this order (WL-0MSAK8YLB0025EGW): 1. **Critical Group N** — `critical` priority items partitioned by file-path conflicts (items sharing a file path land in different groups; items with unknown paths get singleton groups). -2. **Group N** — non-critical `plan_complete` + `intake_complete` items partitioned by file-path conflicts. Within each group, `plan_complete` items appear first, then `intake_complete` items (no headings between sub-groups), each sorted by priority (high → medium → low). +2. **Group N** — non-critical `in_progress` + `plan_complete` + `intake_complete` items partitioned by file-path conflicts. Within each group, `in_progress` items appear first, then `plan_complete`, then `intake_complete` items (no headings between sub-groups), each sorted by priority (high → medium → low). The same within-group stage sub-sort applies inside each `Critical Group N`. 3. **Idea** — single group, sorted by priority. -4. **Other** — single group for all remaining items. +4. **Other** — single group for all remaining items (safety net for unknown/custom stages and stale stage/status combinations; empty for all canonical stages in the default selection list). 5. **In Review** — single group (last). ### Programmatic Access diff --git a/docs/TUI_PROFILING.md b/docs/TUI_PROFILING.md deleted file mode 100644 index 9f06a7776..000000000 --- a/docs/TUI_PROFILING.md +++ /dev/null @@ -1,126 +0,0 @@ -# TUI Profiling and Freeze Diagnostics - -Use this guide to collect reproducible diagnostics for intermittent TUI keyboard freezes (for example: input pauses for 30–60 seconds and then recovers). - -## Enable profiling - -Profiling is **off by default**. - -### Option A: `--perf` (recommended) - -```bash -npm run cli -- tui --perf -# or -wl tui --perf -``` - -This enables: - -- performance timing metrics (expand/collapse, scroll, etc.) -- keypress diagnostic events -- event-loop lag detection - -### Option B: environment flag - -```bash -TUI_PROFILE=1 npm run cli -- tui -# or -TUI_PROFILE=1 wl tui -``` - -Useful when you want diagnostics without changing command arguments. - -## Optional chord-level debug logging - -```bash -TUI_CHORD_DEBUG=1 TUI_LOG_VERBOSE=1 TUI_LOGFILE=./tui-debug.log npm run cli -- tui --perf -``` - -This records low-level chord activity to the file in `TUI_LOGFILE`. - -Note: `--perf` / `TUI_PROFILE=1` no longer imply verbose file logging by default. This avoids logging overhead on the keypress hot path while still collecting profiling artifacts. - -## Known freeze reproduction path (WSL / slow-mounted filesystem) - -Use this scenario to reproduce key-input stalls observed in WSL/Windows Terminal setups: - -1. Write debug logs to a slow mount (for example `/mnt/c/...` in WSL). -2. Enable high-volume chord/debug logging. -3. Hold navigation keys (`j`, `k`, arrows) for 10–30 seconds. - -Example: - -```bash -TUI_CHORD_DEBUG=1 TUI_LOG_VERBOSE=1 TUI_LOGFILE=/mnt/c/Users//wl-tui-debug.log wl tui --perf -``` - -Expected signal during a bad run: - -- `.worklog/tui-profiling-*.jsonl` contains repeated `event_loop_lag` entries. -- `keypress.handlerDurationMs` rises sharply during lag windows. - -## Root-cause hypothesis and mitigation - -Validated RCA from field traces (Tableau-Card-Engine repro logs): - -1. **Long freeze class (~30s):** main-thread stalls aligned with database refresh lock contention (`scheduleRefreshFromDatabase` and `renderListAndDetail` blocked for ~31s). -2. **Residual sluggish class (~1–2s repeated lag):** frequent file-watch-triggered refreshes caused repeated full tree/detail re-renders even when the dataset did not change. - -Mitigation implemented in this branch: - -- TUI file logging now buffers and flushes asynchronously. -- Logging writes are bounded by an in-memory queue cap to prevent unbounded growth under sustained input. -- TUI mode now uses a shorter SQLite busy timeout to avoid multi-second UI stalls when the DB is contended. -- Watch-refresh path now skips expensive re-render work when the refreshed dataset fingerprint is unchanged (diagnostic event: `refresh_skipped_unchanged`). - -You can tune SQLite busy timeout explicitly: - -```bash -WL_SQLITE_BUSY_TIMEOUT_MS=250 wl tui --perf -``` - -## Output artifacts and default locations - -When profiling is enabled, artifacts are written under your worklog directory (typically `.worklog/`): - -- `.worklog/tui-performance.json` — structured performance metrics -- `.worklog/tui-profiling--.jsonl` — JSONL diagnostics (keypress events, event-loop lag events, startup/shutdown markers) - -If `TUI_LOG_VERBOSE=1` is also enabled: - -- `./tui-prototype.log` by default, or `TUI_LOGFILE=` when provided - -## Collect system-call traces (Linux / WSL) - -Use `strace` while reproducing the freeze: - -```bash -strace -tt -f -o /tmp/wl-tui.strace wl tui --perf -``` - -Then attach these to the work item: - -- `.worklog/tui-performance.json` -- `.worklog/tui-profiling-*.jsonl` -- `tui-debug.log` (if enabled) -- `/tmp/wl-tui.strace` - -## Optional terminal session capture - -```bash -asciinema rec /tmp/wl-tui-freeze.cast -wl tui --perf -# reproduce freeze, then exit and Ctrl-D -``` - -## Interpreting diagnostics quickly - -- `event_loop_lag` records indicate the event loop was delayed beyond `TUI_EVENT_LOOP_LAG_MS` (default: 200ms). -- `keypress` events show key metadata and whether chords consumed the key. -- Long stretches without `keypress` processing, or repeated lag spikes, are strong indicators of blocking work on the main thread. - -You can tune lag sensitivity: - -```bash -TUI_EVENT_LOOP_LAG_MS=100 wl tui --perf -``` diff --git a/docs/dev/implement-worktree-node-modules-verification.md b/docs/dev/implement-worktree-node-modules-verification.md new file mode 100644 index 000000000..c6ef0383e --- /dev/null +++ b/docs/dev/implement-worktree-node-modules-verification.md @@ -0,0 +1,59 @@ +# Worktree node_modules — end-to-end verification (WL-0MSGQ64N2006TQB2) + +## Problem + +`implement.py start` creates a fresh git worktree with no `node_modules`, so +tests that spawn the CLI via `path.join(projectRoot, 'node_modules', '.bin', +'tsx')` failed with MODULE_NOT_FOUND. The 9 read-cache e2e tests +(`tests/cli/read-cache-e2e.test.ts`) failed on every `implement.py finish` +for worktree-based items. + +## Fixes (both shipped before this verification) + +1. **implement.py auto-symlink** — SorraAgents SA-0MSGS763C006SM1B, commit + `03344c5c` on dev: `implement.py start` symlinks + `/node_modules -> /node_modules` when the main + checkout has one and the worktree lacks one. +2. **resolveTsxBin walk-up** — ContextHub commit `613f1490`: + `tests/test-utils.ts` `resolveTsxBin()` walks up parent directories to + find a real `node_modules/.bin/tsx`, used by all read-cache test files. + +## AC1 verification (2026-08-07, fresh worktree at dev HEAD `c6c26688`) + +Fresh worktree created by `implement.py start WL-0MSGQ64N2006TQB2` with the +auto-symlink in place, then verified in multiple configurations: + +| Configuration | pytest | vitest (full suite) | read-cache e2e | +|---|---|---|---| +| Worktree with symlink (start default) | 103 passed | 4071 passed (223 files) | 9 passed | +| Worktree, **symlink removed** (no node_modules) | — | **4071 passed** (223 files) | **9 passed** | + +- `npm run build` (build:shared + tsc + generate-version + herdr postbuild) + succeeds both with and without the symlink (npm resolves `.bin` from the + ancestor main-checkout `node_modules`). +- Initial full-suite run before build showed 40 failures across 10 files — + all caused by the missing `dist/` directory in the fresh worktree + (gitignored, not checked out), not by node_modules. After `npm run build` + the suite is fully green, confirming `implement.py finish`'s + build → test ordering is correct. +- `tests/cli/read-cache-e2e.test.ts` (9 tests) and + `tests/cli/read-cache-spawn-reduction.test.ts` (1 test) pass in all + configurations. + +## AC2 — no repo pollution + +`node_modules`, `dist` and `.worklog` are gitignored; the symlink and build +artifacts live inside the gitignored worktree. `git status` in the worktree +shows only the intended verification doc. + +## AC3 — main checkout + +Main checkout at the same commit (`c6c26688`) with its own `node_modules` +present — unchanged behavior; suite green (verified previously, 2026-08-07). + +## Conclusion + +The failure mode described in WL-0MSGQ64N2006TQB2 is fully resolved. The +`resolveTsxBin` walk-up alone (fix 2) makes the suite green in a worktree +with no node_modules at all; the implement.py auto-symlink (fix 1) remains +as belt-and-braces and is the default state created by `implement.py start`. diff --git a/docs/dev/wl-process-healthcheck.md b/docs/dev/wl-process-healthcheck.md new file mode 100644 index 000000000..b9c88a446 --- /dev/null +++ b/docs/dev/wl-process-healthcheck.md @@ -0,0 +1,101 @@ +# wl Process Healthcheck + +Lightweight watchdog that detects accumulation of concurrent `wl`/`worklog` +CLI processes before it degrades the host. Motivation and design rationale +come from the [wl process spawning investigation](./wl-process-spawning-investigation.md) +(206 concurrent wl processes, ~17 GB RAM, host load 150–225). + +## How it works + +`scripts/wl-process-healthcheck.sh` counts node `wl`/`worklog` CLI processes +(`ps`-based, no external dependencies) and classifies the result: + +| Level | Count | Action | +|-------|-------|--------| +| OK | < watch threshold (20) | log count to `/tmp/wl-healthcheck-count`, exit 0 | +| WATCH | 20–50, or above 50 but not yet sustained | log count to `/tmp/wl-healthcheck-count`, exit 0 | +| ALERT | > 50 for 3 consecutive checks | emit process tree (`ps -eo pid,ppid,etime,args`), exit 2 | + +The current count is written to `/tmp/wl-healthcheck-count` (overridable via +`--count-file`) on OK/WATCH levels. Consecutive high readings are tracked in +`/tmp/wl-healthcheck-ticks` (overridable via `--ticks-file`) and reset on any +OK/WATCH reading or after a completed alert. + +### Exit codes + +| Code | Meaning | +|------|---------| +| 0 | OK or WATCH (count logged) | +| 2 | ALERT — sustained high count; process tree emitted to stderr | + +## Usage + +```bash +# One-off check +./scripts/wl-process-healthcheck.sh + +# JSON output for scripts / monitoring +./scripts/wl-process-healthcheck.sh --json +``` + +### Options + +``` +--alert-threshold N Alert above N concurrent wl processes (default 50) +--watch-threshold N Watch at N concurrent wl processes (default 20) +--sustained N Require N consecutive high readings (default 3) +--count-file PATH File receiving the current count (default /tmp/wl-healthcheck-count) +--ticks-file PATH File tracking consecutive high readings (default /tmp/wl-healthcheck-ticks) +--json Emit machine-readable JSON to stdout +-h, --help Show help and exit +``` + +### cron (every 5 minutes) + +```cron +*/5 * * * * /path/to/worklog/scripts/wl-process-healthcheck.sh >> /var/log/wl-healthcheck.log 2>&1 +``` + +### systemd timer (every 5 minutes) + +`/etc/systemd/system/wl-healthcheck.service`: + +```ini +[Unit] +Description=wl process healthcheck watchdog +After=network.target + +[Service] +Type=oneshot +ExecStart=/path/to/worklog/scripts/wl-process-healthcheck.sh +``` + +`/etc/systemd/system/wl-healthcheck.timer`: + +```ini +[Unit] +Description=Run wl process healthcheck every 5 minutes + +[Timer] +OnBootSec=5min +OnUnitActiveSec=5min + +[Install] +WantedBy=timers.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now wl-healthcheck.timer +``` + +On ALERT the exit code is non-zero, so cron/systemd mail-on-error or a +monitoring hook (herdr toast / terminal notification) can page the operator. +Typical usage: alert only when the count is *sustained* for several checks, +so transient spikes during interactive use do not page anyone. + +## CI regression gate + +The CLI test workflow (`.github/workflows/cli-tests.yml`) asserts after the +test suite that zero orphaned `tsx src/cli.ts` processes remain, covering the +test-harness orphan fix (WL-0MSB447TJ000R3N8). A regression there fails CI. diff --git a/docs/dev/wl-process-spawning-investigation.md b/docs/dev/wl-process-spawning-investigation.md index 63d605c70..ec30e029d 100644 --- a/docs/dev/wl-process-spawning-investigation.md +++ b/docs/dev/wl-process-spawning-investigation.md @@ -324,7 +324,9 @@ exit 0 ### 6.4 Where to put it - Repo-local: `scripts/wl-process-healthcheck.sh` + a systemd timer or cron - entry in the affected projects. + entry in the affected projects. **Implemented:** see + [wl-process-healthcheck.md](./wl-process-healthcheck.md) for the shipped + script, thresholds, and cron/systemd usage. - Framework-level (optional): a `pi` status footer showing current wl process count, or a periodic check in the herdr plugin. diff --git a/docs/guardrails.md b/docs/guardrails.md index 172faf800..0180c57d6 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -61,10 +61,8 @@ The following shell command patterns are detected and blocked: ## Configuration -Guardrails can be enabled or disabled via the extension settings: +Guardrails can be enabled or disabled via the settings file: -- **Extension settings overlay**: Use `/wl settings` in the pi TUI and - toggle the "Data guardrails" option - **Settings file**: Set `guardrailsEnabled` in `.pi/settings.json` under the `context-hub` namespace: diff --git a/docs/icons-design.md b/docs/icons-design.md index b2ba57c26..839a8081b 100644 --- a/docs/icons-design.md +++ b/docs/icons-design.md @@ -159,10 +159,11 @@ fallback** is used instead. See §8 below. Every icon MUST carry an equivalent accessible label so that screen readers and tooling that parses CLI output can identify the icon's meaning. -### 9.1 TUI Output +### 9.1 CLI/Plugin Output -The TUI uses the Pi-based rendering framework which supports accessible labels -natively. Icons can be annotated via the framework's built-in label system. +The retained Pi extension modules (session health footer) and CLI output +use `chalk` for colour. Icons can be annotated via the framework's built-in +label system where supported. ### 9.2 CLI Output @@ -291,109 +292,33 @@ export function iconsEnabled(opts?: { noIcons?: boolean }): boolean; ## 13. Implementation Guide -### 13.1 Pi TUI List Rendering (`packages/tui/extensions/index.ts`) +### 13.1 CLI List Rendering -The Pi TUI browse selection list renders status, stage (or audit-aware icon -for `in_review`), and producer review flag icons before the title in each row. -The third column (previously audit result) now shows the producer review flag -for all stages. The layout is: - -- **Column 1**: Status icon (🔓 open, 🔄 in-progress, ✔️ completed, etc.) -- **Column 2**: Stage icon (💡 idea, 📥 intake, 📋 plan, 🛠️ progress, 🏁 done) - For `in_review` stage, this column becomes audit-aware: - - 🟩 (stale-passed icon) — if the audit is stale but readyToClose === true - (auditedAt <= updatedAt - 60 seconds, but auditResult === true) - - 🔍 (stage icon) — if no audit exists, or the audit is stale - with readyToClose !== true - - ✅ — if a fresh audit exists and readyToClose === true - - ❌ — if a fresh audit exists and readyToClose === false -- **Column 3**: Producer review flag (❌ needs review, ✅ review complete) - Replaces the audit result icon for all stages. -- **Column 4 (optional)**: Epic icon + child count for epic items - -Examples: - -``` -🔄 🛠️ ✅ 🏰(5) Epic feature name ← when icons enabled -[INPR][PROG][PRODUCER_OK][EPIC](5) Epic feature name ← when fallback - -🔄 🔍 ✅ 🏰 Epic feature name ← in_review, no audit -[INPR][REVIEW][PRODUCER_OK][EPIC] Epic feature name ← when fallback - -🔄 🟩 ✅ 🏰 Epic feature name ← in_review, stale audit but passed -[INPR][YES_STALE][PRODUCER_OK][EPIC] Epic feature name ← when fallback - -🔄 ✅ ❌ Regular task ← in_review, fresh audit pass, needs producer review -[INPR][YES][NEEDS_PRODUCER] Regular task ← when fallback -``` - -### Audit Staleness - -The staleness check uses a 60-second buffer to prevent the audit's own -timestamp from falsely appearing as "fresh": - -``` -audit is fresh when: auditedAt > updatedAt - 60000 (milliseconds) -audit is stale when: auditedAt <= updatedAt - 60000 -``` - -When no audit exists or the audit is stale without a pass result -(`auditResult !== true`), column 2 shows the normal `in_review` stage icon -(🔍 / `[REVIEW]`). When the audit is stale but `readyToClose === true`, -column 2 shows the stale-passed icon (🟩 / `[YES_STALE]`). - -The `formatBrowseOption` function prepends the icons before the title. -The icon prefix (status + stage/producer + optional epic icon/child count) -is padded to a fixed visible width via per-list dynamic padding so that -titles start at the same column position across all rows. The padding is -computed as the maximum icon prefix width across all items in the current -list, and each item's prefix is padded to that width with spaces. -See `getIconPrefix()` in the same module for the prefix computation. -The `buildSelectionWidget` preview uses a different format (ID/tags/GH/risk-effort) -without the icon prefix. It shows a single-line summary with risk and effort -icons appended as a final pipe-separated segment at the end: +Icons are prepended before the title in CLI list output: ``` -WL-001 | tags: tui | GH #608 | 🐇 🌱 ← when icons enabled -WL-001 | tags: tui | GH #608 | [S] [MED] ← when fallback +🔓 ✅ Set up CI pipeline ← when icons enabled +[OPEN][PRODUCER_OK] Set up CI pipeline ← when fallback ``` -When effort and/or risk are undefined, the corresponding icon is omitted. -If both are missing, the final segment is omitted entirely. - -### 13.2 TUI List Rendering - -The Pi-based TUI renders list items via the `packages/tui/extensions/` -folder. Icons are prepended before the title in the browse list. -{white-fg}[OPEN]{/white-fg} Set up CI pipeline ← when fallback -``` +> The Pi-based TUI browse list (which rendered status/stage/producer-review/ +> epic icons via `getIconPrefix` in `packages/tui/extensions/Worklog/lib/browse.ts`) +> has been removed — work item browsing is now provided by the Herdr plugin. +> The `dist/icons.ts` functions remain in use by the CLI and the retained +> session-health footer. -The `formatTitleOnlyTUI` helper in `src/commands/helpers.ts` may be extended -or a new wrapper created that injects the icon before the title. +### 13.2 CLI Detail Output -### 13.3 TUI Detail Pane - -File: `src/tui/components/detail.ts`, `src/tui/components/metadata-pane.ts` +File: `src/cli-output.ts`, `src/commands/helpers.ts` (`humanFormatWorkItem`) -The metadata pane already shows `Status:` and `Priority:` lines. These lines -should be updated to include the icon: +The status and priority display lines include the icon: ``` Status: 🟢 [OPEN] Priority: 🔴 [CRIT] ``` -### 13.4 CLI Output - -File: `src/cli-output.ts`, `src/commands/helpers.ts` (`humanFormatWorkItem`) - -The status and priority display lines should include the icon: - -``` -Status: 🟢 Open | Priority: 🔴 Critical -``` - -### 13.5 Tests +### 13.3 Tests Tests should verify: - Icon functions return expected emoji for valid inputs @@ -413,9 +338,9 @@ const useIcons = iconsEnabled({ noIcons: opts.noIcons }); // In a list renderer: const icon = priorityIcon(item.priority, { noIcons: !useIcons }); -const line = `${icon} ${formatTitleOnlyTUI(item)}`; +const line = `${icon} ${item.title}`; -// In a metadata pane: +// In a detail view: const pIcon = priorityIcon(item.priority, { noIcons: !useIcons }); const sIcon = statusIcon(item.status, { noIcons: !useIcons }); lines.push(`Status: ${sIcon} ${item.status}`); @@ -431,8 +356,6 @@ lines.push(`Priority: ${pIcon} ${item.priority}`); | File | Change | |------|--------| | `src/icons.ts` | Core icon module with emoji, fallback, and label functions | -| `src/tui/controller.ts` | Added icon rendering to TUI list rows | -| `src/tui/components/metadata-pane.ts` | Added icon rendering to metadata pane | | `src/commands/helpers.ts` | Added icon formatting to CLI output (summary, concise, normal, full) | | `src/commands/list.ts` | Added `--no-icons` CLI flag | | `src/commands/show.ts` | Added `--no-icons` CLI flag | diff --git a/docs/migrations/dialog-helpers-mapping.md b/docs/migrations/dialog-helpers-mapping.md index f2b7f4c58..5952ec431 100644 --- a/docs/migrations/dialog-helpers-mapping.md +++ b/docs/migrations/dialog-helpers-mapping.md @@ -2,8 +2,9 @@ > **DEPRECATED**: The Blessed TUI (including `src/tui/components/dialogs.ts`) > has been removed from the repository. This migration document is preserved -> for historical reference only. See the `wl tui` command which now delegates -> to the Pi-based TUI (`wl piman`). +> for historical reference only. The Pi-based TUI that succeeded it (launched +> via `wl tui` / `wl piman`) has also been removed — work item browsing and +> management is now provided by the Herdr plugin. This one-page mapping documents how the private helper methods used in src/tui/components/dialogs.ts map to the new exported helpers in @@ -31,4 +32,5 @@ src/tui/components/dialog-helpers.ts. - The extraction was intentionally additive; DialogsComponent retained its private helper implementations to avoid large refactors. - The Blessed TUI was removed in June 2026 and replaced by the Pi-based TUI - (`wl piman` / `wl tui`). + (`wl piman` / `wl tui`). The Pi-based TUI was itself removed later — work + item browsing is now provided by the Herdr plugin. diff --git a/docs/opencode-to-pi-migration.md b/docs/opencode-to-pi-migration.md index 539f80835..d55391869 100644 --- a/docs/opencode-to-pi-migration.md +++ b/docs/opencode-to-pi-migration.md @@ -1,15 +1,19 @@ # Opencode-to-Pi Migration Guide -This guide documents the migration from the legacy OpenCode integration to the new Pi-based agent framework. It is intended for maintainers, reviewers, and anyone working on the TUI codebase. +This guide documents the migration from the legacy OpenCode integration to the Pi-based agent framework. It is intended for maintainers, reviewers, and anyone working on the codebase. + +> **Note:** The Pi-based TUI (chat pane, action palette, `/wl` browse flow) +> has since been removed from the repository — work item browsing and +> management is now provided by the [Herdr plugin](../packages/herdr/). The +> agent-side Pi extension modules (activity indicator, session health, model +> display, guardrails, skill-path tool, error recovery, lease release) +> remain and auto-load into every pi session. ## Overview -The TUI previously relied on an OpenCode client for agent interactions (natural language chat, action palette, and agent-driven flows). This has been replaced with the Pi framework, which provides: +The TUI previously relied on an OpenCode client for agent interactions (natural language chat, action palette, and agent-driven flows). This was replaced with the Pi framework, which provides: -- **PiAdapter** (`packages/tui/extensions/index.ts` (Pi extension)): The core abstraction replacing `OpencodeClient`. Provides a clean interface for agent backend communication. -- **ChatPane** (`packages/tui/extensions/Worklog/chatPane.ts`): A natural language chat interface with keyword-based routing for `wl` commands (list, next, show, create, update, close, search, claim). -- **ActionPalette** (`packages/tui/extensions/Worklog/actionPalette.ts`): A keyboard-first action palette with default actions mapping to `wl` CLI commands. -- **wl CLI Integration** (`packages/tui/extensions/wl-integration.ts`, `src/wl-integration/spawn.ts`): All work item reads/writes now go through the `wl` CLI via `child_process.spawn`, not direct database access. +- **wl CLI Integration** (`packages/tui/extensions/wl-integration.ts`, `src/wl-integration/spawn.ts`): All work item reads/writes go through the `wl` CLI via `child_process.spawn`, not direct database access. ## What Changed @@ -33,10 +37,10 @@ The TUI previously relied on an OpenCode client for agent interactions (natural - `src/tui/controller.ts` — replaced `OpencodeClient` with `PiAdapter`, updated key handlers - `src/tui/constants.ts` — updated key descriptions and references - `packages/tui/extensions/index.ts` (Pi extension) — new PiAdapter implementation -- `packages/tui/extensions/Worklog/chatPane.ts` — new ChatPane component -- `packages/tui/extensions/Worklog/actionPalette.ts` — new ActionPalette component +- `packages/tui/extensions/Worklog/chatPane.ts` — new ChatPane component (removed in the Pi TUI removal) +- `packages/tui/extensions/Worklog/actionPalette.ts` — new ActionPalette component (removed in the Pi TUI removal) - `README.md` — added references to Pi agent features -- `docs/tutorials/04-using-the-tui.md` — updated tutorial with Pi agent chat and action palette +- `docs/tutorials/04-using-the-tui.md` — updated tutorial with Pi agent chat and action palette (removed in the Pi TUI removal) ### Files Retained (for reference) @@ -107,11 +111,10 @@ All 157+ tests should pass. Tests that previously depended on `OpencodeClient` h ### E2E tests -E2E tests for agent-driven flows are in `tests/e2e/agent-flow.test.ts`. They use mocked `child_process.spawn` for CI safety. - -```bash -npx vitest run tests/e2e/agent-flow.test.ts -``` +E2E tests for agent-driven flows lived in `tests/e2e/agent-flow.test.ts` (mocked +`child_process.spawn` for CI safety). That file, together with +`tests/e2e/headless-tui.test.ts`, was removed when the Pi-based TUI was +removed — work item browsing moved to the Herdr plugin (`packages/herdr/`). ## FAQ @@ -133,7 +136,6 @@ A: The PiAdapter uses the standard Pi framework configuration. Check the Pi docu ## Related Documentation -- [Pi TUI Design Checklist](./ux/design-checklist.md) -- [TUI.md](../TUI.md) — TUI controls and usage - [wl CLI Integration API](./wl-integration-api.md) -- [Pi Adapter](../src/tui/pi-adapter.ts) — source code +- [Herdr plugin](../packages/herdr/) — work item browsing and management +- [Pi extension README](../packages/tui/extensions/README.md) — retained agent-side plugin modules diff --git a/docs/tutorials/03-building-a-plugin.md b/docs/tutorials/03-building-a-plugin.md index 96b8eabd6..feb3cba1b 100644 --- a/docs/tutorials/03-building-a-plugin.md +++ b/docs/tutorials/03-building-a-plugin.md @@ -327,6 +327,6 @@ You built a complete Worklog plugin that: ## Next steps -- [Using the TUI](04-using-the-tui.md) -- browse work items interactively +- [Planning and Tracking an Epic](05-planning-an-epic.md) -- plan and track an epic with parent/child relationships - [Plugin Guide](../../PLUGIN_GUIDE.md) -- full plugin API reference with advanced topics - [Example Plugins](../../examples/README.md) -- working plugin examples (stats, bulk-tag, CSV export) diff --git a/docs/tutorials/04-using-the-tui.md b/docs/tutorials/04-using-the-tui.md deleted file mode 100644 index 344a8562e..000000000 --- a/docs/tutorials/04-using-the-tui.md +++ /dev/null @@ -1,235 +0,0 @@ -# Tutorial 4: Using the TUI - -**Target audience:** Any Worklog user who prefers a visual interface -**Time to complete:** 10 minutes -**Prerequisites:** Worklog installed ([Tutorial 1](01-your-first-work-item.md)) with some work items created - -## What you will learn - -By the end of this tutorial you will be able to: - -- Launch and navigate the interactive TUI -- Create, edit, and manage work items visually -- Use keyboard shortcuts for efficient navigation -- Access the built-in Pi agent assistant - -## Step 1: Launch the TUI - -```bash -wl tui -wl piman -``` - -The TUI launches the Pi coding agent with Worklog extensions pre-loaded. -It shows a browse list of recommended next work items. - -> **Note:** The TUI is now Pi-based. Both `wl tui` and `wl piman` launch -> the same interactive browse interface. - -### Filter to in-progress items only - -```bash -wl tui --in-progress -``` - -### Include completed and deleted items - -```bash -wl tui --all -``` - -## Step 2: Navigate the tree - -| Key | Action | -|-----|--------| -| Up / Down | Move selection up or down | -| Right / Enter | Expand a node to show children | -| Left | Collapse a node (or jump to parent) | -| Space | Toggle expand/collapse | -| Mouse click | Select an item | -| Mouse scroll | Scroll the list | - -As you navigate, the details pane on the right updates to show the selected item's full information, including description, comments, timestamps, and metadata. - -Note: The right-hand metadata pane shows a compact, easy-to-scan layout. Risk and Effort are shown on a single line (`Risk/Effort: /`), and a one-line Audit summary (when present) is surfaced as `Audit: — by `. Created/Updated rows are not displayed in the compact pane to reduce noise. - -## Step 3: Manage work items - -The TUI supports common work item operations without leaving the interface: - -| Key | Action | -|-----|--------| -| n | Create a new work item | -| e | Edit the selected item | -| c | Add a comment to the selected item | -| d | Delete the selected item | -| r | Refresh/reload all items | -| / | Search items | -| v | Cycle the needs-producer-review filter (on/off/all) | -| h | Toggle the help menu | - -### Create a work item - -Press `n` to open the creation dialog. Fill in the title, description, and other fields. The new item appears in the tree immediately. - -### Edit an item - -Select an item and press `e`. Modify any field and save. The details pane updates to reflect your changes. - -### Add a comment - -Select an item and press `c`. Type your comment and save. Comments appear in the details pane under the item's existing comments. - -## Step 4: Move and reparent items - -Press `m` on a selected item to enter move mode: - -1. The source item is highlighted with a yellow `[M]` prefix -2. Its descendants are dimmed (they cannot be targets) -3. Navigate to the desired new parent -4. Press `m` or `Enter` to reparent the item under the target -5. Press `m` or `Enter` on the source item itself to unparent it (move to root level) -6. Press `Esc` to cancel - -Other action keys are disabled during move mode to prevent accidental edits. - -## Step 5: Switch between panes - -Use window-management shortcuts to move focus: - -| Key | Action | -|-----|--------| -| Ctrl+W, Ctrl+W | Cycle focus between panes | -| Ctrl+W, h | Focus the list pane | -| Ctrl+W, l | Focus the details pane | -| Ctrl+W, p | Focus the previous pane | - -## Step 6: Use the Pi agent assistant - -Press `O` (capital O) to open the Pi agent assistant dialog. The server starts automatically and a status indicator appears: - -- `[-]` -- Server stopped -- `[~]` -- Server starting -- `[OK] Port: 9999` -- Server running -- `[X]` -- Server error - -### Interact with OpenCode - -| Key | Action | -|-----|--------| -| Type your prompt | Enter your question or instruction | -| Ctrl+S | Send the prompt | -| Enter | Accept autocomplete or add a newline | -| Escape | Close the dialog | - -### Run shell commands - -Prefix your prompt with `!` to run a shell command in the project root: - -``` -! npm test -``` - -The command output streams in the response pane. Press `Ctrl+C` to cancel a running command without closing the prompt. - -### Use slash commands - -Type `/` to see available commands: - -- `/help` -- Get help with OpenCode -- `/create` -- Create a new work item from a description -- `/edit` -- Edit files with AI assistance -- `/test` -- Generate or run tests -- `/fix` -- Fix issues in code - -Example: - -``` -/create Fix the login page redirect when session expires -``` - -This creates a work item with an auto-generated title, description, and appropriate issue type and priority. - -### Navigate OpenCode panes - -When OpenCode is active, the response appears in a bottom pane: - -| Key | Action | -|-----|--------| -| Ctrl+W, k | Focus the response pane | -| Ctrl+W, j | Focus the input pane | -| q or click [x] | Close the response pane | - -## Step 6a: Pi Extension Browse Shortcuts - -When using the Pi agent with the Worklog browse extension (launched via `piman`), you can quickly insert commands into the editor using keyboard shortcuts. These shortcuts are **config-driven** — defined in `packages/tui/extensions/Worklog/shortcuts.json` and dispatched dynamically by the shortcut registry, so they can be extended or customized without editing source code. - -### Browse List View Shortcuts - -In the browse selection list (when you see a list of work items), press one of the following keys to insert a command for the selected item: - -| Key | Command Inserted | Stage filter | -|-----|------------------|-------------| -| `c` | `/intake` (create new item) | — | -| `n` | `/intake ` | `idea` | -| `p` | `/plan ` | `intake_complete` | -| `i` | `/skill:implement ` | `intake_complete`, `plan_complete`, `in_progress` | -| `s` | search | — | -| `r` | producer-review toggle | — | -| `f i` / `f n` / `f p` / `f r` | stage filters (idea / intake / plan / in_review) | — | -| `u p l/m/h/c` | update priority (low/medium/high/critical) | — | -| `u s` | update stage/status | — | -| `u t` | update title | — | -| `x c` / `x d` | close / delete | — | -| `a a` / `a y` / `a r` | audit (automatic / approve / reject) | `in_review` | - -The command text is inserted into the Pi editor (without a trailing newline), allowing you to review or edit it before pressing Enter to submit. Chords (multi-key shortcuts like `u p h`) are entered by pressing each key in sequence. - -### Detail View Shortcuts - -In the detail scrollable view (when viewing a single work item), the same shortcuts work identically: press `i`, `p`, `n`, `c`, `s`, or `r` (plus the `u`, `x`, `a`, and `f` chords) to insert the corresponding command for the currently displayed work item. The detail view also clears its preview widget before closing the modal, giving you a clean editor to work in. - -When viewing details, a shortcut hint line appears at the bottom of the rendered content showing available keys for the current work item's stage (same formatting and filtering as the selection list hints). When a chord leader key (e.g., `u`) is pressed, the hint line updates to show available chord completions. The hint line respects the `showHelpText` setting and can be hidden via `/wl settings`. - -### How It Works - -Each shortcut is defined as a JSON object with: -- `key` (or `chord`): The single-character key or chord sequence (e.g., `"i"` or `["u", "p", "h"]`) -- `command`: The template string to insert (e.g., `/skill:implement `) -- `view`: Which view(s) the shortcut applies to (`"list"`, `"detail"`, or `"both"`) -- `label` / `description`: Human-readable metadata shown in the help-line hints -- `stages` (optional): Restricts the shortcut to items in the listed stages (e.g., audit chords only appear for `in_review` items) - -The `shortcutRegistry` loads `shortcuts.json` at extension init time and dispatches matched shortcuts in both the browse list and detail view handlers. Navigation keys (`Up`, `Down`, `Enter`, `Escape`, `PageUp`, `PageDown`, `G`) remain functional in both views. - -## Step 7: Exit the TUI - -Press `q`, `Esc`, or `Ctrl+C` to quit the TUI. All changes made during the session are saved to the local database. - -## Summary - -| Action | Key | -|--------|-----| -| Launch TUI | `wl tui` | -| Navigate | Arrow keys, Space, Enter | -| Create item | n | -| Edit item | e | -| Add comment | c | -| Delete item | d | -| Search | / | -| Move/reparent | m | -| Pi agent | O | -| Switch panes | Ctrl+W, Ctrl+W | -| Help | h | -| Quit | q / Esc / Ctrl+C | -| Pi extension: implement | `i` (browse view) | -| Pi extension: plan | `p` (browse view) | -| Pi extension: intake | `n` (browse view) | -| Pi extension: create | `c` (browse view) | -| Pi extension: audit | `a` (browse view) | - -## Next steps - -- [Planning and Tracking an Epic](05-planning-an-epic.md) -- organize complex features -- [TUI Reference](../../TUI.md) -- complete TUI documentation -- [Pi TUI Migration Guide](../../docs/opencode-to-pi-migration.md) -- migrating from OpenCode to Pi diff --git a/docs/tutorials/05-planning-an-epic.md b/docs/tutorials/05-planning-an-epic.md index a0bac6b0a..2e32f4f28 100644 --- a/docs/tutorials/05-planning-an-epic.md +++ b/docs/tutorials/05-planning-an-epic.md @@ -260,5 +260,4 @@ wl list --tags "auth" ## Next steps - [Team Collaboration with Git Sync](02-team-collaboration.md) -- share your epic with the team -- [Using the TUI](04-using-the-tui.md) -- visualize epic hierarchy interactively - [CLI Reference](../../CLI.md) -- full command documentation diff --git a/docs/tutorials/README.md b/docs/tutorials/README.md index 4f55b4449..6bd815171 100644 --- a/docs/tutorials/README.md +++ b/docs/tutorials/README.md @@ -9,8 +9,7 @@ Step-by-step guides for learning Worklog. Each tutorial is self-contained and ca | 1 | [Your First Work Item](01-your-first-work-item.md) | New users | 10-15 min | | 2 | [Team Collaboration with Git Sync](02-team-collaboration.md) | Team leads | 15-20 min | | 3 | [Building a CLI Plugin](03-building-a-plugin.md) | Developers | 20-25 min | -| 4 | [Using the TUI](04-using-the-tui.md) | Any user | 10 min | -| 5 | [Planning and Tracking an Epic](05-planning-an-epic.md) | Project leads | 15-20 min | +| 4 | [Planning and Tracking an Epic](05-planning-an-epic.md) | Project leads | 15-20 min | ## Getting started diff --git a/docs/ux/design-checklist.md b/docs/ux/design-checklist.md deleted file mode 100644 index 2fe23acae..000000000 --- a/docs/ux/design-checklist.md +++ /dev/null @@ -1,89 +0,0 @@ -# Pi TUI Design Checklist - -This checklist defines the UI/UX requirements for the Pi-based TUI in the ContextHub project. -Implementors and reviewers should validate against these items before merging changes. - -## 1. Layout & Structure - -- [ ] **Separation of Concerns**: Chat pane and action palette must be visually and functionally separate. -- [ ] **Keyboard-First Navigation**: All primary flows must be operable via keyboard without a mouse. -- [ ] **Responsive Design**: Panes should adapt to terminal size changes (resize events handled gracefully). -- [ ] **Widget Placement**: Widgets (work-item list, details) are placed below the editor, not overlaying it. -- [ ] **Non-Obstructive**: Native chat input and editor remain visible and functional at all times. - -## 2. Keyboard Navigation - -- [ ] **Shortcuts**: Standard keyboard shortcuts must work: - - `Ctrl+/` or `Ctrl+Shift+P` for action palette - - `Esc` to close modals/panels -- [ ] **Tab Order**: Logical tab order across interactive elements. -- [ ] **Focus Management**: Modal dialogs trap focus and restore it on close. - -## 3. Accessibility - -- [ ] **Labels**: All interactive elements have accessible labels. -- [ ] **Color Contrast**: Sufficient contrast between text and background in all themes. -- [ ] **Screen Reader**: Widgets announce state changes via notifications. -- [ ] **Focus Indicators**: Visible focus indicators for keyboard navigation. - -## 4. Chat Pane - -- [ ] **Visibility**: Chat pane is toggleable via a keyboard shortcut. -- [ ] **Message Display**: Agent responses are rendered with markdown support. -- [ ] **Streaming**: Agent responses stream incrementally (no blocking waits). -- [ ] **History**: Conversation history is preserved during a session. -- [ ] **Natural Language**: User can type freeform requests; agent interprets and acts. - -## 5. Action Palette - -- [ ] **Activation**: Opens via keyboard shortcut (configurable). -- [ ] **Filtering**: Typed input narrows results in real-time. -- [ ] **Navigation**: Arrow keys + Enter/Esc for selection and dismissal. -- [ ] **Actions Listed**: All agent-driven actions are discoverable: - - Create work item - - Update work item - - Close work item - - Claim/assign work item - - Run `wl` helper commands (next, list, show, search) - - Start agent conversation - - Trigger higher-level flows (create PR, run tests, delegate) -- [ ] **Confirmation**: State-changing actions require explicit confirmation. - -## 6. Widget System - -- [ ] **Persistence**: Widgets remain visible until explicitly hidden. -- [ ] **Refresh**: Widgets auto-refresh after wl CLI operations. -- [ ] **Details**: Selected item details update when selection changes. -- [ ] **Commands**: `/wl` (worklog browse) is functional. - -## 7. Error Handling - -- [ ] **User-Friendly**: Errors are displayed in a non-crashing, readable format. -- [ ] **Retry**: Transient failures (timeout, network) trigger automatic retry with backoff. -- [ ] **Notifications**: Users are notified of errors via the existing notification system. - -## 8. Theme Support - -- [ ] **Pi Themes**: Respects the current Pi theme configuration. -- [ ] **Custom Styling**: Widget styling uses Pi theme tokens, not hardcoded colors. - -## 9. Performance - -- [ ] **Non-Blocking**: wl CLI calls run off the main UI thread (spawn in child process). -- [ ] **Virtual List**: Long lists are virtualized for performance. -- [ ] **Memory**: No memory leaks in widget lifecycle; proper cleanup on hide/destroy. - -## 10. Pi Best Practices - -- [ ] **Extension Pattern**: TUI follows Pi extension patterns for modularity. -- [ ] **Configuration**: All configurable settings are in config files, not hardcoded. -- [ ] **Plugin Loading**: Plugins load gracefully and handle missing dependencies. -- [ ] **Version Compatibility**: TUI version is reported and compatible with wl CLI version. - -## Implementation Notes - -- Use `ctx.ui.setWidget()` for widget placement below the editor. -- Register shortcuts via `pi.registerShortcut()` for keyboard interactions. -- Use `ctx.ui.notify()` for user notifications on show/hide/selection. -- Avoid embedding theme ANSI codes into cached strings; rebuild on invalidate. -- Document tradeoffs if using `ctx.ui.custom()` or `ctx.ui.setEditorComponent()` for interactive UI. diff --git a/docs/wl-integration.md b/docs/wl-integration.md index 233b89acc..882b5e7ad 100644 --- a/docs/wl-integration.md +++ b/docs/wl-integration.md @@ -2,7 +2,7 @@ ## Overview -The **wl CLI Integration Layer** provides a safe, reliable way for the TUI and Pi agents to execute `wl` commands via subprocess spawn. It handles: +The **wl CLI Integration Layer** provides a safe, reliable way for Pi extension modules and CLI code to execute `wl` commands via subprocess spawn. It handles: - **Command spawning** – wraps `child_process.spawn` with configurable timeout, retries, and working directory. - **JSON parsing** – automatically parses `--json` output with robust recovery from partial/malformed output. @@ -11,12 +11,17 @@ The **wl CLI Integration Layer** provides a safe, reliable way for the TUI and P - **Exponential backoff** – retry delays use exponential backoff with jitter to avoid thundering herd. - **Attempts tracking** – `CommandResult.attempts` reports how many attempts were made. -## Quick Start +## Usage in the Pi extension + +Retained Pi extension modules (`session-health.ts`, `activity-indicator.ts`, +and the Pi extension wrapper `packages/tui/extensions/wl-integration.ts`) use +`runWl` to fetch work item state for the session health footer. The +CLI-level spawn layer lives at `src/wl-integration/spawn.ts`. ```ts import { runWlCommand, runWl, wlEvents } from './packages/tui/extensions/wl-integration.js'; -// Simple usage – TUI wrapper automatically appends --json +// Simple usage – the wrapper automatically appends --json const items = await runWl('list'); // Low-level usage – full control over args and options @@ -79,9 +84,9 @@ shell wrappers, or environment interference) using three strategies: If all strategies fail, a `JSON_PARSE` error is returned and the command is retried (if retries are configured). -## Migration Notes for Existing TUI Code +## Migration Notes -The old pattern in the TUI controller looked like this: +The integration layer replaces raw `child_process.spawn` calls: ```ts // BEFORE: raw spawn @@ -106,7 +111,7 @@ const payload = result.json; /* ... */ ``` -The `runWl` convenience wrapper automatically appends `--json` and throws on error, making it ideal for TUI flows that expect JSON output: +The `runWl` convenience wrapper automatically appends `--json` and throws on error, making it ideal for flows that expect JSON output: ```ts try { diff --git a/package.json b/package.json index 04360c972..a3eb98fff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "worklog", - "version": "1.0.8", + "version": "1.0.9", "description": "A simple experimental issue tracker for AI agents", "main": "dist/index.js", "type": "module", @@ -13,6 +13,7 @@ }, "scripts": { "build": "npm run build:shared && tsc && node ./scripts/generate-version.cjs", + "postbuild": "bash ./scripts/install-herdr.sh", "build:shared": "cd packages/shared && npm run build", "dev": "tsx watch src/index.ts", "prestart": "npm run build", @@ -21,7 +22,6 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "test:tui": "bash ./tests/tui-ci-run.sh", "test:timings": "node ./scripts/test-timings.cjs", "benchmark:sort-index": "tsx scripts/benchmark-sort-index.ts", "benchmark:wl-next": "tsx bench/wl-next-perf.ts", diff --git a/packages/herdr/README.md b/packages/herdr/README.md index ffe63aafd..7d3fb2c93 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -4,8 +4,8 @@ A Herdr plugin that provides a keyboard-navigable work item selection list for b ## Features -- **Browse work items** — Lists work items from `wl next` in a scrollable, keyboard-navigable list. The top-level list is root-only: child work items are hidden and appear only under their parent via expand. -- **Filter by stage** — Press `f` followed by a chord key (`i`=idea, `n`=intake, `p`=plan, `r`=review) to filter items by stage +- **Browse work items** — Lists work items from `wl next` in a scrollable, keyboard-navigable list. The top-level list is root-only: child work items are hidden and appear only under their parent via expand. Expanded parents **stay expanded across refreshes**: each auto/manual refresh re-fetches their children in parallel with the top-level list and swaps both in atomically, so the hierarchy never momentarily collapses or flickers (WL-0MSBVBNGH002RDP5). +- **Filter by stage** — Press `f` followed by a chord key (`i`=idea, `n`=intake, `p`=plan, `r`=review, `s`=sprint back to the default view), or type `/wl ` (shorthand alias or canonical stage name, e.g. `/wl intake_complete` or `/wl progress`), to filter items by stage. Stage-filtered views show **every open** root item in the selected stage — no `browseItemCount` cap and no `wl next` selection omission (WL-0MSDT8X1V003206G) - **View details** — Press Enter on any item to see its full details (description, acceptance criteria, metadata, tags, priority, GitHub issue number, and audit status information such as audit result, review status, and last audit timestamp) - **Audit indicators** — The list view shows audit icons next to `in_review` items (✅ audited, ❌ failed, ❓ unaudited). The detail view metadata section additionally shows the review status (❌ needs review / ✅ reviewed) and the last audit timestamp. - **Chord shortcuts** — Multi-key chord sequences provide quick actions like updating priorities, stage/status, title, closing/deleting items, running workflows, and toggling review status (configurable via `shortcuts.json`) @@ -13,13 +13,15 @@ A Herdr plugin that provides a keyboard-navigable work item selection list for b - **Command input form** — When a chord command contains unknown `` placeholders (e.g. `!!wl update --status --stage `), the plugin shows a modal input form so you can fill in the values before the command runs. Known identifiers like `` are still auto-substituted with the selected item's ID. The dialog is 80% of the pane width (40-column minimum), text wraps at the inner width, and the box grows downward as content is entered. See [Command input form](#command-input-form). - **Keyboard navigation** — Arrow keys or j/k to navigate (wraps at list boundaries), Page Up/Down, g/G for first/last, Enter to select, Escape to go back - **Pi agent pane dispatch** — Agent commands (`/skill:*`, `/intake`, `/plan`) are automatically dispatched to a new pi agent pane opened to the right, where pi receives the command as its initial prompt. Free-form prompts use the `/prompt:` prefix: the routing prefix is stripped so pi receives only the prompt text. +- **Agent status tracking** — When an agent command carrying a work-item ID is dispatched, the worklist records which pi agent pane is attached to that item (persisted to the gitignored `.worklog/agent-panes.json`, shared across worklist panes). The list shows a live agent-status icon at the start of each row's icon prefix: 🟢 working, ⛔ blocked, ⚪ idle. Done/closed items (and items without an agent) show no icon. The icon is a fixed-width slot so the item-ID column never shifts. See [Agent status icons](#agent-status-icons). - **Open Pi Agent action** — The plugin provides an action to open a fresh interactive pi session pane - **Tab-based opening** — The worklist opens in a new tab in the current workspace, providing full-screen access without reducing space for existing panes +- **Podcast Editing tab** — The `open-podcast-editor-tab` action (bound to `prefix+l`) opens the worklist pane in a new tab and renames that tab to **`Podcast Editing`** so it is instantly recognisable in the tab row during podcast production. See [Podcast Editing tab](#podcast-editing-tab). - **Quit** — Press `q` to exit - **Metadata panel** — The bottom portion of the list view (roughly 20–40% of the pane height, responsive to terminal size) is reserved for the selected item's metadata: ID, title, status, stage, priority, type, risk, effort, tags, audit info, and more. The panel scrolls independently with `m`/`M` (down/up) so long metadata never affects list navigation. See [Metadata panel](#metadata-panel). - **Command log** — Every plugin-dispatched command that targets a work item (via `` substitution or an explicit item ID) is recorded to a local JSON log. For `in_progress` items the panel shows the **last command** at the bottom, so you can see exactly what was last dispatched against the item. See [Command log](#command-log). - **Stage grouping** — Work items are grouped by their Worklog stage (standard lifecycle stages only: `idea`, `intake_complete`, `plan_complete`, `in_progress`, `in_review`, `done` — no custom stage values). Podcast episode items group exactly as their frontmatter stages map 1:1 (PRD §7.2). See [Stage grouping](#stage-grouping). -- **Generic md viewer** — When a work item's description carries a `Key Files:` path to a markdown document (e.g. a podcast episode `.podcast.md`), the detail view renders the file with a generic markdown viewer (frontmatter skipped, headings/lists/code shown) as a preview. See [Markdown viewer](#markdown-viewer). +- **Generic md viewer** — When a work item's description carries a `Key Files:` path to a markdown document (e.g. a podcast episode `.podcast.md`), the detail view renders the file with a generic markdown viewer (frontmatter skipped, headings/lists/code shown) as a preview. A persistent **Related Docs** table of contents at the top of the detail view lists every `.md` Key File (`↑↓/j:k` to navigate, `Enter` to open in the viewer), and the metadata panel shows a display-only `Related Docs` row. See [Markdown viewer](#markdown-viewer). - **Inline note links** — Inline `[NOTE : ...]` markers (PRD §7.1) render as clickable links to the note work items: the marker is displayed as `↗`, and the note text is never shown in the viewer. See [Inline note links](#inline-note-links). - **Code Freeze awareness** — While a ship-it release is in progress the project is in *Code Freeze*: the worklist shows a prominent banner and blocks all implement commands (`/skill:implement*`) with a notice dialog until the release finishes. See [Code Freeze](#code-freeze). @@ -36,11 +38,32 @@ A Herdr plugin that provides a keyboard-navigable work item selection list for b # Clone the repository cd /path/to/worklog-repo +# Build the repo — the postbuild hook automatically links the herdr plugin +# and registers the prefix+l keybinding (idempotent; safe to re-run) +npm run build + +# Build the plugin (also triggered by herdr's own [[build]] step) +cd packages/herdr && npm run build +``` + +`npm run build` runs `scripts/install-herdr.sh` via the root `postbuild` hook. The script: + +- Links the plugin with `herdr plugin link packages/herdr/herdr-plugin.toml` (a no-op when already linked). +- Inserts the `prefix+l` → `worklog-selection-list.open-podcast-editor-tab` keybinding into your herdr config (`~/.config/herdr/config.toml`, or `$HERDR_CONFIG_PATH` when set) **only if** it is not already present — re-running the build never creates duplicate keybindings. +- Migrates a legacy `prefix+l` → `worklog-selection-list.open-worklist` binding in-place to the new action, so the Podcast Editing tab name takes effect without a manual config edit. +- Warns (without failing the build) when `herdr` is not on PATH or the config cannot be written, so `npm run build` succeeds in CI/offline environments. + +Manual install (fallback, if you prefer not to run the build): + +```bash # Link the plugin in Herdr herdr plugin link packages/herdr/herdr-plugin.toml -# Build the plugin -cd packages/herdr && npm run build +# Add the keybinding to ~/.config/herdr/config.toml if not already present +# [[keys.command]] +# key = "prefix+l" +# command = "herdr plugin action invoke worklog-selection-list.open-podcast-editor-tab" +# description = "Open the Podcast Editing tab (Worklog work item selection pane)." ``` The plugin pane will then be available via the Herdr plugin system. @@ -50,7 +73,7 @@ The plugin pane will then be available via the Herdr plugin system. ### From the Herdr UI 1. Open the worklist pane: - - Press `prefix+l` to open the worklist in a new tab + - Press `prefix+l` to open the Podcast Editing tab (worklist pane in a new tab named `Podcast Editing`) - Right-click in any pane → Plugins → Worklog Selection List → Open worklist - Or use the Herdr command palette: `herdr plugin action run worklog-selection-list open-worklist` @@ -65,11 +88,15 @@ The plugin pane will then be available via the Herdr plugin system. - `Tab` — Toggle expand/collapse a parent item with children - `Escape` — Go back (from detail or filter mode); in a child list, return to the parent level at the previous scroll position. When inside a child list the footer shows a `[esc] back` hint (with `(N levels)` when nested deeper than one level). -3. Filter by stage using chord shortcuts: +3. Filter by stage using chord shortcuts (or type `/wl `): - Press `f` then `i` — Filter to idea-stage items - Press `f` then `n` — Filter to intake_complete items - Press `f` then `p` — Filter to plan_complete items - Press `f` then `r` — Filter to in_review items + - Press `f` then `s` (sprint) — Return to the default unfiltered browse list + - `/wl ` accepts shorthand aliases (`idea`, `intake`, `plan`, `progress`, `review`) and canonical stage names (`intake_complete`, `plan_complete`, `in_progress`, `in_review`) + - `/wl` with no stage argument returns to the default unfiltered browse list + - Filtered views show **every open** item in the selected stage (see [Selection List Behaviour](#selection-list-behaviour)) - Press `Escape` to cancel an incomplete chord 4. Workflow shortcuts (single-key): @@ -104,13 +131,34 @@ The plugin pane will then be available via the Herdr plugin system. 10. Quit: - Press `q` to close the worklist pane +### Agent status icons + +Rows that have a worklist-spawned pi agent pane attached show the agent's live status as the first icon in the row's icon prefix: + +| Icon | Meaning | +|------|---------| +| 🟢 | Agent is working on the item | +| ⛔ | Agent is blocked | +| ⚪ | Agent is idle (spawned but not currently active) | +| *(none)* | No agent attached, or the agent finished (`done`/closed) | + +The icon occupies a fixed-width slot, so the remaining icons and the item-ID column stay perfectly aligned whether or not a row has an agent. State is refreshed from the herdr CLI on each worklist refresh (with a short TTL); if the CLI is unavailable the list renders without icons rather than failing. ### From the command line ```bash # Direct invocation (opens in a new tab) herdr plugin action run worklog-selection-list open-worklist + +# Open the Podcast Editing tab (worklist pane renamed to "Podcast Editing") +herdr plugin action run worklog-selection-list open-podcast-editor-tab ``` +### Podcast Editing tab + +`herdr plugin pane open` has no tab-title option, so a worklist pane opened in a tab gets a generated numeric label. The `open-podcast-editor-tab` action (bound to `prefix+l`) fixes that: it opens the worklist pane in a new tab exactly like `open-worklist` (same flags and working-directory resolution) and then renames the created tab to **`Podcast Editing`** via `herdr tab rename`, so the podcast production tab is instantly recognisable among several open tabs. Each press opens a new tab — there is deliberately no focus-if-open toggle. + +The underlying script (`scripts/open-podcast-editor-tab.sh`) fails fast with a clear error and non-zero exit when the herdr CLI is unavailable, the pane open fails, or the created `tab_id` cannot be parsed — a missing rename is never silently skipped. The tab label can be overridden with the `TAB_LABEL` environment variable. + ### Configuration The plugin respects the following environment variables: @@ -122,12 +170,142 @@ The plugin respects the following environment variables: Settings are persisted in `~/.config/herdr/worklog-plugin.json`. Key settings include: - `autoRefresh` — Enable periodic auto-refresh of the work item list (default: `true`) -- `refreshIntervalMs` — Interval in ms between auto-refreshes (default: `30000`). Refresh cycles are single-flight: a tick that fires while the previous refresh is still awaiting its `wl` calls is skipped (no overlapping refresh cycles / wl spawn bursts from a pane), and the cadence resumes on the next tick (WL-0MSBVYBMD004007C). +- `refreshIntervalMs` — Interval in ms between auto-refreshes (default: `30000`). Refresh cycles are single-flight: a tick that fires while the previous refresh is still awaiting its `wl` calls is skipped (no overlapping refresh cycles / wl spawn bursts from a pane), and the cadence resumes on the next tick (WL-0MSBVYBMD004007C). Each refresh is **atomic with respect to expanded state**: children of expanded parents are re-fetched in parallel with the top-level list and applied in one synchronous swap, so an expanded hierarchy never momentarily collapses mid-refresh (WL-0MSBVBNGH002RDP5). - `autoSync` — Enable periodic background `wl sync` before auto-refreshes (default: `true`). Background syncs use a single-flight in-process guard and pass `wl sync --if-idle`, so overlapping syncs (from this pane or other panes/TUI instances) are skipped instead of piling up — preventing wl sync lock storms (WL-0MSAB7ZUC004SK7E). - `syncIntervalMs` — Interval in ms between background `wl sync` calls (default: `60000`, minimum: `60000`; set to `0` to disable auto-sync) -- `browseItemCount` — Max number of non-mandatory items to show in the list (default: `10`, range `1`–`50`; critical and completed/in_review items are always shown regardless) +- `browseItemCount` — Max number of non-mandatory items to show in the list (default: `20`, range `1`–`50`; critical and completed/in_review items are always shown regardless) - `showHelpText` — Show the shortcut hint line at the bottom of the list (default: `true`); changes apply on the next render without a plugin restart -- `showIcons` — Toggle icons in the list (default: `true`) +- `showIcons` — Toggle icons in the list (default: `true`); changes apply on the next render without a plugin restart + +### Downtime worker (local-LLM idle dispatch) + +When the local LLM (llama-server behind the llama-proxy) is idle, the plugin +can use that compute to advance the worklog backlog automatically: after the +proxy reports idle continuously for the configured threshold, it opens a +visible (non-focus-stealing) pi agent pane. Dispatch priority (WL-0MSI8H3HP000K0RG): +first a completed/in_review item **without a valid audit** → `/skill:audit +`; else `/skill:plan` on the next `intake_complete` item; else falls back +to `/skill:intake` on the next `idea` item (parent WL-0MSF49FMW009M06K). + +A "valid" audit is defined by the review-icon freshness rule: the audit is +current — i.e. the review icon is **neither** the hourglass `⏳` (stale passed) +**nor** the magnifying glass `🔍` (no audit / stale failed). Concretely, +`isAuditFresh(auditedAt, updatedAt)` returns `true` (auditedAt within the 60s +staleness buffer of updatedAt); missing audit timestamps are treated as +not-fresh and therefore selected. + +Settings (all re-read each poll, so changes apply without a plugin restart): + +- `downtimeEnabled` — Enable the downtime worker (default: `true`) +- `downtimeIdleThresholdMs` — Minimum continuous idle duration before a +dispatch (default: `240000` = 4 minutes, floor 1s) +- `downtimeRequiredFreeSlots` — Required free slots; `0` means **all** slots +must be free (default). A positive integer N is accepted; without per-slot +identity data the worker fails closed to all-slots-free for `0 < N < total`; +`N > total` never dispatches. +- `downtimePollIntervalMs` — Poll interval for the proxy status endpoint +(default: `30000`, hard floor `10000`) +- `downtimeProxyUrl` — Base URL of the llama-proxy (default: +`http://192.168.0.199:8000`) +- `downtimeModel` — pi model pattern for dispatched panes (default: `plan`) +- `downtimeNoCandidateCooldownMs` — Full pause (no proxy polling, no idle + tracking, no dispatch) after the worker finds no candidate in either + stage — a genuine empty backlog (default: `3600000` = 60 minutes, floor + 60s so the pause cannot be disabled or set trivially small) + +The worker polls `GET {proxyUrl}/llama/local/status` on the poll interval. +Idle means: llama-server running, no active query, no model switch, no active +local lease, and the required free-slot condition met. Endpoint failures, +timeouts, and ambiguous responses are treated as **busy** (no dispatch) and +never crash the plugin. Each poll is single-flight with a per-poll timeout. + +**Dispatch behaviour** — once idle has been continuous for the threshold, the +worker first runs `wl list --status completed --stage in_review --json` and +selects the first completed/in_review item **without** a valid audit that was +**modified within the last 7 days** (`updatedAt` recency window; a candidate +with a missing `updatedAt` is still selected — recency cannot be verified — +while an unparseable one is skipped), dispatching `/skill:audit ` (pane +named `Downtime audit`). If none, it runs `wl next --stage intake_complete +--json` and dispatches `/skill:plan `; if no such item it runs `wl next +--stage idea --json` and dispatches `/skill:intake `; if all three are +empty nothing is dispatched. A `wl`/CLI error on the `intake_complete` lookup +does **not** skip the `idea` lookup — a tier-3 candidate can still dispatch. +The item is claimed (`wl update --status in_progress`) *before* the pane +spawns, so it appears in-progress immediately and a second pane's `wl next` +cannot select it. Panes are named `Downtime audit` / `Downtime plan` / +`Downtime intake`, opened +with `--no-focus` (visible, never steals focus), `--cwd ` and +`--model `. + +**Empty-backlog cooldown** — when **both** `wl next` lookups genuinely return +no candidate (the tab's project has nothing to dispatch), the worker enters a +full **pause** for `downtimeNoCandidateCooldownMs` (default 60 minutes): no +proxy polling, no idle tracking, and no dispatch until the pause expires — so +it stops burning cycles (proxy polling + `wl` spawns) during empty periods. +Only a *genuine* empty backlog triggers the pause: transient `wl`/CLI errors +and the in-flight dispatch guard fail closed to busy and never start a +cooldown on their own. When the pause expires the worker resumes polling and +requires a fresh full idle period before the next dispatch (no stale idle +credit). The worker re-reads the setting every tick, so a change applies from +the next cooldown entry without a plugin restart. + +**Three-strike rule on CLI errors** — a dispatch attempt that ends in a `wl` +CLI error counts as one strike. Three **consecutive** strikes pause the +worker entirely (same full pause as the empty-backlog cooldown) *after* +logging the persistent error to the rolling downtime log, so a persistently +broken `wl` CLI stops burning idle cycles instead of retrying forever. A +successful dispatch, a genuine no-candidate outcome, or an expired pause +resets the strike counter; a single transient error never pauses on its own. + +**Hang protection** — every downtime `wl` invocation (`wl next` and +`wl list` selection lookups) runs with a bounded 10s timeout, so a hung `wl` +child is killed and the lookup fails closed to a CLI-error strike within a +bounded time instead of wedging the dispatch task until a pane restart +(previously the two selection lookups had **no** timeout, so a single hang +permanently stopped downtime dispatch — silently). As a belt-and-suspenders +backstop, the scheduler also wraps each downtime-task run in a 60s watchdog: +a tick run that hangs past the bound is abandoned and the task's +single-flight flag resets, so the next scheduler tick retries. Healthy runs +complete in well under a second and are unaffected (the watchdog timer is +cleared when the run settles). + +**Worklog-root routing** — the downtime worker's `wl next` selection and its +`wl comment add` audit trail run through the same `--worklog-dir` override as +the worklist, so idle dispatch picks (and comments on) items from the tab's +resolved project root (e.g. SorraAgents) rather than the plugin process's own +directory. + +**Blocked-questions handling** — the dispatched prompt instructs the agent: +if it cannot proceed because it needs answers, record the questions in a +comment on the work item (`wl comment add --comment ...`) and mark the +item as needing producer review (`wl update --needs-producer-review +true`), then stop — it never blocks indefinitely. + +**No lock file (cross-pane decision Q5)** — concurrent panes are serialized +by the idle→busy cadence (a dispatch consumes the local slot, so the proxy +reports busy and the worker requires a fresh full idle period) and by the +pre-dispatch claim; there is deliberately **no cross-pane lock file**. A +small residual risk of two panes dispatching at the same idle boundary is +accepted — the claim prevents duplicate work on the same item. + +**Audit trail** — every successful dispatch records two traces: (1) a +comment on the dispatched item (`wl comment add`, author +`herdr-downtime`) stating the automatic dispatch, the skill run, and the +UTC timestamp — this survives `wl sync` and is the durable trail; and (2) a +bounded JSONL entry in `.worklog/downtime-dispatches.log` under the +resolved worklog root (rolling — only the most recent 100 entries are +kept). A three-strike CLI-error pause additionally writes a JSONL entry to +the same rolling log (with the `at` timestamp and an error message) so the +persistent failure is auditable even though nothing was dispatched. The +`.worklog` log file is gitignored and local-only; all writes are +fail-closed, so a comment or log failure never blocks or fails a dispatch. + +The worker runs inside the plugin's single consolidated scheduler loop (one +`setInterval`; no independent timers), uses unref'd timers, and is cleaned up +when the pane exits. While the pane is open the list header shows the worker +state, e.g. `[⏳ downtime idle 3:12]`, `[downtime busy]`, +`[⏳ downtime dispatching]`, `[downtime disabled]`, or `[downtime paused]` +(no-candidate cooldown). ### Pause-when-hidden (pane visibility gating) @@ -183,8 +361,21 @@ Example: with `browseItemCount=15`, 2 critical + 3 completed/in_review + first 10 others (15 total). If there were 20 completed/in_review items instead of 3, all 22 mandatory items would be shown (22 > 15). -The **stage-filtered** views (press `f` + stage chord) are unchanged: they -show only items matching the selected stage. +The **stage-filtered** views (press `f` + stage chord, or `/wl `) show +**every open** root item in the selected stage (WL-0MSDT8X1V003206G): + +- All open items (`status=open`) in the stage are listed — no + `browseItemCount` cap and no `wl next` selection omission, so items the + priority algorithm deprioritises are still visible. +- Items with status `blocked`, `in-progress`, or `completed` are excluded + even when their stage matches. +- Children stay hidden in the top-level list and remain reachable via + expand (Tab), exactly as in the unfiltered view. +- Results follow the standard list order (sortIndex), matching the + unfiltered view. + +The default (unfiltered) worklist is unaffected — `/wl` with no stage +argument keeps the smart-selection behaviour described above. The "top N of M" header reflects the **actual displayed count** (N), which may exceed `browseItemCount` when the mandatory set is large. @@ -201,6 +392,13 @@ clamped to a minimum of 3 rows so it is always usable. metadata (status, stage, priority, type, risk, effort, children/parent counts, tags, GitHub issue number, created/updated timestamps, and audit state). +- When the item's description has a `Key Files:` section containing one or + more `.md` paths, the panel also shows a **`Related Docs`** row listing + every markdown path (joined with `, `; long values are truncated to the + pane width). Non-markdown Key Files (`.ts`, `.json`, …) are excluded, and + the row is omitted entirely when there are no `.md` Key Files. The row is + **display-only** — opening a document happens from the detail view's + Related Docs table of contents (see [Markdown viewer](#markdown-viewer)). - For items whose stage is `in_progress`, the panel additionally shows **`Last command:`** — the most recent command the plugin dispatched against that item (`none yet` until the first dispatch). @@ -219,7 +417,10 @@ grouping, so podcast episode items group exactly as their frontmatter `pipeline_stage` maps 1:1 onto the Worklog stages (PRD §7.2). Groups render in the canonical order (Critical → Group N → Idea → Other → In Review) with group separators in the list; stage changes re-group items on the next -refresh. +refresh. Non-critical `in_progress` items join the file-path-partitioned +`Group N` lists alongside `plan_complete`/`intake_complete` items and sort +ahead of them (actively-worked items first); "Other" remains only as a +safety net for unknown/custom stages. ## Markdown viewer @@ -233,9 +434,37 @@ description. The viewer: - is preview-only (no notes editor); - falls back to the raw description when the file is missing/unreadable. +Key Files paths are resolved against the **worklog root** (the directory +containing `.worklog/`, from `HERDR_RESOLVED_CWD` / `configureWorklogTarget`) +— never the plugin pane's process CWD, which is the plugin source dir — with +fallback candidates tried in order: the worklog root, then the legacy +podcast-relative base `/.llm-wiki/wiki/podcast/` (older episode items +wrote Key Files paths relative to the podcast dir rather than the wiki root), +then `process.cwd()` as a last resort. + The rendered lines appear under an `Episode file (md viewer)` heading in the detail view, scrollable with the usual `↑↓/j:k` keys. +### Related Docs table of contents + +When the item has at least one `.md` Key File, the **top of the detail view** +shows a persistent **`Related Docs`** table of contents listing every markdown +Key File (numbered, with a focus indicator on the selected entry). This makes +all associated documents visible — not just the first one — and lets you open +any of them: + +- `↑`/`↓` and `j`/`k` move the ToC selection (clamped to its bounds). +- **`Enter`** renders the selected document's content in the markdown viewer + (replacing the auto-rendered first file for that selection; the first file + remains the initial default). +- The ToC stays **visible on all renders**: while the viewed document scrolls, + the ToC remains pinned at the top of the detail view. +- Navigating **past the last ToC entry** transfers focus to document + scrolling (the usual `↑↓/j:k`, `g`/`G`, `pgup`/`pgdn` keys apply there); + navigating **up past the top of the document** returns focus to the ToC. +- `esc`/`q` exit the detail view as usual. Items with no `.md` Key Files + render exactly as before (no ToC). + ## Inline note links Inline `[NOTE : ...]` review-note markers (PRD §7.1) — where `` is @@ -298,6 +527,7 @@ packages/herdr/ │ └── worklist.ts # List state, rendering, keyboard handling, command output ├── scripts/ │ ├── open.sh # Open the worklist pane +│ ├── open-podcast-editor-tab.sh # Open the worklist pane in a tab renamed "Podcast Editing" │ ├── toggle.sh # Toggle the worklist pane │ ├── send-to-pi.sh # Split pane to right, launch pi with agent command │ ├── run-in-pane.sh # Run a shell command visibly in a new pane (stays open for inspection) @@ -309,19 +539,25 @@ packages/herdr/ - **No direct database access** — The plugin uses the `wl` CLI as the backend data source, ensuring compatibility without duplicating data-access logic. - **Terminal UI via raw mode** — The TUI uses raw stdin mode and ANSI escape codes for rendering, making it compatible with any Herdr pane without additional dependencies. -- **Fixed-height pane rendering** — The list renderer budgets its output to `rows - 1` lines (header + blank + filter bar + items + group separators + fill + footer), reserving the last row for the transient notification line (e.g. `[Synced]`, `[Refresh failed]`). Group separator lines count against the budget, so the pane never scrolls the header or top items off the top of the view regardless of item/group count (see WL-0MSAAON63003N6LO). +- **Fixed-height pane rendering** — The list renderer budgets its output to `rows - 1` lines (header + items + group separators + fill + footer), reserving the last row for the transient notification line (e.g. `[Synced]`, `[Refresh failed]`). The active stage filter is shown in the header only (` (filtered: )`) — there is no standalone filter bar or blank chrome row. Group separator lines count against the budget, so the pane never scrolls the header or top items off the top of the view regardless of item/group count (see WL-0MSAAON63003N6LO, WL-0MSGTSPXK007POB1). - **Testable core** — All state management, formatting, and keyboard handling is pure logic in `worklist.ts`, fully testable without a terminal. - **Toast notifications instead of bottom-line status** — Transient status feedback (refresh outcomes, sync outcomes, sent/skipped command feedback, errors) is surfaced via Herdr toast notifications (`herdr notification show`) instead of being appended to the bottom of the pane output. This keeps the rendered pane within the terminal height budget, so the list header and top lines are never pushed off the top of the pane. Toast delivery requires `ui.toast.delivery = "herdr"` in `~/.config/herdr/config.toml`; toasts appear in the bottom-right corner by default. The helper lives in `notify.ts` and is fire-and-forget (failures are tolerated silently). - **Command routing via callback** — When a chord resolves to a non-`/wl` command, it is passed to an `onCommand` callback (set by the entry point) which routes it by prefix: - `!!`/`!` prefixed commands (shell-executed shortcuts such as audit approve/reject, priority updates, close/delete) are run **visibly in a new herdr pane** via `scripts/run-in-pane.sh` — the wrapper keeps the pane's process alive so the pane stays open (exit status reported; dismiss with Enter or close with `prefix+x`) so the user can inspect the command output. - Everything else is written to stdout with a `CMD:` prefix for the calling framework (Herdr) to execute. - **Pi agent dispatch** — Agent commands (`/skill:*`, `/intake`, `/plan`) are intercepted by the entry point and routed to a new pi agent pane. The `send-to-pi.sh` script splits the current pane to the right, creates a new pane, runs `pi` with the command as the initial prompt, and renames the pane to "Pi Agent". Agent commands are routed before any prefix handling, so they are unaffected by `!!`/`!` processing. +- **Podcast Editing tab naming** — `herdr plugin pane open` creates tabs with generated numeric labels. The `open-podcast-editor-tab` action wraps the same pane-open command and renames the created tab to "Podcast Editing" via `herdr tab rename` (socket API, not session-state editing), so podcast production is instantly recognisable in the tab row. Each press still opens a new tab; only the label changes. - **Model selection per shortcut** — Each LLM-bound shortcut entry in `shortcuts.json` may carry an optional `model` field (a pi model pattern such as `plan`, `code`, or `author`). When the command is dispatched to the agent channel, `--model ` is forwarded to the spawned `pi` CLI (e.g. `pi --model code '/skill:implement '`), so every workflow runs on an appropriately specialised model without manual model switching. Agent-bound entries without a `model` field default to `plan`; shell (`!!`) and `/wl` filter entries never carry a model and never receive a `--model` flag. The default mapping in `src/shortcuts.json`: `/plan`, `/intake`, `/skill:audit`, `/prompt:` → `plan`; `/skill:implement` → `code`. - **Free-form prompts via `/prompt:`** — Commands starting with `/prompt:` are also routed to the agent pane, but the `/prompt:` routing prefix is stripped before `send-to-pi.sh` runs, so pi receives only the bare prompt text (e.g. `pi "What are the audit gaps reported in the most recent audit for WL-123"`). This lets a chord shortcut open a new pi instance with an arbitrary injected prompt, not just a skill/workflow invocation. The `P-p` chord opens the command input form so you can type any free-form prompt, and `P-a` opens pi with `What are the audit gaps reported in the most recent audit for ` (the selected item's ID is substituted automatically). Edit `src/shortcuts.json` to bind your own prompt text to any free chord. - **Correct project directory for new panes** — Panes created by `send-to-pi.sh`, `open-pi-agent.sh`, and `run-in-pane.sh` are started in the correct project root. Herdr's `follow` CWD policy would otherwise inherit the source pane's CWD (the plugin directory), so each script resolves a target CWD (`--cwd` arg > `HERDR_RESOLVED_CWD` > `$PWD`) and applies it in both launch modes: `--no-resize` passes it to `herdr pane split --cwd`, and the default resize mode forwards it to `grid.py --cwd` which includes it in the `pane.split` RPC params. The entry point passes the resolved worklog root (`wlRoot`) so skills, `wl` commands, and relative paths operate on the user's project rather than the plugin's installation directory. - **`` placeholder resolution** — Before output, any `` placeholders in the resolved command are replaced with the currently selected work item's ID. If no item is selected and the command requires ``, the command is silently dropped (graceful no-op). - **Parameter input form** — Chord commands containing unknown `` placeholders open a modal input form (`form-dialog.ts`) before dispatch. The dialog renders at 80% of the pane width (40-column minimum, centered), wraps the description and field values at its inner content width, and expands downward as content wraps — bounded by the terminal height. Every content line is padded to exactly the border width so the box borders stay aligned at any pane width (see WL-0MSAKRBOC005T320). -- **Chord shortcut system** — Multi-key chord sequences are defined in `shortcuts.json` and resolved via `ShortcutRegistry`. Chords can be filtered by view (list/detail) and stage. Entries may carry an optional `model` field (see **Model selection per shortcut** above). +- **Chord shortcut system** — Multi-key chord sequences are defined in `shortcuts.json` and resolved via `ShortcutRegistry`. Chords can be filtered by view (list/detail), stage, and work-item issue type. Entries may carry an optional `model` field (see **Model selection per shortcut** above). +- **Project-local shortcut overrides** — A consumer project can add chords or override bundled defaults **without editing the plugin bundle** by placing a `shortcuts.json` at its **worklog root** (the project root resolved via `configureWorklogTarget`; the plugin reads `/shortcuts.json` when it exists). Semantics: + - The bundled `src/shortcuts.json` is loaded first and remains the base config; a local entry with the **same `chord` + `view`** replaces the bundled entry, while local entries with new chords are appended. + - The merge is **deterministic and deduplicated** (dedup key = `view` + `chord`); within the local file, later entries win for the same `view`+`chord`. + - Local entries are validated with the same rules as bundled entries: an invalid entry is **logged and skipped**, and a missing/malformed local file (bad JSON, non-array) falls back to bundled-only with an error logged — a broken local file never crashes the plugin. + - When no worklog root is resolved (uninitialized project) or no local file exists, the registry is exactly the bundled defaults and all dispatch behaviour is unchanged. - **Metadata panel** — The bottom of the list view is reserved for the selected item's metadata (`formatMetadataPanel` in `worklist.ts`). The panel height ramps linearly with pane height (20% → 40%, min 3 rows) via `computeMetadataPanelHeight`, and the panel scrolls independently (`m`/`M`) with a scroll indicator. Row building is shared with the detail view via `buildMetaRows`, so the two views never drift apart (see WL-0MSAYNVBY006LM9X). - **Command log** — Dispatched commands targeting a work item are recorded in `command-log.ts` before execution; the panel surfaces the last command for `in_progress` items. Recording is fire-and-forget (never breaks dispatch) and the log file is written atomically (see WL-0MSAYNVBY006LM9X). @@ -367,6 +603,61 @@ Fail-open is deliberate: a broken or missing marker must never block browsing th - **Notice dialog** — When an implement command is attempted during a freeze, a modal dialog explains that implementation is blocked until the release finishes. Dismiss with `Esc`, `Enter`, or `q` to return to the list. - **Other commands unaffected** — Audit, intake, plan, review, priority, search, sync, and navigation continue to work normally during a freeze. +### Shortcut filtering by work-item type + +Each entry in `shortcuts.json` may carry an optional `work_item_types` array +limiting the shortcut to work items whose issue type is listed +(WL-0MSKH1J0R003BM2M): + +| `work_item_types` value | Behaviour | +|---|---| +| `["podcast"]` | Shortcut visible only on `podcast`-typed work items | +| `["bug","feature","task","chore","epic"]` | Shortcut visible only on code work item types | +| omitted | Always shown (backward compatible) | + +Semantics: + +- The JSON key is snake_case (`work_item_types`); the parsed TS field is + camelCase (`workItemTypes`) — matching the `code_freeze`→`codeFreeze` + convention. +- Any non-array / empty / non-string value is logged as invalid and treated + as omitted (always shown) — a bad value never hides or breaks a shortcut. +- When the selected item's `issueType` is not available (or the entry has no + allowlist), behavior is exactly as before: all entries are candidates. +- The registry methods `lookupChord()`, `lookupChordEntry()`, + `getEntriesForStage()`, `getChordByPrefix()`, `getChordByLeader()`, and + `getChordEntries()` accept an `issueType` parameter and exclude entries + whose allowlist misses it, so footer hints, chord hints, and dispatch + lookups all respect the gating automatically. +- **Bundled restriction:** the code-workflow chords `n` (intake), `p` (plan), + and `i` (implement) carry `work_item_types: ["bug","feature","task", + "chore","epic"]`, so they are hidden on non-code types (e.g. `podcast`, + `docs`). All other bundled shortcuts (audit `a-*`, producer review `r`, + housekeeping `u-*`/`x-*`/`c`/`s`/`P-*`/`f-*`) remain untyped and are + available on all types. Consumer projects can add their own type-gated + chords (e.g. `w` → `wiki-podcast-script` for `podcast` items) via the + project-local `shortcuts.json` mechanism above. + +### Podcast-progression dispatch markers (OSL-0MSKFXM380098LFL) + +Consumer projects that produce podcasts via Worklog episode items (issue +`podcast`) can define progression chords in their project-local +`shortcuts.json` that dispatch the podcast pipeline skills. Two markers are +resolved by the worklist **at dispatch time** from the selected item's +`Key Files:` section and lifecycle context — they never fall through to the +modal input form: + +| Marker | Resolution | Typical command | +|---|---|---| +| `` | `w` write-script chord: stage `intake_complete` (sourced) → `--doc --force-single`; otherwise with open editor-note children → `--rewrite `; otherwise a belt-and-braces error is shown and nothing dispatches (never authors a duplicate) | `/skill:wiki-podcast-script ` | +| `` | `t` TTS chord: first `.podcast.md` Key File, normalized to the wiki-dir-relative `podcast/...` path the TTS skill expects (errors when no script exists yet) | `/skill:wiki-tts-generate --podcast-file ` | + +Both markers require the chord entry to carry `work_item_types: ["podcast"]` +so it is only visible on podcast-typed items (see [Shortcut filtering by +work-item type](#shortcut-filtering-by-work-item-type)); the `w` chord should +additionally be stage-limited to the podcast lifecycle (`intake_complete`, +`plan_complete`, `in_review`, `done`). + ### Shortcut filtering during a freeze Each entry in `shortcuts.json` may carry an optional `code_freeze` field controlling its visibility while the project is frozen (WL-0MSD81VEL009XHWA): diff --git a/packages/herdr/herdr-plugin.toml b/packages/herdr/herdr-plugin.toml index 3d8e14b38..566d5146a 100644 --- a/packages/herdr/herdr-plugin.toml +++ b/packages/herdr/herdr-plugin.toml @@ -35,6 +35,13 @@ title = "Open worklist" description = "Open the Worklog work item selection pane." command = ["bash", "scripts/open.sh"] +# Action: open the podcast editing tab (worklist pane renamed to "Podcast Editing") +[[actions]] +id = "open-podcast-editor-tab" +title = "Open Podcast Editing tab" +description = "Open the Worklog work item selection pane in a new tab renamed to 'Podcast Editing'." +command = ["bash", "scripts/open-podcast-editor-tab.sh"] + # Action: open pi agent pane [[actions]] id = "open-pi-agent" diff --git a/packages/herdr/scripts/open-podcast-editor-tab.sh b/packages/herdr/scripts/open-podcast-editor-tab.sh new file mode 100755 index 000000000..d2952e8ce --- /dev/null +++ b/packages/herdr/scripts/open-podcast-editor-tab.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# open-podcast-editor-tab.sh — Open the podcast editing tab +# +# Opens the Worklog work item selection pane (the podcast production +# workflow) in a NEW TAB and renames that tab to "Podcast Editing" so it +# is instantly recognisable in the tab row among several open tabs. +# +# `herdr plugin pane open` (herdr 0.7.5) has no tab-title option — the +# created tab gets a generated numeric label. This script therefore opens +# the pane exactly like open.sh (same flags, same CWD resolution) and then +# renames the created tab via the socket API (`herdr tab rename`). +# +# Each invocation opens a new tab — there is deliberately no +# focus-if-open toggle; only the tab label changes. +# +# Usage: open-podcast-editor-tab.sh [cwd] +# cwd - Optional working directory for the pane (default: resolved from +# pane metadata / $PWD, same priority order as open.sh) +# +# Environment variables: +# HERDR_BIN_PATH Path to the herdr CLI binary (default: herdr on PATH) +# TAB_LABEL Tab label to apply (default: "Podcast Editing") +# +# Returns: +# 0 on success (tab opened and renamed) +# 1 if the herdr CLI is not found +# 1 if `herdr plugin pane open` fails +# 1 if the created tab_id cannot be parsed from the open output +# 1 if `herdr tab rename` fails + +set -uo pipefail + +herdr_bin="${HERDR_BIN_PATH:-herdr}" +TAB_LABEL="${TAB_LABEL:-Podcast Editing}" + +fail() { + echo "error: $*" >&2 + exit 1 +} + +# ── CLI availability check ───────────────────────────────────────────── +# Fail fast with a clear message instead of a confusing "command not found". +command -v "$herdr_bin" >/dev/null 2>&1 \ + || fail "herdr CLI not found ('${herdr_bin}'). Is herdr installed and on PATH? Set HERDR_BIN_PATH if it lives elsewhere." + +# ── Resolve the logical CWD ──────────────────────────────────────────── +# The action runs from the plugin directory so $PWD is wrong when invoked +# from a keybinding. Query in priority order (same as open.sh): +# 1) $1 (explicit argument) +# 2) $HERDR_PANE_ID → pane get +# 3) herdr pane current +# 4) $PWD (last resort) +# +# If the invoking pane is a Worklog plugin pane, its logical CWD +# (HERDR_RESOLVED_CWD in the pane's env) is more accurate than the +# filesystem cwd reported by `pane get`; read it from /proc via +# `herdr pane process-info`. +_resolve_plugin_cwd() { + local pane_id="$1" + local shell_pid + shell_pid=$( "$herdr_bin" pane process-info --pane "$pane_id" 2>/dev/null \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['process_info']['shell_pid'])" 2>/dev/null ) + if [ -z "$shell_pid" ] || [ ! -f "/proc/$shell_pid/environ" ]; then + return + fi + tr '\0' '\n' < "/proc/$shell_pid/environ" | grep '^HERDR_RESOLVED_CWD=' | cut -d= -f2- || true +} + +_pane_cwd_from_json() { + python3 -c " +import sys, json +try: + data = json.load(sys.stdin) + result = data.get('result', {}) + pane = result.get('pane', {}) if isinstance(result, dict) else {} + if isinstance(pane, dict): + cwd = pane.get('cwd') or pane.get('foreground_cwd', '') + if cwd: + print(cwd) +except: + pass +" 2>/dev/null || echo "" +} + +_pane_id_from_json() { + python3 -c " +import sys, json +try: + data = json.load(sys.stdin) + pane = data.get('result', {}).get('pane', {}) + if isinstance(pane, dict): + print(pane.get('pane_id', '')) +except: + pass +" 2>/dev/null || echo "" +} + +cwd="${1:-}" +pane_id_for_plugin_check="" + +if [ -z "$cwd" ]; then + pane_cwd="" + if [ -n "${HERDR_PANE_ID:-}" ]; then + raw_pane_get=$( "$herdr_bin" pane get "$HERDR_PANE_ID" 2>&1 ) + pane_cwd=$( echo "$raw_pane_get" | _pane_cwd_from_json ) + pane_id_for_plugin_check="$HERDR_PANE_ID" + fi + + if [ -z "$pane_cwd" ]; then + raw_pane_current=$( "$herdr_bin" pane current 2>&1 ) + pane_cwd=$( echo "$raw_pane_current" | _pane_cwd_from_json ) + pane_id_for_plugin_check=$( echo "$raw_pane_current" | _pane_id_from_json ) + fi + + # A Worklog plugin pane reports its logical CWD via HERDR_RESOLVED_CWD + # in the pane process environment (see open.sh). + if [ -n "$pane_id_for_plugin_check" ]; then + plugin_cwd=$( _resolve_plugin_cwd "$pane_id_for_plugin_check" ) + [ -n "$plugin_cwd" ] && pane_cwd="$plugin_cwd" + fi + + cwd="${pane_cwd:-$PWD}" +fi + +# ── Open the worklist pane in a new tab ──────────────────────────────── +# Pass the resolved CWD as an environment variable (not --cwd): the pane +# command uses a RELATIVE path, so --cwd would break script resolution. +# The plugin reads HERDR_RESOLVED_CWD to find the correct .worklog/. +open_output=$("$herdr_bin" plugin pane open \ + --plugin worklog-selection-list \ + --entrypoint worklist \ + --placement tab \ + --env "HERDR_RESOLVED_CWD=$cwd" \ + --focus 2>&1) \ + || fail "'herdr plugin pane open' failed. Is the herdr server running? +$open_output" + +# ── Parse the created tab_id ─────────────────────────────────────────── +# A missing rename is never silently skipped: if the tab_id cannot be +# parsed we fail with the raw output for diagnosis. +tab_id=$( printf '%s' "$open_output" | python3 -c " +import sys, json +try: + data = json.load(sys.stdin) + print(data['result']['plugin_pane']['pane']['tab_id']) +except Exception: + pass +" 2>/dev/null || echo "" ) + +[ -n "$tab_id" ] \ + || fail "could not parse tab_id from 'herdr plugin pane open' output. +Raw output: +$open_output" + +# ── Rename the tab ───────────────────────────────────────────────────── +"$herdr_bin" tab rename "$tab_id" "$TAB_LABEL" >/dev/null 2>&1 \ + || fail "'herdr tab rename $tab_id \"$TAB_LABEL\"' failed. Is the herdr server running?" + +echo "Opened podcast editing tab '$TAB_LABEL' ($tab_id)." >&2 diff --git a/packages/herdr/scripts/tests/test_open_podcast_editor_tab.sh b/packages/herdr/scripts/tests/test_open_podcast_editor_tab.sh new file mode 100755 index 000000000..d2432e69d --- /dev/null +++ b/packages/herdr/scripts/tests/test_open_podcast_editor_tab.sh @@ -0,0 +1,225 @@ +#!/usr/bin/env bash +# test_open_podcast_editor_tab.sh — tests for open-podcast-editor-tab.sh +# +# Covers the parse-and-rename wiring with a mock herdr CLI: +# - happy path: pane opened, tab_id parsed from the pane-open JSON, +# tab renamed to "Podcast Editing" +# - herdr CLI unavailable → clear error, non-zero exit +# - unparseable tab_id → clear error, non-zero exit, rename never called +# - `plugin pane open` failure → clear error, non-zero exit +# +# Run from the repo root (or anywhere): +# bash packages/herdr/scripts/tests/test_open_podcast_editor_tab.sh + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +OPEN_PODCAST_EDITOR="$SCRIPT_DIR/open-podcast-editor-tab.sh" +PASS=0 +FAIL=0 + +pass() { PASS=$((PASS+1)); echo " PASS: $1"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } + +# Temporary sandbox for mocks and logs +SANDBOX="$(mktemp -d)" +HERDR_LOG="$SANDBOX/herdr-log.txt" +trap 'rm -rf "$SANDBOX"' EXIT + +# ── Mock herdr CLI ───────────────────────────────────────────────────── +# Records every invocation in $HERDR_LOG and answers the subcommands the +# script needs: pane current (CWD resolution), pane process-info (plugin +# CWD probe), plugin pane open (returns a created tab_id), tab rename. +MOCK_HERDR="$SANDBOX/mock-herdr" +cat > "$MOCK_HERDR" <> "$HERDR_LOG" +case "\$1" in + pane) + case "\$2" in + current) + echo '{"id":"cli:pane:current","result":{"pane":{"pane_id":"anchor-pane-9","cwd":"/mock/project"}},"type":"pane_info"}' + ;; + process-info) + # No shell_pid → script falls back to the pane cwd + echo '{"id":"cli:pane:process-info","result":{"process_info":{"shell_pid":""}},"type":"process_info"}' + ;; + *) + echo "mock: unknown pane subcommand \$2" >&2 + exit 1 + ;; + esac + ;; + plugin) + if [ "\$2" = "pane" ] && [ "\$3" = "open" ]; then + echo '{"id":"cli:plugin","result":{"plugin_pane":{"pane":{"tab_id":"w9:tPE","pane_id":"w9:p1","label":"Work Items"},"plugin_id":"worklog-selection-list"},"type":"plugin_pane_opened"}}' + exit 0 + fi + echo "mock: unknown plugin command" >&2 + exit 1 + ;; + tab) + if [ "\$2" = "rename" ]; then + echo "mock: renamed tab \$3 to \$4" + exit 0 + fi + echo "mock: unknown tab subcommand \$2" >&2 + exit 1 + ;; + *) + echo "mock: unknown command \$1" >&2 + exit 1 + ;; +esac +MOCK +chmod +x "$MOCK_HERDR" + +# Mock variants for failure paths +MOCK_HERDR_BAD_JSON="$SANDBOX/mock-herdr-bad-json" +cat > "$MOCK_HERDR_BAD_JSON" <> "$HERDR_LOG" +case "\$1" in + pane) + case "\$2" in + current) echo '{"result":{"pane":{"pane_id":"p1","cwd":"/mock"}}}' ;; + *) exit 1 ;; + esac + ;; + plugin) + echo "not json at all" + exit 0 + ;; + *) exit 1 ;; +esac +MOCK +chmod +x "$MOCK_HERDR_BAD_JSON" + +MOCK_HERDR_OPEN_FAILS="$SANDBOX/mock-herdr-open-fails" +cat > "$MOCK_HERDR_OPEN_FAILS" <> "$HERDR_LOG" +case "\$1" in + pane) + case "\$2" in + current) echo '{"result":{"pane":{"pane_id":"p1","cwd":"/mock"}}}' ;; + *) exit 1 ;; + esac + ;; + plugin) + echo "plugin pane open failed: server unreachable" >&2 + exit 1 + ;; + *) exit 1 ;; +esac +MOCK +chmod +x "$MOCK_HERDR_OPEN_FAILS" + +# ── Helper: run the script with a mock and capture output ────────────── + +run_script() { + rm -f "$HERDR_LOG" + HERDR_BIN_PATH="$1" bash "$OPEN_PODCAST_EDITOR" 2>&1 +} + +echo "=== Test: script exists and is executable ===" +[ -f "$OPEN_PODCAST_EDITOR" ] && pass "open-podcast-editor-tab.sh exists" || fail "open-podcast-editor-tab.sh missing" +[ -x "$OPEN_PODCAST_EDITOR" ] && pass "open-podcast-editor-tab.sh executable" || fail "open-podcast-editor-tab.sh not executable" + +echo "" +echo "=== Test: happy path — opens pane, parses tab_id, renames tab ===" +out="$(run_script "$MOCK_HERDR")" +RC=$? + +if [ "$RC" -eq 0 ]; then + pass "script exits 0" +else + fail "script should exit 0, got $RC" +fi + +if grep -q 'plugin pane open --plugin worklog-selection-list --entrypoint worklist --placement tab' "$HERDR_LOG" 2>/dev/null; then + pass "worklist pane opened in a tab" +else + fail "worklist pane open command missing from mock log" + echo " log: $(cat "$HERDR_LOG" 2>/dev/null)" +fi + +if grep -q 'tab rename w9:tPE Podcast Editing' "$HERDR_LOG" 2>/dev/null; then + pass "tab renamed to 'Podcast Editing' with the parsed tab_id" +else + fail "tab rename command missing or wrong (expected 'tab rename w9:tPE Podcast Editing')" + echo " log: $(cat "$HERDR_LOG" 2>/dev/null)" +fi + +if echo "$out" | grep -q 'Opened podcast editing tab'; then + pass "success message printed" +else + fail "success message missing" +fi + +echo "" +echo "=== Test: herdr CLI unavailable → clear error, non-zero exit ===" +out="$(run_script "$SANDBOX/no-such-herdr")" +RC=$? + +if [ "$RC" -ne 0 ]; then + pass "exits non-zero when herdr CLI is unavailable" +else + fail "should exit non-zero when herdr CLI is unavailable" +fi + +if echo "$out" | grep -qi "herdr CLI not found"; then + pass "clear 'herdr CLI not found' error printed" +else + fail "missing clear error message" + echo " output: $out" +fi + +echo "" +echo "=== Test: unparseable tab_id → clear error, non-zero exit, no rename ===" +rm -f "$HERDR_LOG" +out="$(HERDR_BIN_PATH="$MOCK_HERDR_BAD_JSON" bash "$OPEN_PODCAST_EDITOR" 2>&1)" +RC=$? + +if [ "$RC" -ne 0 ]; then + pass "exits non-zero when tab_id cannot be parsed" +else + fail "should exit non-zero when tab_id cannot be parsed" +fi + +if echo "$out" | grep -qi "could not parse tab_id"; then + pass "clear 'could not parse tab_id' error printed" +else + fail "missing parse error message" + echo " output: $out" +fi + +if grep -q 'tab rename' "$HERDR_LOG" 2>/dev/null; then + fail "rename must not be called when tab_id cannot be parsed" +else + pass "rename never called when tab_id cannot be parsed" +fi + +echo "" +echo "=== Test: plugin pane open failure → clear error, non-zero exit ===" +out="$(run_script "$MOCK_HERDR_OPEN_FAILS")" +RC=$? + +if [ "$RC" -ne 0 ]; then + pass "exits non-zero when plugin pane open fails" +else + fail "should exit non-zero when plugin pane open fails" +fi + +if echo "$out" | grep -qi "plugin pane open.*failed"; then + pass "clear open-failure error printed" +else + fail "missing open-failure error message" + echo " output: $out" +fi + +echo "" +echo "========================================" +echo "Results: $PASS passed, $FAIL failed" +echo "========================================" +[ "$FAIL" -eq 0 ] diff --git a/packages/herdr/shared/grid.py b/packages/herdr/shared/grid.py old mode 100644 new mode 100755 index 31fcc4a95..6054ec21f --- a/packages/herdr/shared/grid.py +++ b/packages/herdr/shared/grid.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """Grid rebalance helper for the herdr even-completion grid algorithm. This module provides a Python helper for growing an even-completion grid of @@ -497,7 +496,7 @@ def main() -> None: print("Error: herdr did not return a new pane id", file=sys.stderr) sys.exit(1) print(json.dumps({"pane_id": new_pane_id})) - except Exception as e: + except (OSError, ValueError, RuntimeError) as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) diff --git a/packages/herdr/shared/herdr-agent-state-protocol.md b/packages/herdr/shared/herdr-agent-state-protocol.md index e448b282c..2d9945008 100644 --- a/packages/herdr/shared/herdr-agent-state-protocol.md +++ b/packages/herdr/shared/herdr-agent-state-protocol.md @@ -123,6 +123,12 @@ Sent when the agent is shutting down or going out of scope (e.g., on `Drop`). | `working` | Background work in progress (e.g. TTS generation, review pipeline) | | `blocked` | Waiting for user input (e.g. note-editing mode, confirmation dialog) | +> **Consumed by the worklist plugin:** the herdr worklist plugin +> (WL-0MSBQUJQX005RAT9) mirrors these states to render a per-row agent-status +> icon (🟢 working / ⛔ blocked / ⚪ idle). The herdr CLI's `agent list` +> command is the authoritative status source; the plugin queries it with a +> TTL memoizer and fails open (no icons) when it is unavailable. + ## ID Generation Message IDs follow the pattern: diff --git a/packages/herdr/shared/send-to-pi.sh b/packages/herdr/shared/send-to-pi.sh index c1581b47a..46ae36a72 100755 --- a/packages/herdr/shared/send-to-pi.sh +++ b/packages/herdr/shared/send-to-pi.sh @@ -23,6 +23,9 @@ # (herdr pane split --cwd) modes. # --model Forward `--model ` to the pi CLI invocation # (pi model pattern or id, e.g. `code` or `provider/id`) +# --pane-id-file Write the new pane ID as JSON ({"pane_id": ""}) to +# immediately after the split succeeds. Backward +# compatible: absent flag = current behavior (no file). # -h, --help Show this help message # # Environment variables: @@ -51,6 +54,7 @@ focus=true check_cli=false cwd_arg="" model_arg="" +pane_id_file="" resize=true herdr_bin="${HERDR_BIN_PATH:-herdr}" grid_bin="${HERDR_GRID_BIN:-$(cd "$(dirname "$0")" && pwd)/grid.py}" @@ -102,6 +106,14 @@ while [[ $# -gt 0 ]]; do model_arg="${1#*=}" shift ;; + --pane-id-file) + pane_id_file="$2" + shift 2 + ;; + --pane-id-file=*) + pane_id_file="${1#*=}" + shift + ;; -h|--help) show_help ;; @@ -179,6 +191,15 @@ if [ -z "$np" ]; then exit 1 fi +# ── Publish the new pane ID (optional) ────────────────────────────── +# When --pane-id-file is given, write the pane ID immediately after the split +# succeeds so the calling plugin can record the pane association without +# parsing herdr output itself. Best effort: a failed write must not abort the +# (already successful) pane spawn. +if [ -n "$pane_id_file" ]; then + printf '{"pane_id":"%s"}\n' "$np" > "$pane_id_file" 2>/dev/null || true +fi + # ── Run pi with the command in the new pane ────────────────────────── quoted_cmd="$(printf '%q' "$COMMAND")" if [ -n "$model_arg" ]; then diff --git a/packages/herdr/shared/tests/test_grid.py b/packages/herdr/shared/tests/test_grid.py index 63c9f4393..4235b189b 100644 --- a/packages/herdr/shared/tests/test_grid.py +++ b/packages/herdr/shared/tests/test_grid.py @@ -790,7 +790,7 @@ def test_cli_prints_pane_id_json(self, mock, tmp_path): env["HERDR_SOCKET_PORT"] = str(os.environ["HERDR_SOCKET_PORT"]) result = subprocess.run( [sys.executable, str(_SHARED_DIR / "grid.py"), "w1:p80"], - capture_output=True, text=True, env=env, cwd=str(_SHARED_DIR), + capture_output=True, text=True, env=env, cwd=str(_SHARED_DIR), check=False, ) assert result.returncode == 0, result.stderr out = json.loads(result.stdout) @@ -802,7 +802,7 @@ def test_cli_failure_nonzero_exit(self): env["HERDR_SOCKET_PORT"] = "1" result = subprocess.run( [sys.executable, str(_SHARED_DIR / "grid.py"), "w1:p80"], - capture_output=True, text=True, env=env, cwd=str(_SHARED_DIR), + capture_output=True, text=True, env=env, cwd=str(_SHARED_DIR), check=False, ) assert result.returncode != 0 assert "Error" in result.stderr diff --git a/packages/herdr/shared/tests/test_scripts.sh b/packages/herdr/shared/tests/test_scripts.sh index 576f850ec..ad971530d 100755 --- a/packages/herdr/shared/tests/test_scripts.sh +++ b/packages/herdr/shared/tests/test_scripts.sh @@ -255,6 +255,49 @@ else echo " herdr log: $(cat "$HERDR_LOG" 2>/dev/null)" fi +echo "" +echo "=== Test: --pane-id-file writes the new pane id after the split (resize mode) ===" +PID_FILE="$SANDBOX/pane-id.json" +rm -f "$PID_FILE" +run_send --pane-id-file "$PID_FILE" "hello" >/dev/null 2>&1 || true +if [ -f "$PID_FILE" ] && grep -q '"pane_id":"grid-pane-5"' "$PID_FILE"; then + pass "send-to-pi --pane-id-file writes the grid pane id (resize mode)" +else + fail "send-to-pi --pane-id-file should write the grid pane id" + echo " file: $(cat "$PID_FILE" 2>/dev/null)" +fi + +rm -f "$PID_FILE" +run_send --no-resize --pane-id-file "$PID_FILE" "hello" >/dev/null 2>&1 || true +if [ -f "$PID_FILE" ] && grep -q '"pane_id":"plain-pane-1"' "$PID_FILE"; then + pass "send-to-pi --pane-id-file writes the plain-split pane id (--no-resize)" +else + fail "send-to-pi --pane-id-file should write the plain-split pane id" + echo " file: $(cat "$PID_FILE" 2>/dev/null)" +fi + +rm -f "$PID_FILE" +run_send "hello" >/dev/null 2>&1 || true +if [ ! -f "$PID_FILE" ]; then + pass "send-to-pi without --pane-id-file writes no file (backward compatible)" +else + fail "send-to-pi without --pane-id-file must not write a pane-id file" +fi + +rm -f "$PID_FILE" +run_send --pane-id-file "$SANDBOX/missing-dir/pane-id.json" "hello" >/dev/null 2>&1 || true +if [ ! -f "$SANDBOX/missing-dir/pane-id.json" ]; then + pass "send-to-pi tolerates an unwritable --pane-id-file path (spawn not aborted)" +else + fail "send-to-pi should tolerate an unwritable --pane-id-file path" +fi + +if grep -q "pane run grid-pane-5 exec pi" "$HERDR_LOG" 2>/dev/null; then + pass "send-to-pi --pane-id-file still spawns pi after writing the file" +else + fail "send-to-pi --pane-id-file should still spawn pi" +fi + echo "" echo "=== Test: open-pi-agent --pane-name and --cwd ===" run_open --pane-name "My Agent" --cwd /tmp/proj >/dev/null 2>&1 || true diff --git a/packages/herdr/src/agent-tracker.test.ts b/packages/herdr/src/agent-tracker.test.ts new file mode 100644 index 000000000..495d61b7e --- /dev/null +++ b/packages/herdr/src/agent-tracker.test.ts @@ -0,0 +1,325 @@ +/** + * packages/herdr/src/agent-tracker.test.ts — Unit tests for the + * work-item ↔ agent-pane association tracker (WL-0MSBQUJQX005RAT9). + * + * Covers: + * - recordAgentForWorkItem / getPaneId (in-memory + persisted) + * - persistence: atomic state file round-trip across tracker instances + * - refreshStates: herdr agent list parsing, state mapping, pruning of + * gone/done panes, PollGate-style TTL memoization + * - fail-open: CLI errors / unparseable output keep entries and yield no + * icons + * + * Run: npx vitest run packages/herdr/src/agent-tracker.test.ts + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + AgentTracker, + normalizeAgentState, + parseAgentListOutput, + type AgentState, +} from './agent-tracker.js'; +import { + setExecFileAsync, + resetExecFileAsync, +} from './fetcher.js'; + +/** + * Build a herdr-style `agent list` JSON envelope. The CLI may prefix the + * envelope with log lines, so the parser must scan for the first `{`. + */ +function agentListEnvelope(agents: Array>, prefix = ''): string { + return `${prefix}${JSON.stringify({ + id: 'cli:agent:list', + result: { agents }, + })}`; +} + +function makeStateFile(): string { + const dir = mkdtempSync(join(tmpdir(), 'herdr-agent-tracker-')); + return join(dir, 'agent-panes.json'); +} + +describe('AgentTracker — record & in-memory lookup', () => { + it('records a workItemId → paneId association', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile() }); + await tracker.recordAgentForWorkItem('WL-ABC', 'pane-1'); + expect(tracker.getPaneId('WL-ABC')).toBe('pane-1'); + }); + + it('returns undefined for unknown work items', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile() }); + expect(tracker.getPaneId('WL-NOPE')).toBeUndefined(); + }); + + it('overwrites an existing association for the same work item', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile() }); + await tracker.recordAgentForWorkItem('WL-ABC', 'pane-1'); + await tracker.recordAgentForWorkItem('WL-ABC', 'pane-2'); + expect(tracker.getPaneId('WL-ABC')).toBe('pane-2'); + expect(tracker.size).toBe(1); + }); +}); + +describe('AgentTracker — persistence', () => { + it('persists the mapping to the state file (atomic write)', async () => { + const stateFile = makeStateFile(); + const tracker = new AgentTracker({ stateFile }); + await tracker.recordAgentForWorkItem('WL-ABC', 'pane-1'); + await tracker.recordAgentForWorkItem('WL-DEF', 'pane-2'); + + expect(existsSync(stateFile)).toBe(true); + const raw = JSON.parse(readFileSync(stateFile, 'utf8')); + expect(raw).toHaveLength(2); + expect(raw[0]).toMatchObject({ workItemId: 'WL-ABC', paneId: 'pane-1' }); + expect(raw[1]).toMatchObject({ workItemId: 'WL-DEF', paneId: 'pane-2' }); + }); + + it('restores the mapping across tracker instances (plugin restart)', async () => { + const stateFile = makeStateFile(); + const first = new AgentTracker({ stateFile }); + await first.recordAgentForWorkItem('WL-ABC', 'pane-1'); + + const second = new AgentTracker({ stateFile }); + expect(second.getPaneId('WL-ABC')).toBe('pane-1'); + }); + + it('tolerates a missing or corrupt state file (fail-open)', async () => { + const stateFile = makeStateFile(); + // Missing file — no entries, no throw. + const empty = new AgentTracker({ stateFile: join(stateFile, 'nope.json') }); + expect(empty.size).toBe(0); + + // Corrupt file — no entries, no throw, and recording still works. + writeFileSync(stateFile, '{not json', 'utf8'); + const corrupt = new AgentTracker({ stateFile }); + expect(corrupt.size).toBe(0); + await corrupt.recordAgentForWorkItem('WL-ABC', 'pane-1'); + expect(corrupt.getPaneId('WL-ABC')).toBe('pane-1'); + }); + + it('pruneEntry removes and persists the change', async () => { + const stateFile = makeStateFile(); + const tracker = new AgentTracker({ stateFile }); + await tracker.recordAgentForWorkItem('WL-ABC', 'pane-1'); + await tracker.recordAgentForWorkItem('WL-DEF', 'pane-2'); + await tracker.pruneEntry('WL-ABC'); + + expect(tracker.getPaneId('WL-ABC')).toBeUndefined(); + expect(tracker.size).toBe(1); + const raw = JSON.parse(readFileSync(stateFile, 'utf8')); + expect(raw).toHaveLength(1); + expect(raw[0].workItemId).toBe('WL-DEF'); + }); +}); + +describe('normalizeAgentState', () => { + it('maps known statuses to canonical AgentState values', () => { + expect(normalizeAgentState('idle')).toBe('idle'); + expect(normalizeAgentState('working')).toBe('working'); + expect(normalizeAgentState('blocked')).toBe('blocked'); + expect(normalizeAgentState('done')).toBe('done'); + }); + + it('maps unknown/empty statuses to unknown', () => { + expect(normalizeAgentState('weird')).toBe('unknown'); + expect(normalizeAgentState('')).toBe('unknown'); + expect(normalizeAgentState(undefined)).toBe('unknown'); + expect(normalizeAgentState('WORKING')).toBe('working'); // case-insensitive + }); +}); + +describe('parseAgentListOutput', () => { + it('parses the result.agents array shape', () => { + const records = parseAgentListOutput( + agentListEnvelope([ + { pane_id: 'p1', agent_status: 'working' }, + { pane_id: 'p2', agent_status: 'blocked' }, + ]), + ); + expect(records).toEqual([ + { paneId: 'p1', status: 'working' }, + { paneId: 'p2', status: 'blocked' }, + ]); + }); + + it('tolerates log lines prefixed before the JSON envelope', () => { + const records = parseAgentListOutput( + agentListEnvelope([{ pane_id: 'p1', agent_status: 'idle' }], 'some log line\n'), + ); + expect(records).toEqual([{ paneId: 'p1', status: 'idle' }]); + }); + + it('reads camelCase aliases (paneId / agentStatus)', () => { + const records = parseAgentListOutput( + JSON.stringify({ result: { agents: [{ paneId: 'p1', agentStatus: 'working' }] } }), + ); + expect(records).toEqual([{ paneId: 'p1', status: 'working' }]); + }); + + it('reads a bare status field when agent_status is absent', () => { + const records = parseAgentListOutput( + JSON.stringify({ result: { agents: [{ pane_id: 'p1', status: 'done' }] } }), + ); + expect(records).toEqual([{ paneId: 'p1', status: 'done' }]); + }); + + it('returns null for unparseable output (fail-open trigger)', () => { + expect(parseAgentListOutput('not json at all')).toBeNull(); + expect(parseAgentListOutput('')).toBeNull(); + }); + + it('returns null when no agent array is present', () => { + expect(parseAgentListOutput(JSON.stringify({ result: { ok: true } }))).toBeNull(); + expect(parseAgentListOutput(JSON.stringify({ result: { agents: 'nope' } }))).toBeNull(); + }); +}); + +describe('AgentTracker — refreshStates', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + resetExecFileAsync(); + delete process.env.HERDR_BIN_PATH; + }); + + afterEach(() => { + resetExecFileAsync(); + process.env.HERDR_BIN_PATH = originalEnv.HERDR_BIN_PATH; + }); + + it('maps live agent panes to states and prunes gone panes', async () => { + const stateFile = makeStateFile(); + const tracker = new AgentTracker({ stateFile }); + await tracker.recordAgentForWorkItem('WL-ABC', 'p1'); + await tracker.recordAgentForWorkItem('WL-GONE', 'pX'); + + const mockFn = vi.fn().mockResolvedValue({ + stdout: agentListEnvelope([{ pane_id: 'p1', agent_status: 'working' }]), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + const states = await tracker.refreshStates(); + expect(states.get('WL-ABC')).toBe('working'); + expect(states.has('WL-GONE')).toBe(false); // pane pX not an agent → pruned + expect(tracker.getPaneId('WL-GONE')).toBeUndefined(); // pruned in memory + // Pruning is persisted. + const raw = JSON.parse(readFileSync(stateFile, 'utf8')); + expect(raw).toHaveLength(1); + expect(raw[0].workItemId).toBe('WL-ABC'); + // The CLI is invoked as `herdr agent list` (HERDR_BIN_PATH unset → herdr). + expect(mockFn.mock.calls[0][1]).toEqual(['agent', 'list']); + }); + + it('maps idle/working/blocked and prunes done panes', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile() }); + await tracker.recordAgentForWorkItem('WL-1', 'p1'); + await tracker.recordAgentForWorkItem('WL-2', 'p2'); + await tracker.recordAgentForWorkItem('WL-3', 'p3'); + await tracker.recordAgentForWorkItem('WL-DONE', 'p4'); + + const mockFn = vi.fn().mockResolvedValue({ + stdout: agentListEnvelope([ + { pane_id: 'p1', agent_status: 'idle' }, + { pane_id: 'p2', agent_status: 'working' }, + { pane_id: 'p3', agent_status: 'blocked' }, + { pane_id: 'p4', agent_status: 'done' }, + ]), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + const states = await tracker.refreshStates(); + expect(states.get('WL-1')).toBe('idle'); + expect(states.get('WL-2')).toBe('working'); + expect(states.get('WL-3')).toBe('blocked'); + // done → no icon: pruned. + expect(states.has('WL-DONE')).toBe(false); + expect(tracker.getPaneId('WL-DONE')).toBeUndefined(); + }); + + it('fails open when the herdr CLI errors: no icons, entries kept', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile() }); + await tracker.recordAgentForWorkItem('WL-ABC', 'p1'); + + const mockFn = vi.fn().mockRejectedValue({ code: 'ENOENT' }); + setExecFileAsync(mockFn as any); + + const states = await tracker.refreshStates(); + expect(states.size).toBe(0); + // Entries survive the failed refresh so icons reappear when the CLI returns. + expect(tracker.getPaneId('WL-ABC')).toBe('p1'); + }); + + it('fails open on unparseable CLI output: no icons, entries kept', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile() }); + await tracker.recordAgentForWorkItem('WL-ABC', 'p1'); + + const mockFn = vi.fn().mockResolvedValue({ stdout: 'garbage', stderr: '' }); + setExecFileAsync(mockFn as any); + + const states = await tracker.refreshStates(); + expect(states.size).toBe(0); + expect(tracker.getPaneId('WL-ABC')).toBe('p1'); + }); + + it('memoizes the herdr query within the TTL (PollGate pattern)', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile(), ttlMs: 60_000 }); + await tracker.recordAgentForWorkItem('WL-ABC', 'p1'); + + const mockFn = vi.fn().mockResolvedValue({ + stdout: agentListEnvelope([{ pane_id: 'p1', agent_status: 'working' }]), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + const first = await tracker.refreshStates(); + const second = await tracker.refreshStates(); + expect(first.get('WL-ABC')).toBe('working'); + expect(second.get('WL-ABC')).toBe('working'); + expect(mockFn).toHaveBeenCalledTimes(1); // second call served from cache + }); + + it('uses HERDR_BIN_PATH when set', async () => { + process.env.HERDR_BIN_PATH = '/custom/herdr'; + const tracker = new AgentTracker({ stateFile: makeStateFile(), ttlMs: 60_000 }); + await tracker.recordAgentForWorkItem('WL-ABC', 'p1'); + + const mockFn = vi.fn().mockResolvedValue({ + stdout: agentListEnvelope([{ pane_id: 'p1', agent_status: 'idle' }]), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + await tracker.refreshStates(); + expect(mockFn.mock.calls[0][0]).toBe('/custom/herdr'); + }); + + it('reloads the state file on refresh so other panes/tabs entries are shared', async () => { + const stateFile = makeStateFile(); + const tabA = new AgentTracker({ stateFile }); + await tabA.recordAgentForWorkItem('WL-ABC', 'p1'); + + // Tab B starts after A persisted, then A adds another entry while B runs. + const tabB = new AgentTracker({ stateFile, ttlMs: 60_000 }); + await tabA.recordAgentForWorkItem('WL-DEF', 'p2'); + + const mockFn = vi.fn().mockResolvedValue({ + stdout: agentListEnvelope([ + { pane_id: 'p1', agent_status: 'working' }, + { pane_id: 'p2', agent_status: 'blocked' }, + ]), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + const states = await tabB.refreshStates(); + expect(states.get('WL-ABC')).toBe('working'); + expect(states.get('WL-DEF')).toBe('blocked'); + }); +}); diff --git a/packages/herdr/src/agent-tracker.ts b/packages/herdr/src/agent-tracker.ts new file mode 100644 index 000000000..4b1cefbef --- /dev/null +++ b/packages/herdr/src/agent-tracker.ts @@ -0,0 +1,338 @@ +/** + * packages/herdr/src/agent-tracker.ts — Work-item ↔ agent-pane association + * tracker (WL-0MSBQUJQX005RAT9) + * + * Records which work-item each worklist-spawned pi agent pane is working on, + * persists the mapping to a gitignored JSON state file + * (`.worklog/agent-panes.json`), and resolves each pane's current agent + * state (`idle` / `working` / `blocked` / `done`) via the herdr CLI so the + * worklist can render an agent-status icon per row. + * + * Design notes: + * - Only worklist-spawned agent commands are tracked (the association is + * recorded at dispatch time when the new pane ID is captured). + * - The state file is shared across worklist panes/tabs: each refresh + * re-reads the file (last-writer-wins) before querying herdr. + * - Status lookup fails open: when the herdr CLI is unavailable or its + * output is unparseable, `refreshStates()` returns an empty map (no + * icons) and existing entries are kept so icons reappear when the CLI + * returns. + * - Entries whose pane is gone (no longer in `herdr agent list`) or whose + * agent reports `done` are pruned so the icon disappears. + * - The `herdr agent list` query is memoized within a TTL (PollGate + * pattern from visibility.ts) to avoid CLI spawn churn on every refresh + * tick. + * + * The implementation is self-contained and portable (pure functions where + * feasible) to ease the planned Rust migration (WL-0MS8XGSE400374VS). + */ + +import { existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs'; +import { getExecFileAsync } from './fetcher.js'; +import type { WorkItem } from './fetcher.js'; + +// ── Types ──────────────────────────────────────────────────────────── + +/** + * Agent states mirrored from the herdr sidebar protocol + * (packages/herdr/shared/herdr-agent-state-protocol.md): `idle`, `working`, + * `blocked` plus the CLI-terminal `done` and the catch-all `unknown`. + */ +export type AgentState = 'idle' | 'working' | 'blocked' | 'done' | 'unknown'; + +/** One recorded work-item ↔ pane association. */ +export interface AgentPaneEntry { + workItemId: string; + paneId: string; + /** ISO timestamp of when the association was recorded. */ + recordedAt: string; +} + +/** A parsed `herdr agent list` record. */ +export interface AgentListRecord { + paneId: string; + status: string; +} + +// ── Constants ──────────────────────────────────────────────────────── + +/** Name of the state file inside the project's `.worklog/` directory. */ +export const AGENT_PANES_FILE = 'agent-panes.json'; + +/** Default TTL for the refreshStates memoizer (mirrors visibility.ts). */ +export const AGENT_STATE_TTL_MS = 2000; + +// ── Pure helpers ───────────────────────────────────────────────────── + +/** + * Normalize a raw agent-status string from the herdr CLI into an AgentState. + * Unknown/empty values map to `unknown`. + */ +export function normalizeAgentState(status: string | undefined): AgentState { + const key = (status || '').toLowerCase().trim(); + if (key === 'idle' || key === 'working' || key === 'blocked' || key === 'done') { + return key; + } + return 'unknown'; +} + +/** + * Parse `herdr agent list` output into pane → status records. + * + * Tolerates log lines prefixed before the JSON envelope (scan for the first + * `{`, like fetcher.ts), multiple envelope shapes, and snake_case/camelCase + * field aliases. Returns null when no agent array can be found (the caller + * treats this as fail-open). + */ +export function parseAgentListOutput(raw: string): AgentListRecord[] | null { + const start = raw.indexOf('{'); + if (start < 0) return null; + let payload: unknown; + try { + payload = JSON.parse(raw.slice(start)); + } catch { + return null; + } + + let agents: unknown = null; + if (payload && typeof payload === 'object') { + const obj = payload as Record; + const result = obj.result; + const resultObj = result && typeof result === 'object' ? (result as Record) : null; + if (Array.isArray(resultObj?.agents)) { + agents = resultObj.agents; + } else if (Array.isArray(obj.agents)) { + agents = obj.agents; + } else if (Array.isArray(result)) { + agents = result; + } else if (Array.isArray(obj)) { + agents = obj; + } + } + + if (!Array.isArray(agents)) return null; + + const records: AgentListRecord[] = []; + for (const entry of agents) { + if (!entry || typeof entry !== 'object') continue; + const rec = entry as Record; + const paneId = rec.pane_id ?? rec.paneId; + const status = rec.agent_status ?? rec.agentStatus ?? rec.status; + if (typeof paneId === 'string' && paneId !== '' && typeof status === 'string') { + records.push({ paneId, status }); + } + } + return records; +} + +// ── Tracker ────────────────────────────────────────────────────────── + +export interface AgentTrackerOptions { + /** Path to the persisted state file (`.worklog/agent-panes.json`). */ + stateFile?: string; + /** TTL for refreshStates memoization (PollGate pattern). */ + ttlMs?: number; +} + +/** + * In-memory + persisted mapping from work item ID → agent pane ID, with + * herdr-backed state refresh. Never throws on herdr/fs errors (fail-open). + */ +export class AgentTracker { + private readonly stateFile?: string; + private readonly ttlMs: number; + private readonly entries = new Map(); + private cachedStates = new Map(); + private cachedAt = 0; + + constructor(opts: AgentTrackerOptions = {}) { + this.stateFile = opts.stateFile; + this.ttlMs = opts.ttlMs ?? AGENT_STATE_TTL_MS; + this.loadFromDisk(); + } + + /** Number of tracked associations. */ + get size(): number { + return this.entries.size; + } + + /** Snapshot of the tracked entries (read-only for callers). */ + snapshot(): AgentPaneEntry[] { + return [...this.entries.values()]; + } + + /** + * Record (or overwrite) the association for a work item and persist it. + * Persistence failures are swallowed (fail-open). + */ + async recordAgentForWorkItem(workItemId: string, paneId: string): Promise { + this.entries.set(workItemId, { workItemId, paneId, recordedAt: new Date().toISOString() }); + this.persist(); + } + + /** Remove a single association and persist the change. */ + async pruneEntry(workItemId: string): Promise { + if (this.entries.delete(workItemId)) { + this.persist(); + } + } + + /** Return the pane ID recorded for a work item, or undefined. */ + getPaneId(workItemId: string): string | undefined { + return this.entries.get(workItemId)?.paneId; + } + + /** + * Refresh the agent states for all tracked panes, memoized within the TTL. + * + * On a cache miss: re-reads the shared state file (so associations + * recorded by other panes/tabs are picked up), queries `herdr agent list` + * once, prunes entries whose pane is gone or whose agent reports `done`, + * persists any pruning, and returns the resulting + * `Map`. + * + * Fail-open: a herdr CLI error or unparseable output yields an empty map + * (no icons) and keeps all entries. + */ + async refreshStates(): Promise> { + const now = Date.now(); + if (now - this.cachedAt < this.ttlMs) { + return this.cachedStates; + } + + // Re-read the shared file so other worklist panes/tabs are reflected. + this.loadFromDisk(); + + const bin = process.env.HERDR_BIN_PATH ?? 'herdr'; + try { + const execFileAsync = getExecFileAsync(); + const result = await execFileAsync(bin, ['agent', 'list'], { maxBuffer: 1024 * 1024 }); + const records = parseAgentListOutput(result.stdout); + if (!records) { + throw new Error('Unparseable herdr agent list output'); + } + + const statusByPane = new Map(); + for (const rec of records) { + statusByPane.set(rec.paneId, rec.status); + } + + const states = new Map(); + let pruned = false; + for (const [workItemId, entry] of [...this.entries]) { + const status = statusByPane.get(entry.paneId); + if (status === undefined) { + // Pane is gone (closed/exited) or no longer an agent — prune so + // the icon disappears on the next refresh cycle (AC5). + this.entries.delete(workItemId); + pruned = true; + } else { + const state = normalizeAgentState(status); + if (state === 'done') { + // Agent finished; the pane may still be open but no icon is + // shown. Prune now — a re-dispatch records a fresh association. + this.entries.delete(workItemId); + pruned = true; + } else { + states.set(workItemId, state); + } + } + } + if (pruned) { + this.persist(); + } + + this.cachedStates = states; + this.cachedAt = Date.now(); + return states; + } catch { + // Fail-open: keep entries, render no icons this cycle. + this.cachedStates = new Map(); + this.cachedAt = Date.now(); + return this.cachedStates; + } + } + + // ── Persistence ──────────────────────────────────────────────────── + + /** + * Load the persisted state file into memory. Missing or corrupt files are + * tolerated (in-memory entries are left untouched on a parse failure so a + * half-written file never wipes the live mapping). + */ + private loadFromDisk(): void { + if (!this.stateFile) return; + if (!existsSync(this.stateFile)) return; + try { + const raw = JSON.parse(readFileSync(this.stateFile, 'utf8')); + const list = Array.isArray(raw) ? raw : (raw as { entries?: unknown })?.entries; + if (!Array.isArray(list)) return; + const next = new Map(); + for (const rec of list) { + if (!rec || typeof rec !== 'object') continue; + const entry = rec as Record; + const workItemId = entry.workItemId ?? entry.workItem ?? entry.id; + const paneId = entry.paneId; + if (typeof workItemId === 'string' && workItemId !== '' && typeof paneId === 'string') { + next.set(workItemId, { + workItemId, + paneId, + recordedAt: typeof entry.recordedAt === 'string' ? entry.recordedAt : new Date().toISOString(), + }); + } + } + this.entries.clear(); + for (const [k, v] of next) this.entries.set(k, v); + } catch { + // Corrupt file — keep the current in-memory mapping. + } + } + + /** + * Write the mapping atomically (write-temp-then-rename). Multiple + * worklist tabs may write concurrently — last-writer-wins is acceptable. + * Failures are swallowed (fail-open). + */ + private persist(): void { + if (!this.stateFile) return; + try { + const payload = JSON.stringify([...this.entries.values()], null, 2); + const tmp = `${this.stateFile}.tmp.${process.pid}`; + writeFileSync(tmp, payload, 'utf8'); + renameSync(tmp, this.stateFile); + } catch { + // Persistence must never break the live tracker. + } + } +} + +/** + * Merge the tracker's agent states into a list of work items (recursively + * into children), setting `agentState` only for states with a rendered icon + * (`idle` / `working` / `blocked`). `done` / `unknown` / missing states are + * left unset so no icon renders. Fail-open: never throws. + */ +export async function mergeAgentStates(items: WorkItem[], tracker: AgentTracker): Promise { + let states: Map; + try { + states = await tracker.refreshStates(); + } catch { + return; // fail-open: no icons this cycle + } + if (states.size === 0) return; + const apply = (item: WorkItem): void => { + const state = states.get(item.id); + if (state === 'idle' || state === 'working' || state === 'blocked') { + item.agentState = state; + } + }; + const walk = (list: WorkItem[]): void => { + for (const item of list) { + apply(item); + if (item.children && item.children.length > 0) { + walk(item.children); + } + } + }; + walk(items); +} diff --git a/packages/herdr/src/auto-sync.test.ts b/packages/herdr/src/auto-sync.test.ts index 76630778b..d93527f1d 100644 --- a/packages/herdr/src/auto-sync.test.ts +++ b/packages/herdr/src/auto-sync.test.ts @@ -7,6 +7,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Mock } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; // --------------------------------------------------------------------------- // Module-level state for the mocked child_process.spawn @@ -53,6 +56,11 @@ import { DEFAULT_SYNC_INTERVAL_MS, MIN_SYNC_INTERVAL_MS, SYNC_DISABLED, + DEFAULT_HEARTBEAT_TTL_MS, + heartbeatTtlForInterval, + isSyncHeartbeatFresh, + readSyncHeartbeatMs, + syncHeartbeatPath, _resetSyncInFlight, } from './auto-sync.js'; @@ -315,6 +323,117 @@ describe('runSync', () => { }); }); +// --------------------------------------------------------------------------- +// Cross-instance sync heartbeat (F3 — WL-0MSGAEJQA005QG3W) +// --------------------------------------------------------------------------- + +describe('sync heartbeat', () => { + let hbDir: string; + let worklogDir: string; + + beforeEach(() => { + hbDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wl-hb-')); + // herdr's worklog-dir override points at the .worklog/ subdirectory. + worklogDir = path.join(hbDir, '.worklog'); + }); + + afterEach(() => { + fs.rmSync(hbDir, { recursive: true, force: true }); + }); + + function writeHeartbeat(iso: string): void { + fs.mkdirSync(worklogDir, { recursive: true }); + fs.writeFileSync(path.join(worklogDir, 'last-sync-time'), iso, 'utf-8'); + } + + it('readSyncHeartbeatMs parses the CLI-written ISO marker', () => { + writeHeartbeat('2026-06-25T12:00:00.000Z'); + expect(readSyncHeartbeatMs(worklogDir)).toBe(Date.parse('2026-06-25T12:00:00.000Z')); + }); + + it('readSyncHeartbeatMs returns undefined when the marker is absent or unparseable', () => { + expect(readSyncHeartbeatMs(worklogDir)).toBeUndefined(); + writeHeartbeat('not-a-timestamp'); + expect(readSyncHeartbeatMs(worklogDir)).toBeUndefined(); + }); + + it('isSyncHeartbeatFresh is true within the TTL window and false after it (AC3: no indefinite skip)', () => { + const now = Date.parse('2026-06-25T12:00:00.000Z'); + writeHeartbeat(new Date(now - 10_000).toISOString()); // 10s ago + expect(isSyncHeartbeatFresh(worklogDir, DEFAULT_HEARTBEAT_TTL_MS, now)).toBe(true); + // 46s ago > 45s TTL → stale → a sync must spawn again. + writeHeartbeat(new Date(now - 46_000).toISOString()); + expect(isSyncHeartbeatFresh(worklogDir, DEFAULT_HEARTBEAT_TTL_MS, now)).toBe(false); + }); + + it('isSyncHeartbeatFresh is false when no heartbeat exists (first sync must spawn)', () => { + expect(isSyncHeartbeatFresh(worklogDir, DEFAULT_HEARTBEAT_TTL_MS)).toBe(false); + }); + + it('heartbeatTtlForInterval keeps the cadence: interval minus a 15s margin', () => { + expect(heartbeatTtlForInterval(60_000)).toBe(45_000); + expect(heartbeatTtlForInterval(300_000)).toBe(285_000); + expect(heartbeatTtlForInterval(0)).toBe(SYNC_DISABLED); + expect(heartbeatTtlForInterval(30_000)).toBe(45_000); // clamped to MIN first + }); + + it('runSync with a fresh heartbeat skips WITHOUT spawning a process', async () => { + childEventToFire = 'close'; + writeHeartbeat(new Date().toISOString()); + const outcome = await runSync(worklogDir, { ifIdle: true, heartbeat: true }); + expect(outcome.skipped).toBe(true); + expect(outcome.reason).toBe('heartbeat'); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('runSync with a stale heartbeat still spawns (TTL bounds the skip)', async () => { + childEventToFire = 'close'; + writeHeartbeat(new Date(Date.now() - 2 * DEFAULT_HEARTBEAT_TTL_MS).toISOString()); + const promise = runSync(worklogDir, { ifIdle: true, heartbeat: true }); + expect(mockSpawn).toHaveBeenCalledTimes(1); + expect(mockSpawn).toHaveBeenCalledWith('wl', ['--worklog-dir', worklogDir, 'sync', '--if-idle'], expect.any(Object)); + if (childEventCallback) childEventCallback(); + await promise; + }); + + it('runSync with no heartbeat spawns (first sync of a session)', async () => { + childEventToFire = 'close'; + const promise = runSync(worklogDir, { ifIdle: true, heartbeat: true }); + expect(mockSpawn).toHaveBeenCalledTimes(1); + if (childEventCallback) childEventCallback(); + await promise; + }); + + it('runSync without heartbeat option ignores the marker (manual syncs always run)', async () => { + childEventToFire = 'close'; + writeHeartbeat(new Date().toISOString()); + const promise = runSync(worklogDir, { ifIdle: true }); // no heartbeat flag + expect(mockSpawn).toHaveBeenCalledTimes(1); + if (childEventCallback) childEventCallback(); + await promise; + }); + + it('AC2 (measured): 6 panes on one worklog → only the first spawns within the heartbeat window', async () => { + childEventToFire = 'close'; + // Pane 1: no heartbeat yet → spawns (the sync will write the marker). + let promise = runSync(worklogDir, { ifIdle: true, heartbeat: true }); + expect(mockSpawn).toHaveBeenCalledTimes(1); + if (childEventCallback) childEventCallback(); + await promise; + + // The spawned wl sync succeeded → CLI wrote the heartbeat marker. + writeHeartbeat(new Date().toISOString()); + + // Panes 2-6 tick within the window → all skip without spawning. + for (let pane = 2; pane <= 6; pane++) { + const outcome = await runSync(worklogDir, { ifIdle: true, heartbeat: true }); + expect(outcome.skipped).toBe(true); + expect(outcome.reason).toBe('heartbeat'); + } + expect(mockSpawn).toHaveBeenCalledTimes(1); // 6 panes, 1 process spawned + }); +}); + // --------------------------------------------------------------------------- // SyncTimer // --------------------------------------------------------------------------- diff --git a/packages/herdr/src/auto-sync.ts b/packages/herdr/src/auto-sync.ts index 767a6735b..623bbb4af 100644 --- a/packages/herdr/src/auto-sync.ts +++ b/packages/herdr/src/auto-sync.ts @@ -13,6 +13,7 @@ */ import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; import * as path from 'node:path'; // ── Constants ───────────────────────────────────────────────────────── @@ -26,6 +27,65 @@ export const MIN_SYNC_INTERVAL_MS = 60_000; /** Sentinel value meaning "sync is disabled". */ export const SYNC_DISABLED = 0; +/** + * Default heartbeat freshness window (45s): MIN_SYNC_INTERVAL_MS minus a + * 15s margin. Must be SHORTER than the sync interval so at least one pane's + * tick lands outside the window each cycle — otherwise the heartbeat skip + * would suppress every future sync (indefinite skip, AC3). + */ +export const DEFAULT_HEARTBEAT_TTL_MS = MIN_SYNC_INTERVAL_MS - 15_000; + +// ── Cross-instance sync heartbeat (F3 — WL-0MSGAEJQA005QG3W) ────────── + +/** + * Heartbeat marker file: `/last-sync-time` containing an ISO + * timestamp. Written by the wl CLI only after a successful `wl sync` (see + * src/commands/sync.ts), so its presence means "a sync succeeded recently in + * SOME instance/process". + */ +export function syncHeartbeatPath(worklogDir: string): string { + return path.join(path.resolve(worklogDir), 'last-sync-time'); +} + +/** + * Read the last-success sync timestamp (epoch ms) for a worklog dir. + * Returns undefined when the marker is absent or unparseable. + */ +export function readSyncHeartbeatMs(worklogDir: string): number | undefined { + try { + const raw = fs.readFileSync(syncHeartbeatPath(worklogDir), 'utf-8').trim(); + const ts = Date.parse(raw); + return Number.isFinite(ts) ? ts : undefined; + } catch { + return undefined; + } +} + +/** + * True when a successful sync landed within the last `ttlMs` for the + * worklog dir. `now` is injectable for tests. + */ +export function isSyncHeartbeatFresh( + worklogDir: string, + ttlMs: number, + now: number = Date.now() +): boolean { + const ts = readSyncHeartbeatMs(worklogDir); + return ts !== undefined && now - ts < ttlMs; +} + +/** + * Heartbeat TTL for a configured sync interval: the interval minus a 15s + * margin (clamped to ≥1s), so syncs land roughly once per interval across + * all panes while only the first pane per window spawns. 0 (sync disabled) + * maps to 0. + */ +export function heartbeatTtlForInterval(intervalMs: number): number { + const clamped = clampSyncInterval(intervalMs); + if (clamped === SYNC_DISABLED) return SYNC_DISABLED; + return Math.max(1_000, clamped - 15_000); +} + // ── Single-flight state ─────────────────────────────────────────────── /** @@ -82,6 +142,24 @@ export interface RunSyncOptions { * they wait for the lock like a regular `wl sync`. */ ifIdle?: boolean; + /** + * Cross-instance heartbeat skip (auto-sync only): skip spawning `wl sync` + * entirely when a recent successful sync (heartbeat marker) exists for the + * worklog dir — so with 6 panes on one worklog, only the first to tick + * inside a window spawns a process. Manual/user syncs leave this off so + * they always run. + */ + heartbeat?: boolean; + /** Freshness window for the heartbeat (ms); default DEFAULT_HEARTBEAT_TTL_MS. */ + heartbeatTtlMs?: number; +} + +export interface SyncOutcome { + success: boolean; + error?: string; + skipped?: boolean; + /** Why the sync was skipped: `in-flight` (single-flight guard) or `heartbeat` (fresh marker). */ + reason?: 'in-flight' | 'heartbeat'; } /** @@ -95,18 +173,33 @@ export interface RunSyncOptions { * spawned CLI gets `--if-idle` so it exits immediately when the cross-process * file lock is held — preventing lock-storm process pile-up. * + * With `heartbeat`, the sync is skipped without spawning a process when a + * recent successful sync already happened in another instance (cross-instance + * coordination, F3). + * * @returns A promise resolving with the sync outcome. */ -export function runSync(worklogDir?: string, options?: RunSyncOptions): Promise<{ success: boolean; error?: string; skipped?: boolean }> { +export function runSync(worklogDir?: string, options?: RunSyncOptions): Promise { const ifIdle = options?.ifIdle ?? false; + // Cross-instance heartbeat skip: a successful sync in ANY pane/instance + // writes the heartbeat marker, so with 6 panes only the first to tick + // inside a window spawns `wl sync`; the rest skip without spawning a + // process (measured in the 6-pane spawn-reduction simulation). + if (options?.heartbeat && worklogDir) { + const ttlMs = options.heartbeatTtlMs ?? DEFAULT_HEARTBEAT_TTL_MS; + if (isSyncHeartbeatFresh(worklogDir, ttlMs)) { + return Promise.resolve({ success: false, skipped: true, reason: 'heartbeat', error: 'sync heartbeat fresh' }); + } + } + // Single-flight guard: never spawn a second sync while one is in-flight // (auto-sync path). The caller is told the sync was skipped. if (ifIdle && _syncInFlight) { return Promise.resolve({ success: false, skipped: true, error: 'sync already in progress' }); } - return new Promise<{ success: boolean; error?: string; skipped?: boolean }>((resolve) => { + return new Promise((resolve) => { try { // Target the resolved worklog (same as other wl invocations) so the // background sync operates on the tab project, not the plugin's CWD. diff --git a/packages/herdr/src/downtime-log.test.ts b/packages/herdr/src/downtime-log.test.ts new file mode 100644 index 000000000..d6d4a46d7 --- /dev/null +++ b/packages/herdr/src/downtime-log.test.ts @@ -0,0 +1,79 @@ +/** + * Unit tests for downtime-log.ts — bounded JSONL audit log for herdr + * downtime dispatches (WL-0MSGPI4AR000YOK8, parent WL-0MSF49FMW009M06K). + * + * The log lives at `/.worklog/downtime-dispatches.log` and is bounded + * (rolling): the file keeps only the most recent DOWNTIME_LOG_MAX_ENTRIES + * entries so it cannot grow unbounded over a long-lived plugin pane. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + appendDowntimeLogEntry, + DOWNTIME_LOG_FILE, + DOWNTIME_LOG_MAX_ENTRIES, +} from './downtime-log.js'; + +const tempDirs: string[] = []; + +function makeTempCwd(): string { + const dir = mkdtempSync(join(tmpdir(), 'downtime-log-')); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function readLog(cwd: string): string[] { + const raw = readFileSync(join(cwd, '.worklog', DOWNTIME_LOG_FILE), 'utf8'); + return raw + .split('\n') + .filter((line) => line.trim() !== ''); +} + +describe('downtime rolling log', () => { + it('creates .worklog and appends a JSONL entry under the given cwd', async () => { + const cwd = makeTempCwd(); + const entry = JSON.stringify({ itemId: 'WL-A', kind: 'plan', n: 1 }); + + await appendDowntimeLogEntry(cwd, entry); + + const lines = readLog(cwd); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0])).toEqual({ itemId: 'WL-A', kind: 'plan', n: 1 }); + }); + + it('appends to an existing log file without truncating prior entries', async () => { + const cwd = makeTempCwd(); + mkdirSync(join(cwd, '.worklog')); + writeFileSync(join(cwd, '.worklog', DOWNTIME_LOG_FILE), '{"n":1}\n', 'utf8'); + + await appendDowntimeLogEntry(cwd, '{"n":2}'); + + const lines = readLog(cwd); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0])).toEqual({ n: 1 }); + expect(JSON.parse(lines[1])).toEqual({ n: 2 }); + }); + + it('keeps only the most recent DOWNTIME_LOG_MAX_ENTRIES entries (rolling bound)', async () => { + const cwd = makeTempCwd(); + const total = DOWNTIME_LOG_MAX_ENTRIES + 20; + + for (let i = 0; i < total; i++) { + await appendDowntimeLogEntry(cwd, JSON.stringify({ n: i })); + } + + const lines = readLog(cwd); + expect(lines).toHaveLength(DOWNTIME_LOG_MAX_ENTRIES); + expect(JSON.parse(lines[0])).toEqual({ n: total - DOWNTIME_LOG_MAX_ENTRIES }); + expect(JSON.parse(lines[lines.length - 1])).toEqual({ n: total - 1 }); + }); +}); diff --git a/packages/herdr/src/downtime-log.ts b/packages/herdr/src/downtime-log.ts new file mode 100644 index 000000000..2f52149ee --- /dev/null +++ b/packages/herdr/src/downtime-log.ts @@ -0,0 +1,46 @@ +/** + * Bounded JSONL audit log for herdr downtime dispatches + * (WL-0MSGPI4AR000YOK8, parent WL-0MSF49FMW009M06K). + * + * The log lives at `/.worklog/downtime-dispatches.log` (the `.worklog` + * directory is gitignored, so the file is a local artifact; the worklog + * comment added alongside it is the durable cross-machine trail). The file + * is bounded — only the most recent DOWNTIME_LOG_MAX_ENTRIES entries are + * kept — so it rolls instead of growing unbounded over a long-lived plugin + * pane. Callers must treat failures as fail-closed (never crash the worker). + */ + +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** File name of the downtime dispatch audit log inside `.worklog/`. */ +export const DOWNTIME_LOG_FILE = 'downtime-dispatches.log'; + +/** Rolling bound: keep at most this many entries in the log file. */ +export const DOWNTIME_LOG_MAX_ENTRIES = 100; + +/** + * Append one entry (a JSONL line) to `/.worklog/downtime-dispatches.log`, + * creating the `.worklog` directory if needed and trimming the file back to + * the most recent DOWNTIME_LOG_MAX_ENTRIES entries. Throws on I/O failure — + * callers must catch (fail-closed). + */ +export async function appendDowntimeLogEntry(cwd: string, entry: string): Promise { + const dir = join(cwd, '.worklog'); + const file = join(dir, DOWNTIME_LOG_FILE); + await mkdir(dir, { recursive: true }); + + let lines: string[] = []; + try { + const existing = await readFile(file, 'utf8'); + lines = existing.split('\n').filter((line) => line.trim() !== ''); + } catch { + lines = []; // first entry or unreadable file → start fresh + } + + lines.push(entry); + if (lines.length > DOWNTIME_LOG_MAX_ENTRIES) { + lines = lines.slice(lines.length - DOWNTIME_LOG_MAX_ENTRIES); + } + await writeFile(file, lines.join('\n') + '\n', 'utf8'); +} diff --git a/packages/herdr/src/downtime-worker.fixtures.ts b/packages/herdr/src/downtime-worker.fixtures.ts new file mode 100644 index 000000000..682a6cc72 --- /dev/null +++ b/packages/herdr/src/downtime-worker.fixtures.ts @@ -0,0 +1,136 @@ +/** + * packages/herdr/src/downtime-worker.fixtures.ts — mock fixtures for the + * downtime worker tests + * + * Covers every response class for `GET /llama/local/status` required by the + * test contract (WL-0MSG7ZTC000163FL): idle (all slots free), busy (active + * query / model switch / local lease / server down / not all slots free), + * ambiguous (`total_slots` 0 or missing fields), and network error / + * timeout / HTTP-failure responses for the poller tests (F2). + */ + +import type { LlamaStatus } from './downtime-worker.js'; + +// ── Status payloads ─────────────────────────────────────────────────── + +export const idleAllSlotsFree: LlamaStatus = { + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 4, + total_slots: 4, + current_model: 'qwen3-8b', +}; + +export const busyActiveQuery: LlamaStatus = { ...idleAllSlotsFree, active_query: true }; +export const busyModelSwitch: LlamaStatus = { ...idleAllSlotsFree, model_switch_in_progress: true }; +export const busyLocalLease: LlamaStatus = { ...idleAllSlotsFree, local_lease_active: true }; +export const busyServerNotRunning: LlamaStatus = { ...idleAllSlotsFree, llama_server_running: false }; + +/** available_slots < total_slots → busy with the default N=0 (all slots). */ +export const busyNotAllSlotsFree: LlamaStatus = { ...idleAllSlotsFree, available_slots: 2 }; + +/** `total_slots` 0 with everything else idle → ambiguous → busy. */ +export const ambiguousZeroTotalSlots: LlamaStatus = { + ...idleAllSlotsFree, + available_slots: 0, + total_slots: 0, +}; + +/** Raw (unparsed) response missing the numeric slot fields → ambiguous. */ +export const ambiguousMissingFieldsRaw: Record = { + llama_server_running: true, + current_model: 'qwen3-8b', +}; + +export interface LlamaStatusFixture { + name: string; + status: LlamaStatus; + /** Expected classification with requiredFreeSlots = 0 (all slots). */ + expectedIdle: boolean; + note: string; +} + +/** The corpus used by the fixture-coherence test. */ +export const statusFixtures: LlamaStatusFixture[] = [ + { + name: 'idle: all slots free', + status: idleAllSlotsFree, + expectedIdle: true, + note: 'server up, no query/switch/lease, all 4/4 slots free', + }, + { + name: 'busy: active query', + status: busyActiveQuery, + expectedIdle: false, + note: 'active_query true', + }, + { + name: 'busy: model switch in progress', + status: busyModelSwitch, + expectedIdle: false, + note: 'model_switch_in_progress true', + }, + { + name: 'busy: local lease active', + status: busyLocalLease, + expectedIdle: false, + note: 'local_lease_active true', + }, + { + name: 'busy: server not running', + status: busyServerNotRunning, + expectedIdle: false, + note: 'llama_server_running false', + }, + { + name: 'busy: not all slots free', + status: busyNotAllSlotsFree, + expectedIdle: false, + note: 'available 2 < total 4 → busy with N=0 (all slots)', + }, + { + name: 'ambiguous: total_slots 0', + status: ambiguousZeroTotalSlots, + expectedIdle: false, + note: 'total_slots 0 → ambiguous → busy', + }, +]; + +// ── HTTP response / error fixtures (poller tests, F2) ───────────────── + +/** + * Minimal structural Response for the poller contract (the DOM `Response` + * type is not part of this project's tsconfig lib). Node's real `Response` + * satisfies the shape at runtime. + */ +export interface LlamaStatusHttpResponse { + ok: boolean; + status: number; + json(): Promise; +} + +export function jsonResponseFixture(body: unknown, status = 200): LlamaStatusHttpResponse { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +export const httpErrorResponseFixture: LlamaStatusHttpResponse = jsonResponseFixture( + { error: 'boom' }, + 500, +); + +/** fetch() rejecting (connection refused / DNS failure). */ +export const networkErrorFixture: Error = Object.assign(new Error('fetch failed'), { + name: 'TypeError', +}); + +/** fetch() aborting on timeout. */ +export const timeoutErrorFixture: Error = Object.assign( + new Error('The operation was aborted'), + { name: 'AbortError' }, +); diff --git a/packages/herdr/src/downtime-worker.test.ts b/packages/herdr/src/downtime-worker.test.ts new file mode 100644 index 000000000..491d9ae77 --- /dev/null +++ b/packages/herdr/src/downtime-worker.test.ts @@ -0,0 +1,1557 @@ +/** + * Unit tests for downtime-worker.ts — Local-LLM downtime worker contract + * + * Test-first contract for the herdr downtime worker (WL-0MSG7ZTC000163FL, + * parent WL-0MSF49FMW009M06K). Follows the repo convention of a dedicated + * test-suite feature created before the implementation features + * (cf. WL-0MQD1N3JD007B0FZ). + * + * Green suites test the parts implemented so far (idle detection, + * blocked-questions prompt, settings clamps, fixture coherence, the poller + * and fail-closed parsing from WL-0MSG80254005ZNE9, and the idle tracker, + * dispatch orchestration, pane spawn and worker orchestrator from + * WL-0MSG80AG700429M8). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + isIdleStatus, + evaluateIdle, + parseLlamaStatus, + fetchLocalStatus, + createDowntimePoller, + createIdleTracker, + dispatchDowntimeWork, + createDowntimeWorker, + buildDowntimePrompt, + buildDowntimePaneArgs, + spawnDowntimePane, + buildDowntimeSpawnOptions, + BLOCKED_QUESTIONS_INSTRUCTION, + parseNextItemOutput, + parseAuditCandidatesOutput, + selectAuditCandidate, + toDowntimeCandidate, + skillKindFromPrompt, + buildDowntimeDispatchComment, + clampDowntimePollInterval, + clampDowntimeIdleThresholdMs, + clampDowntimeRequiredFreeSlots, + clampDowntimeNoCandidateCooldownMs, + DOWNTIME_POLL_INTERVAL_FLOOR_MS, + DEFAULT_DOWNTIME_POLL_INTERVAL_MS, + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, + DOWNTIME_NO_CANDIDATE_COOLDOWN_FLOOR_MS, + type LlamaStatus, + type LlamaStatusFetcher, + type DowntimeCandidate, + type DowntimeWorkerDeps, + type DowntimeSpawn, + type AuditCandidate, +} from './downtime-worker.js'; +import { + statusFixtures, + ambiguousMissingFieldsRaw, + idleAllSlotsFree, + networkErrorFixture, + timeoutErrorFixture, + httpErrorResponseFixture, + jsonResponseFixture, + type LlamaStatusHttpResponse, +} from './downtime-worker.fixtures.js'; + +/** Shared deps mock for dispatch tests. */ +function makeDeps(overrides: Partial = {}): DowntimeWorkerDeps { + return { + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getNextAuditCandidate: vi.fn().mockResolvedValue(null), + claimItem: vi.fn().mockResolvedValue(undefined), + spawnAgentPane: vi.fn().mockResolvedValue(undefined), + recordDispatch: vi.fn().mockResolvedValue(undefined), + recordError: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +// ── Fixture coherence (AC1) ─────────────────────────────────────────── + +describe('downtime worker fixtures', () => { + it.each(statusFixtures)( + '$name is classified idle=$expectedIdle by isIdleStatus (N=0)', + (fixture) => { + expect(isIdleStatus(fixture.status, 0)).toBe(fixture.expectedIdle); + }, + ); + + it('a raw payload with missing slot fields is treated as busy (fail-closed)', () => { + const partial = ambiguousMissingFieldsRaw as unknown as LlamaStatus; + expect(isIdleStatus(partial, 0)).toBe(false); + }); +}); + +// ── Idle detection (AC2) ────────────────────────────────────────────── + +describe('idle detection (isIdleStatus)', () => { + const idle: LlamaStatus = { + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 4, + total_slots: 4, + }; + + it('is idle when the server is up, nothing active, and all slots free', () => { + expect(isIdleStatus(idle, 0)).toBe(true); + }); + + it('is busy when llama-server is not running', () => { + expect(isIdleStatus({ ...idle, llama_server_running: false }, 0)).toBe(false); + }); + + it('is busy while a query is active', () => { + expect(isIdleStatus({ ...idle, active_query: true }, 0)).toBe(false); + }); + + it('is busy while a model switch is in progress', () => { + expect(isIdleStatus({ ...idle, model_switch_in_progress: true }, 0)).toBe(false); + }); + + it('is busy while a local lease is active', () => { + expect(isIdleStatus({ ...idle, local_lease_active: true }, 0)).toBe(false); + }); + + it('N=0 (default) requires ALL slots free', () => { + expect(isIdleStatus({ ...idle, available_slots: 3 }, 0)).toBe(false); + expect(isIdleStatus({ ...idle, available_slots: 4 }, 0)).toBe(true); + }); + + it('a positive N requires at least N slots free', () => { + expect(isIdleStatus({ ...idle, available_slots: 2 }, 2)).toBe(true); + expect(isIdleStatus({ ...idle, available_slots: 1 }, 2)).toBe(false); + }); + + it('N > total_slots can never be idle (never dispatches)', () => { + expect(isIdleStatus({ ...idle, available_slots: 4 }, 5)).toBe(false); + }); + + it('ambiguous responses (total_slots 0 or missing fields) are busy', () => { + expect(isIdleStatus({ ...idle, total_slots: 0, available_slots: 0 }, 0)).toBe(false); + expect(isIdleStatus({ llama_server_running: true } as LlamaStatus, 0)).toBe(false); + }); + + it('non-finite or negative slot counts are busy', () => { + expect(isIdleStatus({ ...idle, total_slots: Number.NaN }, 0)).toBe(false); + expect(isIdleStatus({ ...idle, available_slots: -1 }, 0)).toBe(false); + }); +}); + +// ── Threshold timing (AC1/AC3) ──────────────────────────────────────── + +describe('threshold timing (idle-duration tracker)', () => { + const thresholdMs = 240_000; + + it('dispatches only after idle has lasted the full threshold continuously', () => { + const tracker = createIdleTracker(); + const start = 1_000_000; + + tracker.record(true, start); + expect(tracker.idleSince).toBe(start); + expect(tracker.isThresholdMet(thresholdMs, start + thresholdMs - 1)).toBe(false); + expect(tracker.isThresholdMet(thresholdMs, start + thresholdMs)).toBe(true); + }); + + it('any busy poll resets the idle-since timestamp', () => { + const tracker = createIdleTracker(); + const start = 1_000_000; + + tracker.record(true, start); + tracker.record(false, start + 120_000); + expect(tracker.idleSince).toBeNull(); + expect(tracker.isThresholdMet(thresholdMs, start + 360_000)).toBe(false); + }); + + it('keeps the idle run start fixed across consecutive idle polls', () => { + const tracker = createIdleTracker(); + const start = 1_000_000; + tracker.record(true, start); + tracker.record(true, start + 30_000); + tracker.record(true, start + 60_000); + expect(tracker.idleSince).toBe(start); + expect(tracker.isThresholdMet(thresholdMs, start + 60_000)).toBe(false); + expect(tracker.isThresholdMet(thresholdMs, start + thresholdMs)).toBe(true); + }); +}); + +// ── Dispatch selection (AC2) ────────────────────────────────────────── + +describe('dispatch selection', () => { + it('runs /skill:plan on the next intake_complete item', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getNextItem).toHaveBeenCalledWith('intake_complete'); + expect(deps.getNextItem).toHaveBeenCalledTimes(1); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(outcome.candidate?.id).toBe('WL-ABC'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-ABC'), + { model: 'plan', cwd: '/repo' }, + ); + }); + + it('claims the item BEFORE the pane spawns', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.claimItem).toHaveBeenCalledWith('WL-ABC'); + const claimOrder = (deps.claimItem as ReturnType).mock.invocationCallOrder[0]; + const spawnOrder = (deps.spawnAgentPane as ReturnType).mock.invocationCallOrder[0]; + expect(claimOrder).toBeLessThan(spawnOrder); + }); + + it('falls back to /skill:intake on the next idea item when none is intake_complete', async () => { + const deps = makeDeps({ + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: null }) + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-DEF', title: 'An idea', stage: 'idea' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea'); + expect(outcome.kind).toBe('intake'); + expect(outcome.candidate?.id).toBe('WL-DEF'); + expect(deps.claimItem).toHaveBeenCalledWith('WL-DEF'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:intake WL-DEF'), + expect.anything(), + ); + }); + + it('does not dispatch when no item exists in either stage', async () => { + const deps = makeDeps(); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea'); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + it('fails closed to wl-error when the intake_complete lookup errors AND the idea lookup errors', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ ok: false }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Tier 2 error does NOT short-circuit: tier 3 is still attempted. + expect(deps.getNextItem).toHaveBeenCalledTimes(2); + expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea'); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('wl-error'); + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('still checks the idea tier after an intake_complete error and dispatches its candidate', async () => { + const deps = makeDeps({ + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: false }) + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-IDE', title: 'An idea', stage: 'idea' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('intake'); + expect(outcome.candidate?.id).toBe('WL-IDE'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea'); + expect(deps.claimItem).toHaveBeenCalledWith('WL-IDE'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:intake WL-IDE'), + expect.anything(), + ); + }); + + it('fails closed to wl-error when the intake_complete lookup errors and idea answers empty', async () => { + const deps = makeDeps({ + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: false }) + .mockResolvedValueOnce({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + // Backlog is not provably empty (intake_complete state unknown) → the + // worker must NOT treat this as a genuine no-candidate (no cooldown). + expect(outcome.reason).toBe('wl-error'); + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('fails closed to wl-error when the idea lookup errors after an empty intake_complete stage', async () => { + const deps = makeDeps({ + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: null }) + .mockResolvedValueOnce({ ok: false }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea'); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('wl-error'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); +}); + +// ── Audit-tier dispatch (WL-0MSI8H3HP000K0RG) ───────────────────────── + +describe('dispatch audit tier', () => { + const staleCandidate: DowntimeCandidate = { + id: 'WL-AUD', + title: 'Audit me', + stage: 'audit', + }; + + it('dispatches /skill:audit on the audit candidate as the first tier', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue(staleCandidate), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('audit'); + expect(outcome.candidate?.id).toBe('WL-AUD'); + expect(deps.getNextItem).not.toHaveBeenCalled(); + expect(deps.claimItem).toHaveBeenCalledWith('WL-AUD'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:audit WL-AUD'), + { model: 'plan', cwd: '/repo' }, + ); + }); + + it('records the audit dispatch with kind audit', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue(staleCandidate), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; + expect(event.itemId).toBe('WL-AUD'); + expect(event.kind).toBe('audit'); + expect(event.cwd).toBe('/repo'); + }); + + it('when no audit candidate, falls back to audit → plan → intake in order', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue(null), + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: null }) // intake_complete empty + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-IDE', title: 'An idea', stage: 'idea' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('intake'); + expect(outcome.candidate?.id).toBe('WL-IDE'); + expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea'); + }); + + it('fails closed to no-candidate when getNextAuditCandidate returns null and no plan/intake candidate exists', async () => { + // getNextAuditCandidate is fail-closed at the deps boundary: a wl failure + // yields null (no candidate), and the dispatcher falls through to the + // plan/intake tiers. All empty -> no dispatch. + const deps = makeDeps(); // all three tiers empty + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); +}); + +describe('audit selection (selectAuditCandidate)', () => { + // Fixed clock so the 7-day recency filter is deterministic (all fixture + // dates fall within the window). + const NOW = new Date('2026-01-01T00:05:00.000Z').getTime(); + const fresh: AuditCandidate = { + id: 'FRESH', + title: 'fresh', + auditedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:30.000Z', + sortIndex: 200, + }; + const stale: AuditCandidate = { + id: 'STALE', + title: 'stale', + auditedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:02:00.000Z', // >60s later -> stale + sortIndex: 300, + }; + const unaudited: AuditCandidate = { id: 'NOAUDIT', title: 'no audit', sortIndex: 100 }; + + it('selects an item with a missing audit', () => { + expect(selectAuditCandidate([fresh, unaudited], NOW)?.id).toBe('NOAUDIT'); + }); + + it('does not select an item with a fresh audit', () => { + expect(selectAuditCandidate([fresh], NOW)).toBeNull(); + }); + + it('selects a stale item (audit older than the 60s buffer)', () => { + expect(selectAuditCandidate([fresh, stale], NOW)).toEqual(stale); + }); + + it('sorts ascending by sortIndex and returns the first', () => { + expect(selectAuditCandidate([stale, unaudited], NOW)?.id).toBe('NOAUDIT'); + expect(selectAuditCandidate([{ ...unaudited, sortIndex: 500 }, stale], NOW)?.id).toBe('STALE'); + }); + + it('returns null on an empty list', () => { + expect(selectAuditCandidate([], NOW)).toBeNull(); + }); + + it('classifies the 60s freshness boundary correctly', () => { + // auditedAt exactly 60s before updatedAt -> stale (selected) + const boundaryStale: AuditCandidate = { + id: 'B1', + title: 'Boundary stale', + auditedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:01:00.000Z', + }; + // auditedAt just under 60s before updatedAt -> fresh (not selected) + const boundaryFresh: AuditCandidate = { + id: 'B2', + title: 'Boundary fresh', + auditedAt: '2026-01-01T00:00:30.000Z', + updatedAt: '2026-01-01T00:01:00.000Z', + }; + expect(selectAuditCandidate([boundaryStale], NOW)?.id).toBe('B1'); + expect(selectAuditCandidate([boundaryFresh], NOW)).toBeNull(); + }); +}); + +describe('audit selection 7-day recency (selectAuditCandidate)', () => { + const DAY_MS = 24 * 60 * 60 * 1000; + const NOW = new Date('2026-01-01T12:00:00.000Z').getTime(); + const recentStale: AuditCandidate = { + id: 'RECENT', + title: 'modified 6 days ago', + // audited >60s before updatedAt -> stale audit (selected by freshness) + auditedAt: new Date(NOW - 6 * DAY_MS - 120_000).toISOString(), + updatedAt: new Date(NOW - 6 * DAY_MS).toISOString(), + sortIndex: 100, + }; + const ancientStale: AuditCandidate = { + id: 'ANCIENT', + title: 'modified 8 days ago', + auditedAt: new Date(NOW - 8 * DAY_MS - 120_000).toISOString(), + updatedAt: new Date(NOW - 8 * DAY_MS).toISOString(), + sortIndex: 100, + }; + + it('selects a stale-audit item modified within the last 7 days', () => { + expect(selectAuditCandidate([ancientStale, recentStale], NOW)?.id).toBe('RECENT'); + }); + + it('excludes stale-audit items not modified within the last 7 days', () => { + expect(selectAuditCandidate([ancientStale], NOW)).toBeNull(); + }); + + it('includes a candidate with a missing updatedAt (recency cannot be verified)', () => { + const missing: AuditCandidate = { id: 'MISSING', title: 'no updatedAt', sortIndex: 100 }; + expect(selectAuditCandidate([missing], NOW)?.id).toBe('MISSING'); + }); + + it('excludes a candidate with an unparseable updatedAt (fail-closed)', () => { + const garbage: AuditCandidate = { + id: 'GARBAGE', + title: 'bad date', + updatedAt: 'not-a-date', + sortIndex: 100, + }; + expect(selectAuditCandidate([garbage], NOW)).toBeNull(); + }); + + it('includes a candidate modified exactly 7 days ago (boundary inclusive)', () => { + const boundary: AuditCandidate = { + id: 'EDGE', + title: 'exactly 7 days', + auditedAt: new Date(NOW - 7 * DAY_MS - 120_000).toISOString(), + updatedAt: new Date(NOW - 7 * DAY_MS).toISOString(), + sortIndex: 100, + }; + expect(selectAuditCandidate([boundary], NOW)?.id).toBe('EDGE'); + }); + + it('excludes a candidate modified 7 days and 1ms ago', () => { + const justOutside: AuditCandidate = { + id: 'OUT', + title: '7 days + 1ms', + auditedAt: new Date(NOW - 7 * DAY_MS - 120_001).toISOString(), + updatedAt: new Date(NOW - 7 * DAY_MS - 1).toISOString(), + sortIndex: 100, + }; + expect(selectAuditCandidate([justOutside], NOW)).toBeNull(); + }); +}); + +describe('parseAuditCandidatesOutput', () => { + it('parses a { workItems: [...] } shape', () => { + const stdout = JSON.stringify({ + success: true, + count: 1, + workItems: [{ id: 'WL-1', title: 'A', sortIndex: 100, auditedAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:30.000Z' }], + }); + const parsed = parseAuditCandidatesOutput(stdout); + expect(parsed).not.toBeNull(); + expect(parsed![0].id).toBe('WL-1'); + expect(parsed![0].sortIndex).toBe(100); + }); + + it('parses a bare array shape', () => { + const stdout = JSON.stringify([{ id: 'WL-2', title: 'B' }]); + expect(parseAuditCandidatesOutput(stdout)?.[0].id).toBe('WL-2'); + }); + + it('fails closed (null) on malformed JSON', () => { + expect(parseAuditCandidatesOutput('not json')).toBeNull(); + }); + + it('fails closed (null) on output without a list', () => { + expect(parseAuditCandidatesOutput(JSON.stringify({ success: false }))).toBeNull(); + }); + + it('skips items without an id but keeps valid ones', () => { + const stdout = JSON.stringify({ workItems: [{ title: 'no id' }, { id: 'WL-3', title: 'C' }] }); + const parsed = parseAuditCandidatesOutput(stdout); + expect(parsed).not.toBeNull(); + expect(parsed!.length).toBe(1); + expect(parsed![0].id).toBe('WL-3'); + }); + + it('returns an empty array for an empty workItems list', () => { + expect(parseAuditCandidatesOutput(JSON.stringify({ workItems: [] }))).toEqual([]); + }); +}); + +describe('audit prompt & pane helpers', () => { + const candidate: DowntimeCandidate = { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }; + + it('builds an /skill:audit prompt', () => { + const prompt = buildDowntimePrompt('audit', candidate); + expect(prompt).toContain('/skill:audit WL-AUD'); + }); + + it('builds a Downtime audit pane name', () => { + const args = buildDowntimePaneArgs('audit', 'Run /skill:audit WL-AUD — Audit me.', { + model: 'plan', + cwd: '/repo', + }); + expect(args).toContain('Downtime audit'); + expect(args).toContain('--no-focus'); + }); + + it('skillKindFromPrompt detects an audit prompt', () => { + expect(skillKindFromPrompt('Run /skill:audit WL-AUD — x.')).toBe('audit'); + expect(skillKindFromPrompt('Run /skill:plan WL-1 — x.')).toBe('plan'); + expect(skillKindFromPrompt('Run /skill:intake WL-2 — x.')).toBe('intake'); + }); + + it('buildDowntimeDispatchComment renders /skill:audit', () => { + const comment = buildDowntimeDispatchComment('WL-AUD', 'audit', '2026-01-01T00:00:00.000Z'); + expect(comment).toContain('/skill:audit WL-AUD'); + }); + + it('toDowntimeCandidate produces a stage-audit candidate', () => { + expect(toDowntimeCandidate({ id: 'X', title: 'T' })).toEqual({ + id: 'X', + title: 'T', + stage: 'audit', + }); + }); +}); + +// ── Dispatch audit trail (WL-0MSGPI4AR000YOK8) ──────────────────────── + +describe('dispatch audit trail', () => { + it('records a plan dispatch with item id, kind, timestamp and cwd', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(deps.recordDispatch).toHaveBeenCalledTimes(1); + const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; + expect(event.itemId).toBe('WL-ABC'); + expect(event.kind).toBe('plan'); + expect(event.cwd).toBe('/repo'); + expect(Number.isNaN(Date.parse(event.dispatchedAt))).toBe(false); + }); + + it('records an intake fallback dispatch', async () => { + const deps = makeDeps({ + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: null }) + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-DEF', title: 'An idea', stage: 'idea' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('intake'); + const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; + expect(event.itemId).toBe('WL-DEF'); + expect(event.kind).toBe('intake'); + }); + + it('does not record when nothing is dispatched', async () => { + const deps = makeDeps(); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.recordDispatch).not.toHaveBeenCalled(); + }); + + it('keeps the dispatch successful when recordDispatch fails (audit never blocks work)', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + recordDispatch: vi.fn().mockRejectedValue(new Error('audit boom')), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + }); +}); + +describe('buildDowntimeDispatchComment', () => { + it('states the skill, item id, and ISO timestamp', () => { + const comment = buildDowntimeDispatchComment('WL-ABC', 'plan', '2026-01-01T00:00:00.000Z'); + expect(comment).toContain('/skill:plan WL-ABC'); + expect(comment).toContain('2026-01-01T00:00:00.000Z'); + expect(comment).toContain('herdr downtime worker'); + }); + + it('sanitizes newlines out of the embedded title', () => { + const comment = buildDowntimeDispatchComment('WL-ABC', 'intake', '2026-01-01T00:00:00.000Z', 'multi\nline\rtitle'); + expect(comment).toContain('multi line title'); + expect(comment).not.toMatch(/[\r\n]/); + }); +}); + +// ── Single-flight (AC5) ─────────────────────────────────────────────── + +describe('single-flight dispatch guard', () => { + it('does not dispatch a second time while one dispatch is in flight', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + spawnAgentPane: vi.fn().mockImplementation(() => gate), + }); + + const first = dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + const second = dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + release(); + const [firstOutcome, secondOutcome] = await Promise.all([first, second]); + + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + expect(firstOutcome.dispatched).toBe(true); + expect(secondOutcome.dispatched).toBe(false); + expect(secondOutcome.reason).toBe('dispatch-in-flight'); + }); + + it('allows a new dispatch once the previous one has completed', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + }); + + const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(first.dispatched).toBe(true); + expect(second.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(2); + }); +}); + +// ── Endpoint failures & poller (AC6) — implemented in F2 ────────────── + +describe('endpoint failures and poller', () => { + it('network errors are treated as busy and never throw', async () => { + const fetcher = vi.fn().mockRejectedValue(networkErrorFixture); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await expect(poller.poll()).resolves.toBeNull(); + }); + + it('timeouts are treated as busy and never throw', async () => { + const fetcher = vi.fn().mockRejectedValue(timeoutErrorFixture); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await expect(poller.poll()).resolves.toBeNull(); + }); + + it('HTTP error statuses are treated as busy', async () => { + const fetcher = vi.fn().mockResolvedValue(httpErrorResponseFixture); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await expect(poller.poll()).resolves.toBeNull(); + }); + + it('polls GET {proxyUrl}/llama/local/status', async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await poller.poll(); + expect(fetcher).toHaveBeenCalledWith('http://proxy:8000/llama/local/status', expect.anything()); + }); + + it('normalises a trailing slash on the proxy URL', async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); + const poller = createDowntimePoller('http://proxy:8000/', fetcher); + await poller.poll(); + expect(fetcher).toHaveBeenCalledWith('http://proxy:8000/llama/local/status', expect.anything()); + }); + + it('returns the parsed status on a successful poll', async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await expect(poller.poll()).resolves.toEqual(idleAllSlotsFree); + }); + + it('treats invalid JSON as busy', async () => { + const fetcher = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError('Unexpected token'); + }, + }); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await expect(poller.poll()).resolves.toBeNull(); + }); + + it('treats payloads with missing required fields as busy', async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(ambiguousMissingFieldsRaw)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await expect(poller.poll()).resolves.toBeNull(); + }); + + it('derives local_lease_active from the lease fields when the boolean is absent', async () => { + const status = parseLlamaStatus({ + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + available_slots: 4, + total_slots: 4, + local_owner_session_id: 'session-1', + local_owner_lease_remaining_seconds: 120, + }); + expect(status).not.toBeNull(); + expect(status!.local_lease_active).toBe(true); + expect(evaluateIdle(status!, 0)).toBe(false); + }); + + it('coalesces overlapping polls (single-flight, no overlapping fetches)', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = () => resolve(jsonResponseFixture(idleAllSlotsFree)); + }); + const fetcher = vi.fn().mockImplementation(() => gate); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + + const first = poller.poll(); + expect(poller.isPolling()).toBe(true); + const second = poller.poll(); + release(); + + const [firstResult, secondResult] = await Promise.all([first, second]); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(firstResult).toEqual(idleAllSlotsFree); + expect(secondResult).toBe(firstResult); // same coalesced result + expect(poller.isPolling()).toBe(false); + }); + + it('aborts the request after the per-poll timeout (fail closed)', async () => { + vi.useFakeTimers(); + let receivedSignal: unknown; + const fetcher = vi.fn((_url: string, init?: { signal?: unknown }) => { + receivedSignal = init?.signal; + return new Promise((_resolve, reject) => { + const signal = init?.signal as AbortSignal | undefined; + signal?.addEventListener('abort', () => reject(new Error('aborted'))); + }); + }); + + const poller = createDowntimePoller('http://proxy:8000', fetcher, 5_000); + const pollPromise = poller.poll(); + expect(receivedSignal).toBeDefined(); + await vi.advanceTimersByTimeAsync(5_000); + await expect(pollPromise).resolves.toBeNull(); + expect(poller.isPolling()).toBe(false); + vi.useRealTimers(); + }); +}); + +// ── Runtime idle evaluation (F2, AC5) ───────────────────────────────── + +describe('runtime idle evaluation (evaluateIdle)', () => { + const idle: LlamaStatus = { + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 4, + total_slots: 4, + }; + + it('N=0 (default) requires all slots free', () => { + expect(evaluateIdle(idle, 0)).toBe(true); + expect(evaluateIdle({ ...idle, available_slots: 3 }, 0)).toBe(false); + }); + + it('degrades 0 < N < total slots to ALL slots free (fail-closed, no per-slot data)', () => { + // Without per-slot identity (LP-0MSG5TA7Y002GN39), N=2 with 4 slots + // must NOT dispatch on "any 2 free" — it requires all 4 free. + expect(evaluateIdle({ ...idle, available_slots: 2 }, 2)).toBe(false); + expect(evaluateIdle({ ...idle, available_slots: 4 }, 2)).toBe(true); + }); + + it('N == total slots behaves like all slots free', () => { + expect(evaluateIdle({ ...idle, available_slots: 4 }, 4)).toBe(true); + expect(evaluateIdle({ ...idle, available_slots: 3 }, 4)).toBe(false); + }); + + it('N > total slots can never be idle (never dispatches)', () => { + expect(evaluateIdle({ ...idle, available_slots: 4 }, 5)).toBe(false); + }); + + it('ambiguous responses (total_slots 0) are busy', () => { + expect(evaluateIdle({ ...idle, total_slots: 0, available_slots: 0 }, 0)).toBe(false); + }); +}); + +// ── Blocked-questions prompt (AC7) ──────────────────────────────────── + +describe('blocked-questions prompt instruction', () => { + const candidate: DowntimeCandidate = { + id: 'WL-ABC', + title: 'Some task', + stage: 'intake_complete', + }; + + it('the plan prompt runs /skill:plan on the item id', () => { + const prompt = buildDowntimePrompt('plan', candidate); + expect(prompt).toContain('/skill:plan WL-ABC'); + }); + + it('the intake prompt runs /skill:intake on the item id', () => { + const prompt = buildDowntimePrompt('intake', { ...candidate, stage: 'idea' }); + expect(prompt).toContain('/skill:intake WL-ABC'); + }); + + it('instructs recording questions in a comment, flagging needs-producer-review, and stopping', () => { + const prompt = buildDowntimePrompt('plan', candidate); + expect(prompt).toContain('comment'); + expect(prompt).toContain('--needs-producer-review true'); + expect(prompt).toContain('stop'); + expect(BLOCKED_QUESTIONS_INSTRUCTION).toContain('wl comment add'); + expect(BLOCKED_QUESTIONS_INSTRUCTION).toContain('wl update --needs-producer-review true'); + }); +}); + +// ── Settings clamps (AC8) ───────────────────────────────────────────── + +describe('downtime settings clamps', () => { + it('clampDowntimePollInterval enforces the 10s floor and keeps valid values', () => { + expect(clampDowntimePollInterval(5_000)).toBe(DOWNTIME_POLL_INTERVAL_FLOOR_MS); + expect(clampDowntimePollInterval(10_000)).toBe(10_000); + expect(clampDowntimePollInterval(45_000)).toBe(45_000); + }); + + it('clampDowntimePollInterval falls back to the 30s default for non-finite values', () => { + expect(clampDowntimePollInterval(Number.NaN)).toBe(DEFAULT_DOWNTIME_POLL_INTERVAL_MS); + expect(clampDowntimePollInterval(Infinity)).toBe(DEFAULT_DOWNTIME_POLL_INTERVAL_MS); + }); + + it('clampDowntimeIdleThresholdMs rejects negative and non-finite values', () => { + expect(clampDowntimeIdleThresholdMs(-1)).toBe(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + expect(clampDowntimeIdleThresholdMs(Number.NaN)).toBe(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + }); + + it('clampDowntimeIdleThresholdMs keeps valid values and floors at 1s', () => { + expect(clampDowntimeIdleThresholdMs(240_000)).toBe(240_000); + expect(clampDowntimeIdleThresholdMs(0)).toBe(1_000); + }); + + it('clampDowntimeRequiredFreeSlots maps negative/non-finite to 0 (all slots) and rounds', () => { + expect(clampDowntimeRequiredFreeSlots(-3)).toBe(0); + expect(clampDowntimeRequiredFreeSlots(Number.NaN)).toBe(0); + expect(clampDowntimeRequiredFreeSlots(2.7)).toBe(3); + expect(clampDowntimeRequiredFreeSlots(2)).toBe(2); + }); + + it('clampDowntimeNoCandidateCooldownMs keeps valid values and floors at 60s', () => { + expect(clampDowntimeNoCandidateCooldownMs(3_600_000)).toBe(3_600_000); + expect(clampDowntimeNoCandidateCooldownMs(1_000)).toBe(DOWNTIME_NO_CANDIDATE_COOLDOWN_FLOOR_MS); + expect(clampDowntimeNoCandidateCooldownMs(60_000)).toBe(60_000); + }); + + it('clampDowntimeNoCandidateCooldownMs rejects negative and non-finite values', () => { + expect(clampDowntimeNoCandidateCooldownMs(-1)).toBe(DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS); + expect(clampDowntimeNoCandidateCooldownMs(Number.NaN)).toBe(DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS); + expect(clampDowntimeNoCandidateCooldownMs(Infinity)).toBe(DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS); + }); +}); + +// ── Wiring helpers (F4) ─────────────────────────────────────────────── + +describe('parseNextItemOutput', () => { + it('parses the workItem from `wl next --stage --json`', () => { + const stdout = JSON.stringify({ success: true, workItem: { id: 'WL-ABC', title: 'Some task' } }); + expect(parseNextItemOutput(stdout, 'intake_complete')).toEqual({ + id: 'WL-ABC', + title: 'Some task', + stage: 'intake_complete', + }); + }); + + it('returns null when wl next reports no item (workItem null)', () => { + const stdout = JSON.stringify({ success: false, workItem: null, reason: 'no items' }); + expect(parseNextItemOutput(stdout, 'idea')).toBeNull(); + }); + + it('returns null for malformed JSON', () => { + expect(parseNextItemOutput('not json', 'idea')).toBeNull(); + }); + + it('returns null when the workItem lacks an id', () => { + const stdout = JSON.stringify({ success: true, workItem: { title: 'no id' } }); + expect(parseNextItemOutput(stdout, 'idea')).toBeNull(); + }); +}); + +describe('skillKindFromPrompt', () => { + it('derives plan from a /skill:plan prompt and intake otherwise', () => { + expect(skillKindFromPrompt('Run /skill:plan WL-ABC — Task.')).toBe('plan'); + expect(skillKindFromPrompt('Run /skill:intake WL-DEF — Idea.')).toBe('intake'); + }); +}); + +describe('downtime worker enabled state', () => { + it('exposes the enabled flag from the per-tick config', async () => { + const cfg = { enabled: true }; + const worker = createDowntimeWorker({ + poller: createDowntimePoller('http://proxy:8000'), + deps: makeDeps(), + config: () => ({ + enabled: cfg.enabled, + thresholdMs: 240_000, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + }), + }); + expect(worker.enabled).toBe(true); + cfg.enabled = false; + expect(worker.enabled).toBe(false); + }); +}); + +// ── Pane spawn (AC4) ────────────────────────────────────────────────── + +describe('downtime pane spawn (send-to-pi.sh)', () => { + it('builds --pane-name / --no-focus / --cwd / --model args for a plan dispatch', () => { + const args = buildDowntimePaneArgs('plan', 'Run /skill:plan WL-ABC — Some task.', { + model: 'plan', + cwd: '/repo', + }); + expect(args).toEqual([ + '--pane-name', + 'Downtime plan', + '--no-focus', + '--cwd', + '/repo', + '--model', + 'plan', + 'Run /skill:plan WL-ABC — Some task.', + ]); + }); + + it('uses the intake pane name and forwards the configured model', () => { + const args = buildDowntimePaneArgs('intake', 'Run /skill:intake WL-DEF', { + model: 'code', + cwd: '/repo', + }); + expect(args).toContain('Downtime intake'); + expect(args).toContain('code'); + expect(args).not.toContain('Downtime plan'); + }); + + it('spawns via the injectable spawn and unrefs the child', () => { + const spawnFn = vi.fn(() => ({ unref: vi.fn() })) as unknown as DowntimeSpawn; + + spawnDowntimePane('/path/to/send-to-pi.sh', ['--no-focus', 'prompt'], { cwd: '/repo' }, spawnFn); + + expect(spawnFn).toHaveBeenCalledWith('/path/to/send-to-pi.sh', ['--no-focus', 'prompt'], { + cwd: '/repo', + }); + const handle = (spawnFn as ReturnType).mock.results[0].value; + expect(handle.unref).toHaveBeenCalled(); + }); + + it('buildDowntimeSpawnOptions uses detached/ignore options with the resolved cwd', () => { + const options = buildDowntimeSpawnOptions('/repo'); + expect(options).toEqual({ + detached: true, + stdio: 'ignore', + cwd: '/repo', + env: expect.objectContaining({ HERDR_RESOLVED_CWD: '/repo' }), + }); + }); +}); + +// ── Worker orchestrator (AC1/AC5) ───────────────────────────────────── + +describe('downtime worker orchestrator (createDowntimeWorker)', () => { + function makeWorker(overrides: { + enabled?: boolean; + thresholdMs?: number; + cooldownMs?: number; + status?: unknown; + deps?: Partial; + } = {}) { + const cfg = { + enabled: overrides.enabled ?? true, + thresholdMs: overrides.thresholdMs ?? 240_000, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: overrides.cooldownMs ?? 3_600_000, + }; + const fetcher = vi + .fn() + .mockResolvedValue(jsonResponseFixture(overrides.status ?? idleAllSlotsFree)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + ...overrides.deps, + }); + const worker = createDowntimeWorker({ + poller, + deps, + config: () => ({ ...cfg }), + }); + return { worker, deps, cfg, fetcher }; + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does nothing (no poll, no dispatch) when disabled', async () => { + const { worker, deps, fetcher } = makeWorker({ enabled: false }); + const result = await worker.tick(); + expect(result).toEqual({ polled: false, dispatched: false, idle: false }); + expect(fetcher).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('treats a busy status as busy and never dispatches', async () => { + const { worker, deps } = makeWorker({ status: { ...idleAllSlotsFree, active_query: true } }); + const result = await worker.tick(); + expect(result.idle).toBe(false); + expect(worker.idleSince).toBeNull(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('does not dispatch until the idle duration reaches the threshold (AC1)', async () => { + const { worker, deps, cfg } = makeWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + expect(worker.idleSince).toBe(start); + + vi.setSystemTime(start + cfg.thresholdMs - 1); + const before = await worker.tick(); + expect(before.dispatched).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + expect(worker.lastDispatchAt).toBe(start + cfg.thresholdMs); + }); + + it('requires a fresh full idle period after a dispatch (AC5)', async () => { + const { worker, deps, cfg } = makeWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + cfg.thresholdMs); + await worker.tick(); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + + // Still idle at the next tick: the tracker was reset after dispatch, so + // no second dispatch until another full idle period has elapsed. + vi.setSystemTime(start + cfg.thresholdMs + 30_000); + const next = await worker.tick(); + expect(next.dispatched).toBe(false); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + expect(worker.idleSince).toBe(start + cfg.thresholdMs + 30_000); + + vi.setSystemTime(start + cfg.thresholdMs + 30_000 + cfg.thresholdMs); + const after = await worker.tick(); + expect(after.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(2); + }); + + it('a busy poll (proxy busy after dispatch) resets the idle run', async () => { + const { worker, deps, cfg, fetcher } = makeWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + cfg.thresholdMs); + await worker.tick(); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + + fetcher.mockResolvedValueOnce(jsonResponseFixture({ ...idleAllSlotsFree, active_query: true })); + vi.setSystemTime(start + cfg.thresholdMs + 30_000); + const busy = await worker.tick(); + expect(busy.idle).toBe(false); + expect(worker.idleSince).toBeNull(); + + // Back to idle: a full new run is required before the next dispatch. + vi.setSystemTime(start + cfg.thresholdMs + 60_000); + const reIdle = await worker.tick(); + expect(reIdle.idle).toBe(true); + expect(reIdle.dispatched).toBe(false); + vi.setSystemTime(start + cfg.thresholdMs + 60_000 + cfg.thresholdMs); + const reDispatch = await worker.tick(); + expect(reDispatch.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(2); + }); + + it('re-reads settings each tick so changes apply without a restart', async () => { + const { worker, deps, cfg } = makeWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + + cfg.thresholdMs = 60_000; // operator changes the setting live + vi.setSystemTime(start + 60_000); + const result = await worker.tick(); + expect(result.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('does not start a second dispatch while one is in flight (worker single-flight)', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { worker, deps } = makeWorker({ + deps: { spawnAgentPane: vi.fn().mockImplementation(() => gate) }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 240_000); + + const first = worker.tick(); + const second = await worker.tick(); + expect(second.dispatched).toBe(false); + + release(); + const firstResult = await first; + expect(firstResult.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); +}); + +// ── No-candidate cooldown (WL-0MSI7DQL10016QYX) ────────────────────── + +describe('downtime no-candidate cooldown (createDowntimeWorker)', () => { + /** Empty backlog in BOTH stages → the worker should pause. */ + function makeEmptyBacklogWorker(overrides: { + cooldownMs?: number; + deps?: Partial; + } = {}) { + const cfg = { + enabled: true, + thresholdMs: 240_000, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: overrides.cooldownMs ?? 3_600_000, + }; + const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + ...overrides.deps, + }); + const worker = createDowntimeWorker({ + poller, + deps, + config: () => ({ ...cfg }), + }); + return { worker, deps, cfg, fetcher }; + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('enters the cooldown only after a genuine no-candidate outcome', async () => { + const { worker, deps } = makeEmptyBacklogWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // idle run starts + vi.setSystemTime(start + 240_000); // threshold met → dispatch attempt + + const result = await worker.tick(); + + expect(result.dispatched).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(worker.paused).toBe(true); + }); + + it('performs no proxy polling, no idle tracking and no dispatch while paused', async () => { + const { worker, deps, fetcher } = makeEmptyBacklogWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 240_000); + await worker.tick(); // enters cooldown + expect(worker.paused).toBe(true); + + const pollsBefore = fetcher.mock.calls.length; + const idleBefore = worker.idleSince; + vi.setSystemTime(start + 240_000 + 60_000); // still within the pause + const result = await worker.tick(); + + expect(result).toEqual({ polled: false, dispatched: false, idle: false }); + expect(fetcher.mock.calls.length).toBe(pollsBefore); // no poll + expect(worker.idleSince).toBe(idleBefore); // no idle tracking + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(worker.paused).toBe(true); + }); + + it('resumes polling once the cooldown expires and requires a fresh full idle period', async () => { + let backlogEmpty = true; + const { worker, deps } = makeEmptyBacklogWorker({ + deps: { + getNextItem: vi.fn().mockImplementation(() => + Promise.resolve( + backlogEmpty + ? { ok: true, candidate: null } + : { ok: true, candidate: { id: 'WL-NEW', title: 'New item', stage: 'intake_complete' } }, + ), + ), + }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 240_000); + await worker.tick(); // empty backlog → cooldown + expect(worker.paused).toBe(true); + + // The project's backlog fills back up while the worker is paused. + backlogEmpty = false; + vi.setSystemTime(start + 240_000 + 3_600_000); // pause expires + const resumed = await worker.tick(); + expect(resumed.polled).toBe(true); + expect(resumed.dispatched).toBe(false); // fresh idle run — no stale credit + expect(worker.paused).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + + // A full new idle period must elapse before the next dispatch. + vi.setSystemTime(start + 240_000 + 3_600_000 + 240_000); + const afterFreshIdle = await worker.tick(); + expect(afterFreshIdle.dispatched).toBe(true); // candidate now available + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('does not enter the cooldown on a transient wl error (fail closed to busy)', async () => { + const { worker, deps } = makeEmptyBacklogWorker({ + deps: { getNextItem: vi.fn().mockResolvedValue({ ok: false }) }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 240_000); + + const result = await worker.tick(); + + expect(result.dispatched).toBe(false); + expect(worker.paused).toBe(false); // a single error is NOT an empty backlog + expect(worker.errorStrikes).toBe(1); // ...but it IS the first strike + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(deps.recordError).not.toHaveBeenCalled(); + }); + + it('does not enter the cooldown when the dispatch guard reports in-flight', async () => { + // Hold the module-level dispatch single-flight guard with an outer + // in-flight dispatch; the worker's own dispatch call then returns + // `dispatch-in-flight`, which must NOT trigger the cooldown. + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const outerDeps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-X', title: 'Outer', stage: 'intake_complete' }, + }), + spawnAgentPane: vi.fn().mockImplementation(() => gate), + }); + const outer = dispatchDowntimeWork(outerDeps, { model: 'plan', cwd: '/repo' }); + + const { worker } = makeEmptyBacklogWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 240_000); + const result = await worker.tick(); + + expect(result.dispatched).toBe(false); + expect(worker.paused).toBe(false); // in-flight guard is not an empty backlog + + release(); + await outer; + }); + + it('re-reads the cooldown setting each tick so a change applies on the next cooldown entry', async () => { + const { worker, cfg } = makeEmptyBacklogWorker({ cooldownMs: 3_600_000 }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 240_000); + await worker.tick(); // enters cooldown with 3_600_000 (expires at 4_840_000) + expect(worker.paused).toBe(true); + + // Operator lowers the cooldown live; the in-progress pause keeps its + // original expiry, but the NEXT cooldown entry uses the new value. + cfg.noCandidateCooldownMs = 60_000; + vi.setSystemTime(start + 240_000 + 3_600_000); // original pause expires + await worker.tick(); // resumes, starts a fresh idle run + + vi.setSystemTime(start + 240_000 + 3_600_000 + 240_000); // fresh threshold met + await worker.tick(); // empty backlog again → 60s cooldown (new value) + expect(worker.paused).toBe(true); + + // With the NEW 60s value the pause expires long before the old 60-min + // default would have: at +61s the worker has resumed polling. + vi.setSystemTime(start + 240_000 + 3_600_000 + 240_000 + 61_000); + const resumed = await worker.tick(); + expect(resumed.polled).toBe(true); + expect(worker.paused).toBe(false); + }); +}); + +// ── Three-strike rule on CLI errors ─────────────────────────────────── + +describe('three-strike rule on CLI errors (createDowntimeWorker)', () => { + /** Every dispatch attempt ends in a wl CLI error ({ok:false} both tiers). */ + function makeErrorWorker(overrides: { deps?: Partial } = {}) { + const cfg = { + enabled: true, + thresholdMs: 240_000, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + }; + const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ ok: false }), + ...overrides.deps, + }); + const worker = createDowntimeWorker({ + poller, + deps, + config: () => ({ ...cfg }), + }); + return { worker, deps, cfg, fetcher }; + } + + it('pauses and records the persistent error after 3 consecutive CLI errors', async () => { + const { worker, deps } = makeErrorWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // idle run starts + vi.setSystemTime(start + 240_000); // threshold met + + const s1 = await worker.tick(); // strike 1 + expect(s1.dispatched).toBe(false); + expect(worker.paused).toBe(false); + expect(worker.errorStrikes).toBe(1); + + const s2 = await worker.tick(); // strike 2 + expect(s2.dispatched).toBe(false); + expect(worker.paused).toBe(false); + expect(worker.errorStrikes).toBe(2); + + const s3 = await worker.tick(); // strike 3 → pause + log + expect(s3.dispatched).toBe(false); + expect(worker.paused).toBe(true); + expect(worker.errorStrikes).toBe(0); // counter reset once paused + expect(deps.recordError).toHaveBeenCalledTimes(1); + const event = (deps.recordError as ReturnType).mock.calls[0][0]; + expect(event.cwd).toBe('/repo'); + expect(event.message).toContain('3 consecutive'); + expect(Number.isNaN(Date.parse(event.at))).toBe(false); + }); + + it('resumes with a fresh strike counter once the pause expires', async () => { + const { worker, deps } = makeErrorWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 240_000); + await worker.tick(); // strike 1 + await worker.tick(); // strike 2 + await worker.tick(); // strike 3 → paused + expect(worker.paused).toBe(true); + + vi.setSystemTime(start + 240_000 + 3_600_000); // pause expires + const resumed = await worker.tick(); + expect(resumed.polled).toBe(true); + expect(worker.paused).toBe(false); + expect(worker.errorStrikes).toBe(0); + + // A fresh error is strike 1 again — not a strike 4. + vi.setSystemTime(start + 240_000 + 3_600_000 + 240_000); + await worker.tick(); + expect(worker.errorStrikes).toBe(1); + expect(worker.paused).toBe(false); + }); + + it('resets the strike counter on a successful dispatch', async () => { + const { worker, deps } = makeErrorWorker({ + deps: { + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: false }) // tier 2 error + .mockResolvedValueOnce({ ok: false }) // tier 3 error → strike 1 + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' } }) + .mockResolvedValue({ ok: false }), // errors afterwards + }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 240_000); + + await worker.tick(); // strike 1 + expect(worker.errorStrikes).toBe(1); + + await worker.tick(); // successful dispatch → strikes reset + expect(worker.errorStrikes).toBe(0); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + + // A fresh full idle period is required after the dispatch (AC5) before + // the next dispatch attempt. + vi.setSystemTime(start + 240_000 + 240_000); // fresh idle run starts + await worker.tick(); + vi.setSystemTime(start + 240_000 + 480_000); // fresh threshold met + await worker.tick(); // strike 1 (fresh — not a strike 2) + expect(worker.errorStrikes).toBe(1); + await worker.tick(); // strike 2 + expect(worker.paused).toBe(false); + expect(worker.errorStrikes).toBe(2); + + await worker.tick(); // strike 3 → paused + expect(worker.paused).toBe(true); + expect(deps.recordError).toHaveBeenCalledTimes(1); + }); + + it('does not strike on a no-candidate outcome (CLI answered — healthy)', async () => { + const { worker, deps } = makeErrorWorker({ + deps: { + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: false }) + .mockResolvedValueOnce({ ok: false }) // strike 1 + .mockResolvedValue({ ok: true, candidate: null }), // genuine empty backlog + }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 240_000); + + await worker.tick(); // strike 1 + expect(worker.errorStrikes).toBe(1); + + const result = await worker.tick(); // no-candidate → cooldown, strikes reset + expect(result.dispatched).toBe(false); + expect(worker.paused).toBe(true); // genuine empty backlog pauses the worker + expect(worker.errorStrikes).toBe(0); + expect(deps.recordError).not.toHaveBeenCalled(); // no persistent-error log + }); +}); diff --git a/packages/herdr/src/downtime-worker.ts b/packages/herdr/src/downtime-worker.ts new file mode 100644 index 000000000..e609ba842 --- /dev/null +++ b/packages/herdr/src/downtime-worker.ts @@ -0,0 +1,989 @@ +/** + * packages/herdr/src/downtime-worker.ts — Local-LLM downtime worker (contract) + * + * Test-first contract for the herdr downtime worker (parent + * WL-0MSF49FMW009M06K). Implemented in F1 (tests + fixtures), F2 (settings + * clamps, poller, parsing, runtime idle evaluation) and F3 (idle tracker, + * dispatch orchestration, pane spawn): + * + * - `isIdleStatus` — idle detection from a `/llama/local/status` payload. + * - `evaluateIdle` — runtime idle evaluation with the fail-closed + * degradation for a configured N < total slots (no per-slot data yet). + * - `parseLlamaStatus` / `fetchLocalStatus` / `createDowntimePoller` — the + * single-flight poller for `GET {proxyUrl}/llama/local/status` with + * per-poll timeout and fail-closed parsing. + * - `createIdleTracker` — continuous idle-duration tracker (idleSince vs + * threshold). + * - `dispatchDowntimeWork` — dispatch orchestration: completed/in_review + * items without a valid audit (modified within the last 7 days) → + * `/skill:audit ` (audit tier, WL-0MSI8H3HP000K0RG), then `wl next --stage intake_complete` → + * `/skill:plan `, fallback `--stage idea` → `/skill:intake `, + * pre-dispatch claim, per-process single-flight. A tier-2 CLI error does + * NOT short-circuit: the idea tier is still attempted so a tier-3 + * candidate can still dispatch. + * `wl next` failures are reported as `{ok:false}` (fail closed to busy) + * and are never mistaken for an empty backlog; three consecutive error + * outcomes pause the worker entirely after logging the persistent error + * via `deps.recordError` (three-strike rule, `DOWNTIME_ERROR_STRIKE_LIMIT`). + * - `buildDowntimePaneArgs` / `spawnDowntimePane` — send-to-pi.sh + * invocation (`--pane-name Downtime `, `--no-focus`, `--cwd`, + * `--model`), detached and unref'd. + * - `createDowntimeWorker` — per-tick orchestrator (poll → evaluate → + * track → dispatch) with settings re-read each tick, plus the + * no-candidate cooldown (WL-0MSI7DQL10016QYX): a genuine empty backlog + * in both stages pauses the worker entirely for + * `downtimeNoCandidateCooldownMs` (default 60 min) — no poll, no idle + * tracking, no dispatch — and resets the idle tracker so a fresh full + * idle period is required after the pause. Transient `wl` errors and the + * in-flight dispatch guard never trigger the cooldown; three consecutive + * CLI-error outcomes do (three-strike rule), after logging the + * persistent error via `deps.recordError`. + * - `buildDowntimePrompt` / `BLOCKED_QUESTIONS_INSTRUCTION` — dispatched + * agent prompt, including the blocked-questions instruction. + * - `clampDowntimePollInterval` / `clampDowntimeIdleThresholdMs` / + * `clampDowntimeRequiredFreeSlots` / `clampDowntimeNoCandidateCooldownMs` + * — settings clamps, wired into `settings.ts`. + * + * Fail-closed behaviour (never dispatch, never throw) is the SAFE default + * at every boundary. + */ + +import { spawn } from 'node:child_process'; +import { isAuditFresh } from './icons.js'; + +// ── Constants ───────────────────────────────────────────────────────── + +/** Proxy status endpoint path. */ +export const DOWNTIME_STATUS_PATH = '/llama/local/status'; + +/** Hard floor for the poll interval (must not hammer the proxy). */ +export const DOWNTIME_POLL_INTERVAL_FLOOR_MS = 10_000; + +/** Defensive floor for the idle threshold (prevents immediate dispatch). */ +export const DOWNTIME_IDLE_THRESHOLD_FLOOR_MS = 1_000; + +export const DEFAULT_DOWNTIME_POLL_INTERVAL_MS = 30_000; +export const DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS = 240_000; + +/** 0 = all slots must be free (default). Any positive integer N is accepted. */ +export const DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS = 0; + +/** Sane floor for the no-candidate cooldown (the pause cannot be disabled or set trivially small). */ +export const DOWNTIME_NO_CANDIDATE_COOLDOWN_FLOOR_MS = 60_000; + +/** Default pause after a genuine empty backlog: 60 minutes. */ +export const DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS = 3_600_000; + +/** + * Audit-tier recency window: a completed/in_review candidate is only + * dispatched for audit when it was modified within the last 7 days. + */ +export const DOWNTIME_AUDIT_RECENCY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * Three-strike rule: this many consecutive CLI-error dispatch outcomes + * pause the worker entirely (after logging the persistent error). + */ +export const DOWNTIME_ERROR_STRIKE_LIMIT = 3; + +/** + * Timeout for the downtime worker's `wl` CLI invocations (`getNextItem` / + * `getNextAuditCandidate` in index.ts): a hung `wl` child must be killed + * and the lookup fail closed (a CLI-error strike) within a bounded time + * instead of wedging the dispatch task until the pane restarts + * (WL-0MSJIPHD0001L1J9). 10s is comfortably above the healthy ~0.15s wl + * latency and below the CLI's own 60s safety timeout (used as the upper + * bound for this value). + */ +export const DOWNTIME_WL_TIMEOUT_MS = 10_000; + +/** + * Scheduler-level watchdog bound for ONE downtime-worker tick run + * (WL-0MSJIPHD0001L1J9): the maximum wall-clock time a scheduler run may + * take before it is abandoned and the task's single-flight flag is reset so + * the next tick retries — a hung run can never permanently wedge the + * downtime task. Chosen comfortably above the worst-case bounded dispatch + * path (5s proxy poll + up to three 10s wl lookups + 3s claim + 5s dispatch + * comment ≈ 45s) so healthy runs never trip it, while a hung run still + * recovers within a minute instead of wedging until a pane restart. + */ +export const DOWNTIME_RUN_TIMEOUT_MS = 60_000; + +export const DEFAULT_DOWNTIME_PROXY_URL = 'http://192.168.0.199:8000'; +export const DEFAULT_DOWNTIME_MODEL = 'plan'; + +// ── Types ───────────────────────────────────────────────────────────── + +/** Shape of `GET /llama/local/status` as served by the llama-proxy. */ +export interface LlamaStatus { + llama_server_running: boolean; + active_query: boolean; + model_switch_in_progress: boolean; + local_lease_active: boolean; + available_slots: number; + total_slots: number; + current_model?: string; + local_owner_session_id?: string | null; + local_owner_lease_remaining_seconds?: number | null; +} + +// ── Idle detection (implemented) ────────────────────────────────────── + +/** + * True when the proxy reports an idle state for the required free-slot + * count: + * + * - llama-server is running, + * - no active query, model switch, or local lease, + * - `requiredFreeSlots <= 0` (default) requires ALL slots free + * (`available_slots >= total_slots`) with `total_slots > 0`, + * - a positive N requires at least N slots free; N > total_slots can never + * be satisfied, so it is never idle, + * - ambiguous responses (missing/non-finite fields, `total_slots` 0) are + * busy (fail-closed). + */ +export function isIdleStatus(status: LlamaStatus, requiredFreeSlots: number): boolean { + if (!status.llama_server_running) return false; + if (status.active_query) return false; + if (status.model_switch_in_progress) return false; + if (status.local_lease_active) return false; + + const total = status.total_slots; + if (!Number.isFinite(total) || total <= 0) return false; // ambiguous → busy + + const available = status.available_slots; + if (!Number.isFinite(available) || available < 0) return false; + + const required = requiredFreeSlots <= 0 ? total : requiredFreeSlots; + if (required > total) return false; // N > total_slots can never be idle + return available >= required; +} + +/** + * Runtime idle evaluation (F2). Same conditions as `isIdleStatus`, plus the + * fail-closed fallback from planning decision Q7: while the proxy does not + * expose per-slot identity (LP-0MSG5TA7Y002GN39), a configured N with + * 0 < N < total slots degrades to requiring ALL slots free — stricter than + * configured, never "any N slots" without same-slot identity. N > total + * slots never dispatches (carried through `isIdleStatus`). + */ +export function evaluateIdle(status: LlamaStatus, requiredFreeSlots: number): boolean { + const total = status.total_slots; + if (!Number.isFinite(total) || total <= 0) return false; // ambiguous → busy + const effective = requiredFreeSlots > 0 && requiredFreeSlots < total + ? total + : requiredFreeSlots; + return isIdleStatus(status, effective); +} + +// ── Idle-duration tracker (implemented — F3) ────────────────────────── + +export interface IdleTracker { + /** Idle-since timestamp (ms), or null when not currently in an idle run. */ + readonly idleSince: number | null; + /** + * Record one poll result. `isIdle` true starts (or continues) the idle + * run; false resets it (idleSince = null). + */ + record(isIdle: boolean, now?: number): void; + /** + * True once the current idle run has lasted `thresholdMs` or longer + * continuously. + */ + isThresholdMet(thresholdMs: number, now?: number): boolean; +} + +/** + * Continuous idle-duration tracker. `idleSince` is set on the first idle + * poll and kept fixed for the whole idle run (so consecutive idle polls + * advance the run); ANY busy poll resets it to null. A dispatch therefore + * only fires after the idle state has been continuous for the full + * threshold, and a fresh full idle period is required after any busy poll. + */ +export function createIdleTracker(): IdleTracker { + let idleSince: number | null = null; + return { + get idleSince(): number | null { + return idleSince; + }, + record(isIdle: boolean, now: number = Date.now()): void { + idleSince = isIdle ? (idleSince ?? now) : null; + }, + isThresholdMet(thresholdMs: number, now: number = Date.now()): boolean { + if (idleSince === null) return false; + return now - idleSince >= thresholdMs; + }, + }; +} + +// ── Poller (implemented — F2) ──────────────────────────────────────── + +/** + * Minimal structural fetch signature (the DOM `fetch` type is not part of + * this project's tsconfig lib). Node's global `fetch` satisfies it at + * runtime. + */ +export type LlamaStatusFetcher = ( + url: string, + init?: { signal?: unknown }, +) => Promise<{ ok: boolean; status: number; json(): Promise }>; + +/** Per-poll timeout: an unresponsive proxy must fail closed, not hang. */ +export const DEFAULT_DOWNTIME_POLL_TIMEOUT_MS = 5_000; + +/** + * Fail-closed parse of a `GET /llama/local/status` payload. Returns null + * (treated as busy by the caller) when required fields are missing or + * malformed. `local_lease_active` is derived from the lease fields the + * proxy actually serves when the boolean is not present. + */ +export function parseLlamaStatus(raw: unknown): LlamaStatus | null { + if (typeof raw !== 'object' || raw === null) return null; + const o = raw as Record; + + if (typeof o.llama_server_running !== 'boolean') return null; + if (typeof o.active_query !== 'boolean') return null; + if (typeof o.model_switch_in_progress !== 'boolean') return null; + + const available = o.available_slots; + const total = o.total_slots; + if (typeof available !== 'number' || !Number.isFinite(available) || available < 0) return null; + if (typeof total !== 'number' || !Number.isFinite(total) || total <= 0) return null; + + let localLeaseActive: boolean; + if (typeof o.local_lease_active === 'boolean') { + localLeaseActive = o.local_lease_active; + } else { + // Derived from the lease fields served by the llama-proxy. + const ownerSession = o.local_owner_session_id; + const leaseSeconds = o.local_owner_lease_remaining_seconds; + localLeaseActive = + (typeof ownerSession === 'string' && ownerSession.length > 0) || + (typeof leaseSeconds === 'number' && Number.isFinite(leaseSeconds) && leaseSeconds > 0); + } + + return { + llama_server_running: o.llama_server_running, + active_query: o.active_query, + model_switch_in_progress: o.model_switch_in_progress, + local_lease_active: localLeaseActive, + available_slots: available, + total_slots: total, + current_model: typeof o.current_model === 'string' ? o.current_model : undefined, + local_owner_session_id: + typeof o.local_owner_session_id === 'string' ? o.local_owner_session_id : undefined, + local_owner_lease_remaining_seconds: + typeof o.local_owner_lease_remaining_seconds === 'number' + ? o.local_owner_lease_remaining_seconds + : undefined, + }; +} + +/** + * Fetch and parse `GET {url}/llama/local/status` with a per-poll timeout. + * Every failure mode — network error, timeout, HTTP error status, invalid + * JSON, ambiguous payload — resolves to null (busy); this function never + * throws. + */ +export async function fetchLocalStatus( + url: string, + opts?: { timeoutMs?: number; fetcher?: LlamaStatusFetcher }, +): Promise { + const timeoutMs = opts?.timeoutMs ?? DEFAULT_DOWNTIME_POLL_TIMEOUT_MS; + const fetcher = opts?.fetcher ?? (globalThis.fetch as unknown as LlamaStatusFetcher); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetcher(url, { signal: controller.signal }); + if (!res.ok) return null; + let raw: unknown; + try { + raw = await res.json(); + } catch { + return null; // invalid JSON + } + return parseLlamaStatus(raw); + } catch { + return null; // network errors / timeouts → busy, never throw + } finally { + clearTimeout(timer); + } +} + +export interface DowntimePoller { + /** + * Poll `GET {proxyUrl}/llama/local/status` once. Resolves to the parsed + * status, or null when the endpoint failed/timed out or the payload was + * ambiguous (both cases are treated as busy by the caller). Overlapping + * calls coalesce onto the in-flight poll (single-flight). + */ + poll(): Promise; + /** True while a poll is in flight (single-flight guard for the loop). */ + isPolling(): boolean; +} + +/** + * Single-flight poller for the llama-proxy status endpoint. `poll()` never + * overlaps: a second call while one poll is in flight returns the same + * in-flight promise. + */ +export function createDowntimePoller( + proxyUrl: string, + fetcher: LlamaStatusFetcher = globalThis.fetch as unknown as LlamaStatusFetcher, + timeoutMs: number = DEFAULT_DOWNTIME_POLL_TIMEOUT_MS, +): DowntimePoller { + let inFlight: Promise | null = null; + const url = `${proxyUrl.replace(/\/+$/, '')}${DOWNTIME_STATUS_PATH}`; + + return { + poll(): Promise { + if (inFlight !== null) return inFlight; + inFlight = fetchLocalStatus(url, { timeoutMs, fetcher }).finally(() => { + inFlight = null; + }); + return inFlight; + }, + isPolling(): boolean { + return inFlight !== null; + }, + }; +} + +// ── Dispatch (implemented — F3) ─────────────────────────────────────── + +export type DowntimeStage = 'intake_complete' | 'idea' | 'audit'; +export type DowntimeSkillKind = 'plan' | 'intake' | 'audit'; + +export interface DowntimeCandidate { + id: string; + title: string; + stage: DowntimeStage; +} + +/** + * A completed/in_review item candidate for the downtime audit tier. + * Parsed from `wl list --status completed --stage in_review --json`. + */ +export interface AuditCandidate { + id: string; + title: string; + auditedAt?: string | null; + updatedAt?: string; + sortIndex?: number; +} + +/** + * Outcome of one `wl next --stage --json` lookup. + * + * `ok: true` means the CLI answered (candidate may still be null when the + * stage is genuinely empty); `ok: false` means a transient wl/CLI failure + * (fail-closed to busy — never a candidate). The distinction matters: an + * empty backlog pauses the worker immediately, while a CLI failure counts + * as one strike and only pauses after `DOWNTIME_ERROR_STRIKE_LIMIT` + * consecutive failures (three-strike rule). + */ +export type DowntimeNextResult = + | { ok: true; candidate: DowntimeCandidate | null } + | { ok: false }; + +/** External boundaries injected so the dispatch logic is testable. */ +export interface DowntimeWorkerDeps { + /** Runs `wl next --stage --json` and reports the first candidate (or a wl failure). */ + getNextItem(stage: DowntimeStage): Promise; + /** + * Look up the next completed/in_review item WITHOUT a valid audit (the + * audit dispatch tier, which runs before the plan/intake tiers). Fail-closed: + * a wl failure yields no candidate (no dispatch). + */ + getNextAuditCandidate(): Promise; + /** Claims the item (`wl update --status in_progress`) before dispatch. */ + claimItem(itemId: string): Promise; + /** Opens a visible pi agent pane running the prompt (via send-to-pi.sh). */ + spawnAgentPane(prompt: string, opts: { model: string; cwd: string }): Promise; + /** + * Audit trail for a successful dispatch: comment on the item + rolling + * log entry under `.worklog`. Must never throw (fail-closed). + */ + recordDispatch(event: DowntimeDispatchEvent): Promise; + /** + * Record a persistent CLI-error event (three consecutive wl failures). + * Must never throw (fail-closed): logging must not crash the worker. + */ + recordError(event: DowntimeErrorEvent): Promise; +} + +/** Audit event recorded for every successful downtime dispatch. */ +export interface DowntimeDispatchEvent { + itemId: string; + kind: DowntimeSkillKind; + /** ISO-8601 UTC timestamp of the dispatch. */ + dispatchedAt: string; + /** Worklog root (the rolling log lives at `/.worklog`). */ + cwd: string; + title?: string; +} + +/** + * Persistent-error event recorded when the wl CLI fails + * `DOWNTIME_ERROR_STRIKE_LIMIT` times consecutively. Written to the rolling + * `.worklog` log so the failure is auditable even though no dispatch occurs. + */ +export interface DowntimeErrorEvent { + /** Worklog root (the rolling log lives at `/.worklog`). */ + cwd: string; + /** ISO-8601 UTC timestamp of the error. */ + at: string; + message: string; +} + +export interface DowntimeDispatchOutcome { + dispatched: boolean; + candidate?: DowntimeCandidate; + kind?: DowntimeSkillKind; + reason?: string; +} + +/** + * Per-process single-flight guard: at most one dispatch can be in flight at + * a time (concurrent calls are refused, not queued). Cross-pane + * serialization is handled by the pre-dispatch claim (Q5 — no lock file): + * the claimed item leaves `wl next`'s selection set for other panes. + */ +let dispatchInFlight = false; + +/** + * Dispatch one downtime work item. Selection priority (audit tier first, + * WL-0MSI8H3HP000K0RG): a completed/in_review item WITHOUT a valid audit → + * `/skill:audit `; if none, `wl next --stage intake_complete` → + * `/skill:plan `; if none, `wl next --stage idea` → `/skill:intake `; + * if all three are empty, no dispatch. A CLI error at the intake_complete + * tier does NOT short-circuit: the idea tier is still attempted, so a + * tier-3 candidate can still dispatch. A `wl-error` outcome (any CLI + * failure) is never a candidate and never a `no-candidate`; the caller's + * three-strike rule decides when consecutive errors pause the worker. The + * item is claimed BEFORE the pane spawns so it appears in_progress + * immediately and a second pane cannot select it. The caller must only + * invoke this once the idle tracker reports the threshold met (AC1); a + * dispatch consumes the local slot, so the proxy reports busy and the + * tracker requires a fresh full idle period before the next dispatch. + */ +export async function dispatchDowntimeWork( + deps: DowntimeWorkerDeps, + opts: { model: string; cwd: string }, +): Promise { + if (dispatchInFlight) { + return { dispatched: false, reason: 'dispatch-in-flight' }; + } + dispatchInFlight = true; + try { + const auditCandidate = await deps.getNextAuditCandidate(); + if (auditCandidate !== null) { + await deps.claimItem(auditCandidate.id); + await deps.spawnAgentPane(buildDowntimePrompt('audit', auditCandidate), opts); + await recordDispatchAudit(deps, 'audit', auditCandidate, opts.cwd); + return { dispatched: true, candidate: auditCandidate, kind: 'audit' }; + } + // Tier 2 (intake_complete → /skill:plan). A CLI error here does NOT + // short-circuit: tier 3 is still attempted so a tier-3 candidate can + // still dispatch (operator refinement). + let tier2Error = false; + const intakeComplete = await deps.getNextItem('intake_complete'); + if (intakeComplete.ok) { + if (intakeComplete.candidate !== null) { + await deps.claimItem(intakeComplete.candidate.id); + await deps.spawnAgentPane(buildDowntimePrompt('plan', intakeComplete.candidate), opts); + await recordDispatchAudit(deps, 'plan', intakeComplete.candidate, opts.cwd); + return { dispatched: true, candidate: intakeComplete.candidate, kind: 'plan' }; + } + } else { + tier2Error = true; + } + + // Tier 3 (idea → /skill:intake) is ALWAYS attempted when tier 2 produced + // no candidate — including when tier 2 errored. + const idea = await deps.getNextItem('idea'); + if (idea.ok) { + if (idea.candidate !== null) { + await deps.claimItem(idea.candidate.id); + await deps.spawnAgentPane(buildDowntimePrompt('intake', idea.candidate), opts); + await recordDispatchAudit(deps, 'intake', idea.candidate, opts.cwd); + return { dispatched: true, candidate: idea.candidate, kind: 'intake' }; + } + if (tier2Error) { + // Tier 2 errored and tier 3 answered empty: the backlog is NOT + // provably empty (the intake_complete state is unknown) → fail + // closed to busy (a strike), never a no-candidate — partial + // information must not pause the worker. + return { dispatched: false, reason: 'wl-error' }; + } + // Both tiers answered with no candidate → genuine empty backlog (the + // only outcome that triggers the no-candidate cooldown in the worker). + return { dispatched: false, reason: 'no-candidate' }; + } + // Tier 3 errored (with or without a tier-2 error): fail closed to busy. + // The worker counts this as one CLI-error strike; the backlog is not + // provably empty so this is never `no-candidate` (the three-strike rule + // governs when consecutive errors pause the worker). + return { dispatched: false, reason: 'wl-error' }; + } finally { + dispatchInFlight = false; + } +} + +/** + * Record the audit event for a successful dispatch. Wrapped so a failing + * audit implementation (or a stub that rejects) can never fail or block the + * dispatch itself — the audit trail is best-effort (AC4). + */ +async function recordDispatchAudit( + deps: DowntimeWorkerDeps, + kind: DowntimeSkillKind, + candidate: DowntimeCandidate, + cwd: string, +): Promise { + try { + await deps.recordDispatch({ + itemId: candidate.id, + kind, + dispatchedAt: new Date().toISOString(), + cwd, + title: candidate.title, + }); + } catch { + // fail-closed: audit logging must never crash or block the worker + } +} + +/** + * Argument vector for spawning `send-to-pi.sh`: visible pane named + * `Downtime `, `--no-focus` (visible but never steals focus), + * `--cwd `, `--model `, then the prompt. + */ +export function buildDowntimePaneArgs( + kind: DowntimeSkillKind, + prompt: string, + opts: { model: string; cwd: string }, +): string[] { + return [ + '--pane-name', + `Downtime ${kind}`, + '--no-focus', + '--cwd', + opts.cwd, + '--model', + opts.model, + prompt, + ]; +} + +/** Minimal spawn handle: the caller unrefs so the parent can exit first. */ +export interface DowntimeSpawnHandle { + unref(): void; +} + +/** Injectable spawn boundary (matches the repo's injectable-seam pattern). */ +export type DowntimeSpawn = ( + scriptPath: string, + args: string[], + opts: { cwd: string }, +) => DowntimeSpawnHandle; + +/** + * Spawn options for `send-to-pi.sh`: detached, stdio ignored, resolved cwd + * forwarded so the pane opens in the right project root. + */ +export function buildDowntimeSpawnOptions(cwd: string): { + detached: boolean; + stdio: 'ignore'; + cwd: string; + env: NodeJS.ProcessEnv; +} { + return { + detached: true, + stdio: 'ignore', + cwd, + env: { ...process.env, HERDR_RESOLVED_CWD: cwd }, + }; +} + +/** Default spawn: detached, stdio ignored, resolved cwd forwarded. */ +export const defaultDowntimeSpawn: DowntimeSpawn = (scriptPath, args, opts) => + spawn(scriptPath, args, buildDowntimeSpawnOptions(opts.cwd)); + +/** + * Spawn `send-to-pi.sh` detached with stdio ignored, then unref so the + * parent (plugin) process can exit independently — same pattern as the + * TUI's existing agent dispatches. + */ +export function spawnDowntimePane( + scriptPath: string, + args: string[], + opts: { cwd: string }, + spawnFn: DowntimeSpawn = defaultDowntimeSpawn, +): void { + const child = spawnFn(scriptPath, args, opts); + child.unref(); +} + +// ── Worker orchestrator (implemented — F3) ──────────────────────────── + +/** Per-tick configuration; re-read every tick so settings apply live. */ +export interface DowntimeWorkerConfig { + poller: DowntimePoller; + deps: DowntimeWorkerDeps; + /** Re-read each tick so settings changes apply without a restart. */ + config(): { + enabled: boolean; + thresholdMs: number; + requiredFreeSlots: number; + model: string; + cwd: string; + /** Pause duration after a genuine empty backlog (no-candidate), ms. */ + noCandidateCooldownMs: number; + }; +} + +export interface DowntimeWorkerTickResult { + polled: boolean; + dispatched: boolean; + idle: boolean; +} + +export interface DowntimeWorker { + /** One poll + evaluation + possible dispatch. Call from the scheduler task. */ + tick(): Promise; + /** Idle-since timestamp of the current idle run (null when busy). */ + readonly idleSince: number | null; + /** True while a dispatch is in flight. */ + readonly dispatching: boolean; + /** Timestamp of the last successful dispatch (null until the first). */ + readonly lastDispatchAt: number | null; + /** Whether the worker is enabled per the current settings (re-read). */ + readonly enabled: boolean; + /** + * True while the worker is paused in the no-candidate cooldown: no proxy + * polling, no idle tracking, and no dispatch until the pause expires + * (WL-0MSI7DQL10016QYX). + */ + readonly paused: boolean; + /** + * Count of consecutive CLI-error dispatch outcomes (three-strike rule). + * Reset to 0 on a successful dispatch, a genuine no-candidate outcome, or + * when a cooldown expires. + */ + readonly errorStrikes: number; +} + +/** + * Compose poller + idle evaluation + tracker + dispatch into the per-tick + * worker the scheduler loop (F4) registers as a due-work task. + */ +export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker { + const tracker = createIdleTracker(); + let dispatching = false; + let lastDispatchAt: number | null = null; + // No-candidate cooldown (WL-0MSI7DQL10016QYX): timestamp until which the + // worker is fully paused (no poll, no idle tracking, no dispatch) after a + // genuine empty backlog OR three consecutive CLI errors. null = not paused. + let cooldownUntil: number | null = null; + // Three-strike rule: consecutive CLI-error dispatch outcomes. A successful + // dispatch, a genuine no-candidate outcome, or an expired cooldown resets it. + let errorStrikes = 0; + + return { + get idleSince(): number | null { + return tracker.idleSince; + }, + get dispatching(): boolean { + return dispatching; + }, + get lastDispatchAt(): number | null { + return lastDispatchAt; + }, + get enabled(): boolean { + return opts.config().enabled; + }, + get paused(): boolean { + return cooldownUntil !== null && Date.now() < cooldownUntil; + }, + get errorStrikes(): number { + return errorStrikes; + }, + async tick(): Promise { + const cfg = opts.config(); + if (!cfg.enabled) return { polled: false, dispatched: false, idle: false }; + // Cooldown gate: while paused the worker performs NO proxy polling, NO + // idle tracking, and NO dispatch. The pause is a full stop (user + // confirmed "pause completely"); once it expires the idle tracker is + // empty, so a fresh full idle period is required before the next + // dispatch (no stale idle credit from before the pause). + if (cooldownUntil !== null) { + if (Date.now() < cooldownUntil) { + return { polled: false, dispatched: false, idle: false }; + } + cooldownUntil = null; // pause expired — resume normal polling + errorStrikes = 0; // fresh strike counter after the pause + } + if (opts.poller.isPolling()) return { polled: false, dispatched: false, idle: tracker.idleSince !== null }; + + const status = await opts.poller.poll(); + if (status === null) { + tracker.record(false); // endpoint failure/ambiguity → busy + return { polled: true, dispatched: false, idle: false }; + } + + const idle = evaluateIdle(status, cfg.requiredFreeSlots); + tracker.record(idle); + if (!idle) return { polled: true, dispatched: false, idle: false }; + if (!tracker.isThresholdMet(cfg.thresholdMs)) { + return { polled: true, dispatched: false, idle: true }; + } + if (dispatching) return { polled: true, dispatched: false, idle: true }; + + dispatching = true; + try { + const outcome = await dispatchDowntimeWork(opts.deps, { + model: cfg.model, + cwd: cfg.cwd, + }); + if (outcome.dispatched) { + lastDispatchAt = Date.now(); + errorStrikes = 0; // a successful dispatch proves the CLI is healthy + // Belt-and-suspenders: even if the proxy does not immediately + // report busy, require a fresh full idle period before the next + // dispatch (AC5). + tracker.record(false); + } else if (outcome.reason === 'no-candidate') { + // Genuine empty backlog (both stages answered with no candidate): + // pause entirely so the worker stops burning cycles (proxy polling + // + wl next spawns) for the configured cooldown. Reset the idle + // tracker so a fresh full idle period is required after the pause. + errorStrikes = 0; // the CLI answered — it is healthy + cooldownUntil = Date.now() + cfg.noCandidateCooldownMs; + tracker.record(false); + } else if (outcome.reason === 'wl-error') { + // Three-strike rule on CLI errors: a dispatch attempt ending in a + // wl failure is one strike. Three consecutive strikes pause the + // worker entirely (no dispatch) AFTER logging the persistent error + // so the failure is auditable. A single transient error does NOT + // pause — it retries on the next idle period. + errorStrikes += 1; + if (errorStrikes >= DOWNTIME_ERROR_STRIKE_LIMIT) { + try { + await opts.deps.recordError({ + cwd: cfg.cwd, + at: new Date().toISOString(), + message: + `Downtime worker: ${DOWNTIME_ERROR_STRIKE_LIMIT} consecutive ` + + `wl CLI errors — pausing dispatch for ${cfg.noCandidateCooldownMs}ms.`, + }); + } catch { + // fail-closed: error logging must never crash the worker + } + cooldownUntil = Date.now() + cfg.noCandidateCooldownMs; + errorStrikes = 0; + tracker.record(false); + } + } + // Any other non-dispatch outcome (dispatch-in-flight) is neutral: + // no strike, no cooldown — the next idle period retries. + return { polled: true, dispatched: outcome.dispatched, idle: true }; + } finally { + dispatching = false; + } + }, + }; +} + +// ── Blocked-questions prompt (implemented) ──────────────────────────── + +/** + * Instruction appended to every dispatched prompt: when the skill run + * cannot proceed because answers are needed, record the questions in a + * comment on the work item, mark it as needing producer review, and stop — + * never block indefinitely (parent AC6). + */ +export const BLOCKED_QUESTIONS_INSTRUCTION = + 'If you cannot proceed because you need answers, record the questions in a ' + + 'comment on the work item (wl comment add --comment "question: ...") and ' + + 'mark the item as needing producer review (wl update --needs-producer-review ' + + 'true), then stop — do not block indefinitely.'; + +/** Build the prompt dispatched to a pi agent pane for the given candidate. */ +export function buildDowntimePrompt(kind: DowntimeSkillKind, candidate: DowntimeCandidate): string { + const skill = kind === 'plan' ? '/skill:plan' : kind === 'audit' ? '/skill:audit' : '/skill:intake'; + return [ + `Run ${skill} ${candidate.id} — ${candidate.title}.`, + BLOCKED_QUESTIONS_INSTRUCTION, + ].join('\n'); +} + +/** + * Build the audit comment added to the item on a successful downtime + * dispatch: author-visible statement of the automatic dispatch, the skill + * run, the item, and the UTC timestamp. Newlines in the title are + * collapsed so the comment stays a single clean line. + */ +export function buildDowntimeDispatchComment( + itemId: string, + kind: DowntimeSkillKind, + dispatchedAt: string, + title?: string, +): string { + const skill = kind === 'plan' ? '/skill:plan' : kind === 'audit' ? '/skill:audit' : '/skill:intake'; + const suffix = title ? ` (${title.replace(/[\r\n]+/g, ' ')})` : ''; + return `Auto-dispatched by the herdr downtime worker at ${dispatchedAt} — running ${skill} ${itemId}${suffix}.`; +} + +// ── Wiring helpers (implemented — F4) ───────────────────────────────── + +/** + * Parse the stdout of `wl next --stage --json` into the first + * candidate. Returns null (no dispatch) for empty output, a null + * `workItem`, missing ids, or malformed JSON. + */ +export function parseNextItemOutput(stdout: string, stage: DowntimeStage): DowntimeCandidate | null { + try { + const parsed = JSON.parse(stdout) as { + workItem?: { id?: unknown; title?: unknown } | null; + }; + const raw = parsed?.workItem; + if (!raw || typeof raw !== 'object') return null; + if (typeof raw.id !== 'string' || raw.id.length === 0) return null; + return { + id: raw.id, + title: typeof raw.title === 'string' ? raw.title : '', + stage, + }; + } catch { + return null; + } +} + +/** + * Derive the dispatch kind from a prompt built by `buildDowntimePrompt` + * (the pane name is `Downtime plan` / `Downtime intake` / `Downtime audit`). + */ +export function skillKindFromPrompt(prompt: string): DowntimeSkillKind { + if (prompt.includes('/skill:audit ')) return 'audit'; + return prompt.includes('/skill:plan ') ? 'plan' : 'intake'; +} + +// ── Audit-tier selection (WL-0MSI8H3HP000K0RG) ──────────────────────── + +/** + * Parse the stdout of `wl list --status completed --stage in_review --json` + * into typed audit candidates. Accepts both the bare array shape and the + * `{ workItems: [...] }` wrapper. Malformed/empty output yields null + * (fail-closed). + */ +export function parseAuditCandidatesOutput(stdout: string): AuditCandidate[] | null { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return null; + } + const items = Array.isArray(parsed) + ? parsed + : parsed !== null && typeof parsed === 'object' && Array.isArray((parsed as { workItems?: unknown }).workItems) + ? (parsed as { workItems: unknown[] }).workItems + : null; + if (items === null) return null; + + const candidates: AuditCandidate[] = []; + for (const raw of items) { + if (typeof raw !== 'object' || raw === null) continue; + const o = raw as Record; + if (typeof o.id !== 'string' || o.id.length === 0) continue; + candidates.push({ + id: o.id, + title: typeof o.title === 'string' ? o.title : '', + auditedAt: typeof o.auditedAt === 'string' ? o.auditedAt : undefined, + updatedAt: typeof o.updatedAt === 'string' ? o.updatedAt : undefined, + sortIndex: typeof o.sortIndex === 'number' && Number.isFinite(o.sortIndex) ? o.sortIndex : undefined, + }); + } + return candidates; +} + +/** + * Select the next audit candidate from parsed in_review items: the first + * item WITHOUT a valid audit, sorted ascending by `sortIndex` (heartbeat + * convention). "Valid audit" reuses `isAuditFresh` from icons.ts (fresh = a + * review icon that is neither ⏳ nor 🔍). Returns null when no candidate is + * unaudited/stale (or the list is empty). Missing auditedAt/updatedAt means + * not fresh → selected. + * + * 7-day recency filter: a candidate must have been modified within + * `DOWNTIME_AUDIT_RECENCY_WINDOW_MS` (7 days) to be dispatched. A MISSING + * `updatedAt` is still included (recency cannot be verified → include, per + * operator decision — absent data must not silently drop candidates); an + * unparseable `updatedAt` is excluded (fail-closed: recency cannot be + * verified). `now` is injectable for deterministic tests. + */ +export function selectAuditCandidate( + candidates: AuditCandidate[], + now: number = Date.now(), +): AuditCandidate | null { + const recencyCutoff = now - DOWNTIME_AUDIT_RECENCY_WINDOW_MS; + const target = candidates + .filter((c) => !isAuditFresh(c.auditedAt, c.updatedAt)) + .filter((c) => { + if (!c.updatedAt) return true; // missing → include + const updated = new Date(c.updatedAt).getTime(); + if (Number.isNaN(updated)) return false; // unparseable → fail-closed exclude + return updated >= recencyCutoff; + }) + .sort((a, b) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0)); + return target[0] ?? null; +} + +/** + * Turn a parsed audit candidate into a dispatachable `DowntimeCandidate` + * (stage `audit`) for the audit tier. + */ +export function toDowntimeCandidate(candidate: AuditCandidate): DowntimeCandidate { + return { id: candidate.id, title: candidate.title, stage: 'audit' }; +} + +// ── Settings clamps (implemented — wired into settings.ts by F2) ────── + +/** + * Clamp the downtime poll interval: never below the 10s hard floor, default + * 30s for non-finite input. + */ +export function clampDowntimePollInterval(value: number): number { + if (!Number.isFinite(value)) return DEFAULT_DOWNTIME_POLL_INTERVAL_MS; + return Math.max(Math.round(value), DOWNTIME_POLL_INTERVAL_FLOOR_MS); +} + +/** + * Clamp the idle threshold: reject negative/non-finite (fall back to the + * 4-minute default) and floor at 1s to prevent immediate dispatch. + */ +export function clampDowntimeIdleThresholdMs(value: number): number { + if (!Number.isFinite(value) || value < 0) return DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS; + return Math.max(Math.round(value), DOWNTIME_IDLE_THRESHOLD_FLOOR_MS); +} + +/** + * Clamp the required-free-slots count: a non-negative integer; 0 = all + * slots. Negative/non-finite input falls back to 0 (all slots, the + * strictest safe default). + */ +export function clampDowntimeRequiredFreeSlots(value: number): number { + if (!Number.isFinite(value) || value < 0) return DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS; + return Math.round(value); +} + +/** + * Clamp the no-candidate cooldown: reject negative/non-finite (fall back to + * the 60-minute default) and floor at 60s so the pause cannot be disabled or + * set trivially small (WL-0MSI7DQL10016QYX). + */ +export function clampDowntimeNoCandidateCooldownMs(value: number): number { + if (!Number.isFinite(value) || value < 0) return DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS; + return Math.max(Math.round(value), DOWNTIME_NO_CANDIDATE_COOLDOWN_FLOOR_MS); +} diff --git a/packages/herdr/src/fetcher.ts b/packages/herdr/src/fetcher.ts index 521887036..5af804d3a 100644 --- a/packages/herdr/src/fetcher.ts +++ b/packages/herdr/src/fetcher.ts @@ -11,6 +11,7 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { selectWorkItems } from './smart-selection.js'; import { regroupWorkItems } from './grouping.js'; +import type { AgentState } from './agent-tracker.js'; // ── Types ──────────────────────────────────────────────────────────── @@ -57,6 +58,9 @@ let _worklogDir: string | undefined; */ export function setWorklogDir(dir: string | undefined): void { _worklogDir = dir; + // Switching worklog dirs invalidates any in-flight fetch memo (F4): reads + // racing against the old dir must never be shared with the new one. + clearFetchMemo(); } /** @@ -67,11 +71,28 @@ export function getWorklogDir(): string | undefined { return _worklogDir; } +/** + * Build the argument vector for a wl CLI invocation, prepending the + * `--worklog-dir` override when set (it is a global option that must appear + * BEFORE the subcommand). Callers that invoke wl directly via + * `getExecFileAsync()` (e.g. the downtime worker's `wl next` and + * `wl comment add`) use this so their commands resolve against the same + * worklog root as the worklist — without the override the argument vector is + * returned unchanged (current behavior preserved). + */ +export function buildWlArgs(args: string[]): string[] { + if (_worklogDir !== undefined) { + return ['--worklog-dir', _worklogDir, ...args]; + } + return args; +} + /** * Reset the worklog directory override. */ export function resetWorklogDir(): void { _worklogDir = undefined; + clearFetchMemo(); } /** @@ -129,6 +150,12 @@ export interface WorkItem { depth?: number; /** Internal: whether the expand icon should show collapsed state. */ _expanded?: boolean; + /** + * Current agent status for the worklist-spawned agent pane associated + * with this item (merged by the agent tracker). `idle`/`working`/`blocked` + * render an icon; `done`/`unknown`/absent render none (WL-0MSBQUJQX005RAT9). + */ + agentState?: AgentState; } // ── Helpers ─────────────────────────────────────────────────────────── @@ -263,7 +290,53 @@ function extractItems(payload: unknown): WorkItem[] { const CLI_BINARIES = ['wl', 'worklog']; -async function runWl(args: string[], includeJson = true, timeoutMs?: number): Promise { +// ── In-process fetch memoization (F4 — WL-0MSGAEPOJ002824S) ─────────── + +/** + * Read commands whose results may be deduplicated in-process. Matches the + * wl CLI read-cache command set (F2). Writes (update/create/sync/…) are + * NEVER memoized — a deduped write would drop a mutation. + */ +const MEMOIZABLE_COMMANDS = new Set(['list', 'next', 'show', 'search', 'status']); + +/** Cap on in-flight memo entries (bounded: entries are removed on settle, so + * this caps only concurrent racing fetches, which is the dedupe window). */ +const MEMO_MAX_ENTRIES = 64; + +/** + * In-flight promise memo: key → shared promise for an identical read that is + * currently executing in this process. Racing identical fetches (overlapping + * refresh ticks, duplicate pane queries) share the SAME promise, so `wl` is + * spawned once instead of N times. Entries are deleted when the promise + * settles — the memo never serves a settled result, so reads that begin + * after a DB write always spawn fresh (never stale across writes). + */ +const inflightFetchMemo = new Map>(); + +function fetchMemoKey(args: string[], includeJson: boolean): string { + // Include the worklog-dir override so a dir change never cross-contaminates. + return `${_worklogDir ?? ''}\u0000${includeJson ? '1' : '0'}\u0000${args.join('\u0000')}`; +} + +/** + * Drop all in-flight fetch memo entries (after a write or worklog-dir + * change). Reads that started before the write keep their own result; reads + * issued after the write will spawn fresh instead of sharing a pre-write + * in-flight read. + */ +export function clearFetchMemo(): void { + inflightFetchMemo.clear(); +} + +/** + * Test helper: current number of in-flight memo entries. Used to verify the + * memo stays bounded under many concurrent distinct fetches. + */ +export function _fetchMemoSize(): number { + return inflightFetchMemo.size; +} + +async function runWlInner(args: string[], includeJson: boolean, timeoutMs?: number): Promise { let lastError: unknown; for (const binary of CLI_BINARIES) { @@ -275,11 +348,8 @@ async function runWl(args: string[], includeJson = true, timeoutMs?: number): Pr fullArgs = args; } - // Prepend --worklog-dir when set (it's a global option that must - // appear before the subcommand). - if (_worklogDir !== undefined) { - fullArgs = ['--worklog-dir', _worklogDir, ...fullArgs]; - } + // Prepend --worklog-dir when set (global option before the subcommand). + fullArgs = buildWlArgs(fullArgs); const result = await execFileAsync(binary, fullArgs, { maxBuffer: 1024 * 1024 * 5, @@ -303,6 +373,43 @@ async function runWl(args: string[], includeJson = true, timeoutMs?: number): Pr throw new Error(`wl CLI not found: ${String(lastError)}`); } +async function runWl(args: string[], includeJson = true, timeoutMs?: number): Promise { + const command = args[0]; + + // Writes must never be deduplicated or share pre-write read results: drop + // any in-flight read memo so a read issued after this write spawns fresh. + if (!MEMOIZABLE_COMMANDS.has(command)) { + if (inflightFetchMemo.size > 0) inflightFetchMemo.clear(); + return runWlInner(args, includeJson, timeoutMs); + } + + // Read: dedupe concurrent identical fetches within this process (F4). + const key = fetchMemoKey(args, includeJson); + const inFlight = inflightFetchMemo.get(key); + if (inFlight) { + return inFlight; + } + + const promise = runWlInner(args, includeJson, timeoutMs).finally(() => { + // Remove on settle: the memo only dedupes CONCURRENT fetches, so a + // later identical read always spawns fresh (never stale across writes). + if (inflightFetchMemo.get(key) === promise) { + inflightFetchMemo.delete(key); + } + }); + + // Bound: evict the oldest entry when over the cap (Map preserves insertion + // order, so the first key is the oldest). + if (inflightFetchMemo.size >= MEMO_MAX_ENTRIES) { + const oldest = inflightFetchMemo.keys().next().value; + if (oldest !== undefined) { + inflightFetchMemo.delete(oldest); + } + } + inflightFetchMemo.set(key, promise); + return promise; +} + // ── Public API ──────────────────────────────────────────────────────── /** @@ -392,12 +499,16 @@ export async function fetchNextItems(count?: number): Promise { } /** - * Fetch work items filtered by stage (via `wl list --stage`). + * Fetch work items filtered by stage (via `wl list`). + * Open items only (WL-0MSDT8X1V003206G): the stage-filtered worklist shows + * every open root item in the stage — items with status `blocked`, + * `in-progress`, or `completed` are excluded even when their stage matches. * Root-only (WL-0MS964SIA0057ABR): stage-filtered top-level lists hide * child items; children remain reachable via expand (wl list --parent). + * Results are ordered by the standard list order (sortIndex). */ export async function fetchItemsByStage(stage: string): Promise { - const output = await runWl(['list', '--stage', stage, '--root-only']); + const output = await runWl(['list', '--status', 'open', '--stage', stage, '--root-only']); const payload = extractJson(output); return extractItems(payload); } diff --git a/packages/herdr/src/grouping.test.ts b/packages/herdr/src/grouping.test.ts index 62fd62e8b..d65573510 100644 --- a/packages/herdr/src/grouping.test.ts +++ b/packages/herdr/src/grouping.test.ts @@ -42,15 +42,18 @@ describe('grouping.ts — duplicated algorithm mirrors core spec', () => { { id: 'WL-C1', stage: 'plan_complete', filePaths: ['src/c.ts'], priority: 'critical' }, { id: 'WL-P1', stage: 'plan_complete', filePaths: ['src/p1.ts'], priority: 'high' }, { id: 'WL-I1', stage: 'intake_complete', filePaths: ['src/i1.ts'], priority: 'medium' }, + { id: 'WL-IP1', stage: 'in_progress', filePaths: ['src/ip1.ts'], priority: 'high' }, { id: 'WL-idea', stage: 'idea', filePaths: [], priority: 'low' }, - { id: 'WL-other', stage: 'in_progress', filePaths: [], priority: 'medium' }, + { id: 'WL-other', stage: 'custom', filePaths: [], priority: 'medium' }, { id: 'WL-R1', stage: 'in_review', filePaths: [], priority: 'medium' }, ]; const groups = assignItemGroups(items, 3); const groupOf = (id: string): number => groups.get(id)!.group; expect(groups.get('WL-C1')!.groupLabel).toBe('Critical Group 1'); + // Plan/intake/in_progress items share Group N (no stage prefix in the label). expect(groups.get('WL-P1')!.groupLabel).toBe('Group 1'); expect(groups.get('WL-I1')!.groupLabel).toBe('Group 1'); + expect(groups.get('WL-IP1')!.groupLabel).toBe('Group 1'); expect(groupOf('WL-C1')).toBeLessThan(groupOf('WL-P1')); expect(groupOf('WL-P1')).toBeLessThan(groupOf('WL-idea')); expect(groupOf('WL-idea')).toBeLessThan(groupOf('WL-other')); @@ -83,6 +86,34 @@ describe('grouping.ts — duplicated algorithm mirrors core spec', () => { const sorted = items.slice().sort(compareGroupableItems); expect(sorted.map(i => i.id)).toEqual(['P-high', 'P-med', 'I-high', 'I-low']); }); + + it('never places canonical stages in "Other" (in_progress joins Group N)', () => { + const items: GroupableItem[] = [ + { id: 'WL-idea', stage: 'idea', filePaths: ['src/idea.ts'], priority: 'medium' }, + { id: 'WL-intake', stage: 'intake_complete', filePaths: ['src/intake.ts'], priority: 'medium' }, + { id: 'WL-plan', stage: 'plan_complete', filePaths: ['src/plan.ts'], priority: 'medium' }, + { id: 'WL-progress', stage: 'in_progress', filePaths: ['src/progress.ts'], priority: 'medium' }, + { id: 'WL-review', stage: 'in_review', filePaths: ['src/review.ts'], priority: 'medium' }, + ]; + const groups = assignItemGroups(items, 3); + for (const [, assignment] of groups) { + expect(assignment.groupLabel).not.toBe('Other'); + } + // Unknown/custom stages still fall back to "Other" as the safety net. + const unknown = assignItemGroups([{ id: 'WL-x', stage: 'custom', filePaths: [], priority: 'medium' }], 3); + expect(unknown.get('WL-x')!.groupLabel).toBe('Other'); + }); + + it('sorts in_progress items first within a group (stage sub-order)', () => { + const items: GroupableItem[] = [ + { id: 'I-low', stage: 'intake_complete', filePaths: [], priority: 'low' }, + { id: 'IP-high', stage: 'in_progress', filePaths: [], priority: 'high' }, + { id: 'P-med', stage: 'plan_complete', filePaths: [], priority: 'medium' }, + { id: 'IP-low', stage: 'in_progress', filePaths: [], priority: 'low' }, + ]; + const sorted = items.slice().sort(compareGroupableItems); + expect(sorted.map(i => i.id)).toEqual(['IP-high', 'IP-low', 'P-med', 'I-low']); + }); }); describe('regroupWorkItems — merged-list regression (WL-0MSAK8YLB0025EGW)', () => { @@ -122,7 +153,7 @@ describe('regroupWorkItems — merged-list regression (WL-0MSAK8YLB0025EGW)', () // "In Review" group label, producing a second In Review section. makeItem('WL-NEXT-PLAN', { stage: 'plan_complete', priority: 'high', group: 2, groupLabel: 'Group 1' }), makeItem('WL-NEXT-REVIEW', { stage: 'in_review', priority: 'medium', status: 'in-progress', group: 5, groupLabel: 'In Review' }), - makeItem('WL-NEXT-OTHER', { stage: 'in_progress', priority: 'medium', group: 3, groupLabel: 'Other' }), + makeItem('WL-NEXT-OTHER', { stage: 'custom', priority: 'medium', group: 3, groupLabel: 'Other' }), // Mandatory wl list subsets (no group metadata). makeItem('WL-LIST-CRIT', { stage: 'plan_complete', priority: 'critical' }), makeItem('WL-LIST-REV', { stage: 'in_review', priority: 'medium', status: 'completed' }), diff --git a/packages/herdr/src/grouping.ts b/packages/herdr/src/grouping.ts index d6f05f735..411808f91 100644 --- a/packages/herdr/src/grouping.ts +++ b/packages/herdr/src/grouping.ts @@ -5,10 +5,11 @@ * algorithm (src/commands/grouping.ts) for the Herdr plugin, which has zero * npm dependencies and cannot import from src/commands/. * - * The functions here are intentionally duplicated per TUI (decision Q2c in - * WL-0MS8W5LTW006YZ4B): a copy lives in this plugin and another in the Pi - * TUI Worklog extension (packages/tui/extensions/Worklog/lib/grouping.ts). - * Keep the two copies and the core implementation in sync. + * The functions here are intentionally duplicated per plugin (decision Q2c in + * WL-0MS8W5LTW006YZ4B): this plugin copy and the core `wl next` implementation + * must be kept in sync. (A third copy in the Pi TUI Worklog extension, + * packages/tui/extensions/Worklog/lib/grouping.ts, was removed by + * WL-0MSGI7PV9004UD0N.) * * Why regrouping is needed: the worklist merges `wl next` results (which * carry `group`/`groupLabel` assignments) with mandatory subsets fetched via @@ -192,10 +193,11 @@ export function groupItemsByFilePaths( * Group display order (most actionable first): * - **Critical Group N** — critical items partitioned by file-path conflicts * (per-category label counter, no single all-inclusive "Critical" group). - * - **Group N** — non-critical `plan_complete` + `intake_complete` items - * partitioned by file-path conflicts (no stage prefix in the label). + * - **Group N** — non-critical `in_progress` + `plan_complete` + `intake_complete` + * items partitioned by file-path conflicts (no stage prefix in the label). * - **Idea** — single group for all `idea` items. - * - **Other** — single group for all remaining non-critical items. + * - **Other** — single group for all remaining non-critical items (safety net + * for unknown/custom stages, `done`, etc.). * - **In Review** — single group for all `in_review` items, placed last. * * Mirrors `assignItemGroups` in src/commands/grouping.ts. @@ -233,15 +235,17 @@ export function assignItemGroups( nextGroup += count; } - // 1. Group N (plan_complete + intake_complete). - const planIntakeItems = items.filter( - item => item.priority !== 'critical' && (item.stage === 'plan_complete' || item.stage === 'intake_complete'), + // 1. Group N (in_progress + plan_complete + intake_complete). + const groupNItems = items.filter( + item => + item.priority !== 'critical' && + (item.stage === 'in_progress' || item.stage === 'plan_complete' || item.stage === 'intake_complete'), ); - if (planIntakeItems.length > 0) { - const planIntakeGroups = groupItemsByFilePaths(planIntakeItems, maxFilePathGroups); - const { map: groupNumMap, count } = remapToSequential(planIntakeGroups, nextGroup); - const labelNumByFileGroup = buildLabelCounter(planIntakeGroups); - for (const [id, g] of planIntakeGroups) { + if (groupNItems.length > 0) { + const groupNGroups = groupItemsByFilePaths(groupNItems, maxFilePathGroups); + const { map: groupNumMap, count } = remapToSequential(groupNGroups, nextGroup); + const labelNumByFileGroup = buildLabelCounter(groupNGroups); + for (const [id, g] of groupNGroups) { const groupNum = groupNumMap.get(g)!; result.set(id, { group: groupNum, groupLabel: `Group ${labelNumByFileGroup.get(g)}` }); } @@ -262,6 +266,7 @@ export function assignItemGroups( item => item.priority !== 'critical' && item.stage !== 'in_review' && + item.stage !== 'in_progress' && item.stage !== 'plan_complete' && item.stage !== 'intake_complete' && item.stage !== 'idea', @@ -299,14 +304,16 @@ function buildLabelCounter(fileGroups: Map): Map // ── Within-group ordering ───────────────────────────────────────────── /** - * Stage sub-order within a group: plan_complete first, then intake_complete, - * then all remaining stages. No headings are rendered between sub-groups. + * Stage sub-order within a group: in_progress first (actively being worked), + * then plan_complete, then intake_complete, then all remaining stages. + * No headings are rendered between sub-groups. */ const WITHIN_GROUP_STAGE_ORDER: Record = { - plan_complete: 0, - intake_complete: 1, + in_progress: 0, + plan_complete: 1, + intake_complete: 2, }; -const REMAINING_STAGE_ORDER = 2; +const REMAINING_STAGE_ORDER = 3; /** * Priority order for within-group sorting: high → medium → low. @@ -321,8 +328,9 @@ const DEFAULT_PRIORITY_ORDER = WITHIN_GROUP_PRIORITY_ORDER.medium; /** * Compare two items for within-group display order: - * stage sub-sort (plan_complete → intake_complete → remaining stages), then - * priority (high → medium → low), then id as a deterministic tie-break. + * stage sub-sort (in_progress → plan_complete → intake_complete → remaining + * stages), then priority (high → medium → low), then id as a deterministic + * tie-break. */ export function compareGroupableItems(a: GroupableItem, b: GroupableItem): number { const stageA = WITHIN_GROUP_STAGE_ORDER[a.stage ?? ''] ?? REMAINING_STAGE_ORDER; diff --git a/packages/herdr/src/icons.test.ts b/packages/herdr/src/icons.test.ts new file mode 100644 index 000000000..4b7d9dc05 --- /dev/null +++ b/packages/herdr/src/icons.test.ts @@ -0,0 +1,129 @@ +/** + * packages/herdr/src/icons.test.ts — Icon tests for the agent-status icon + * and the fixed-width agent slot in the icon prefix (WL-0MSBQUJQX005RAT9). + * + * Alignment invariance (AC3): the agent icon occupies a fixed-width reserved + * slot at the start of the icon prefix, so rows with and without an agent + * keep the status/stage/review icons and the item-ID column at identical + * columns. + * + * Run: npx vitest run packages/herdr/src/icons.test.ts + */ + +import { describe, it, expect } from 'vitest'; +import { + agentStatusIcon, + getIconPrefix, + iconsEnabled, + stringDisplayWidth, +} from './icons.js'; +import { formatItemLine } from './worklist.js'; +import type { WorkItem } from './fetcher.js'; + +describe('iconsEnabled — noIcons flag (WL-0MSBV4RYO008JL70)', () => { + it('returns true by default', () => { + expect(iconsEnabled()).toBe(true); + }); + + it('returns false when noIcons is true', () => { + expect(iconsEnabled({ noIcons: true })).toBe(false); + }); + + it('returns true when noIcons is false', () => { + expect(iconsEnabled({ noIcons: false })).toBe(true); + }); +}); + +describe('agentStatusIcon', () => { + it('maps working → 🟢 (green circle)', () => { + expect(agentStatusIcon('working')).toBe('\u{1F7E2}'); + }); + + it('maps blocked → ⛔ (no-entry sign)', () => { + expect(agentStatusIcon('blocked')).toBe('\u{26D4}'); + }); + + it('maps idle → ⚪ (white circle)', () => { + expect(agentStatusIcon('idle')).toBe('\u{26AA}'); + }); + + it('renders no icon for done, unknown, or missing states', () => { + expect(agentStatusIcon('done')).toBe(''); + expect(agentStatusIcon('unknown')).toBe(''); + expect(agentStatusIcon(undefined)).toBe(''); + expect(agentStatusIcon('')).toBe(''); + }); + + it('is case-insensitive', () => { + expect(agentStatusIcon('WORKING')).toBe('\u{1F7E2}'); + expect(agentStatusIcon('Blocked')).toBe('\u{26D4}'); + }); + + it('uses [TEXT] fallbacks when noIcons is set', () => { + expect(agentStatusIcon('working', { noIcons: true })).toBe('[WORK]'); + expect(agentStatusIcon('blocked', { noIcons: true })).toBe('[BLKD]'); + expect(agentStatusIcon('idle', { noIcons: true })).toBe('[IDLE]'); + expect(agentStatusIcon('done', { noIcons: true })).toBe(''); + }); +}); + +describe('getIconPrefix — fixed-width agent slot (AC3 alignment invariance)', () => { + const base = { status: 'open', stage: 'idea' } as const; + const withAgent = { status: 'open', stage: 'idea', agentState: 'working' } as const; + const withBlockedAgent = { status: 'open', stage: 'idea', agentState: 'blocked' } as const; + + it('keeps the total prefix at the fixed width with and without an agent', () => { + expect(stringDisplayWidth(getIconPrefix(base))).toBe(12); + expect(stringDisplayWidth(getIconPrefix(withAgent))).toBe(12); + }); + + it('keeps the remaining icons at identical columns (no column shift)', () => { + const noAgent = getIconPrefix(base); + const yesAgent = getIconPrefix(withAgent); + // Both prefixes are the same display width; the status icon (display + // column 3-4) must start at the same display column in both. + expect(stringDisplayWidth(noAgent)).toBe(stringDisplayWidth(yesAgent)); + // Display column where the status icon (🔓) begins: + const statusCol = (s: string): number => stringDisplayWidth(s.slice(0, s.indexOf('\u{1F513}'))); + expect(statusCol(noAgent)).toBe(statusCol(yesAgent)); + }); + + it('places the agent icon in the reserved leading slot', () => { + const prefix = getIconPrefix(withAgent); + // The green circle is the first 2-cell glyph. + expect(prefix.startsWith('\u{1F7E2}')).toBe(true); + // Rows without an agent reserve the slot with two spaces. + expect(getIconPrefix(base).startsWith(' ')).toBe(true); + }); + + it('renders different agent states without shifting the status icon', () => { + const working = getIconPrefix(withAgent); + const blocked = getIconPrefix(withBlockedAgent); + // Slot is fixed-width: both have the same display width, and the status + // icon (🔓) appears at the same DISPLAY column (cells 3-4) in both. + expect(stringDisplayWidth(working)).toBe(stringDisplayWidth(blocked)); + const statusCol = (s: string): number => stringDisplayWidth(s.slice(0, s.indexOf('\u{1F513}'))); // 🔓 + expect(statusCol(working)).toBe(statusCol(blocked)); + }); + + it('keeps the item-ID column identical in full row rendering', () => { + const makeItem = (agentState?: string): WorkItem => ({ + id: 'WL-0MSBQUJQX005RAT9', + title: 'Some task', + status: 'open', + stage: 'idea', + agentState: agentState as WorkItem['agentState'], + }); + const withRow = formatItemLine(makeItem('working'), 120); + const withoutRow = formatItemLine(makeItem(undefined), 120); + // Compare DISPLAY columns (emoji are surrogate pairs, so JS indexOf + // code-unit positions differ even though terminal columns do not). + const idCol = (line: string): number => + stringDisplayWidth(line.slice(0, line.indexOf('WL-0MSBQUJQX005RAT9'))); + expect(idCol(withRow)).toBe(idCol(withoutRow)); + // And both place the ID at the same display column a row with a blocked + // agent does. + const blockedRow = formatItemLine(makeItem('blocked'), 120); + expect(idCol(withRow)).toBe(idCol(blockedRow)); + }); +}); diff --git a/packages/herdr/src/icons.ts b/packages/herdr/src/icons.ts index 51ca843f3..bbcbcd8c5 100644 --- a/packages/herdr/src/icons.ts +++ b/packages/herdr/src/icons.ts @@ -92,6 +92,28 @@ const AUDIT_STALE_FAILED = '\u{26A0}'; // ⚠️ const NEEDS_REVIEW_ICON = '\u{274C}'; // ❌ const REVIEW_DONE_ICON = '\u{2705}'; // ✅ +// Agent-status icons (WL-0MSBQUJQX005RAT9). Glyph mapping confirmed by the +// user: working → 🟢 (green circle), blocked → ⛔ (no-entry sign), idle → ⚪ +// (white circle). `done`/`unknown`/absent → no icon. +const AGENT_STATE_ICONS: Record = { + idle: '\u{26AA}', // ⚪ + working: '\u{1F7E2}', // 🟢 + blocked: '\u{26D4}', // ⛔ +}; + +const AGENT_STATE_FALLBACK: Record = { + idle: '[IDLE]', + working: '[WORK]', + blocked: '[BLKD]', +}; + +/** + * Fixed display width (in terminal cells) of the reserved agent-status + * slot at the start of the icon prefix. Rows with and without an agent keep + * the remaining icons and the item-ID column at identical columns (AC3). + */ +export const AGENT_SLOT_WIDTH = 2; + // ── Public API ───────────────────────────────────────────────────────── /** @@ -170,6 +192,21 @@ export function epicIcon(opts?: IconOptions): string { return EPIC_ICON; } +/** + * Get the icon for a tracked agent's current state. + * + * `working → 🟢`, `blocked → ⛔`, `idle → ⚪`. `done`, `unknown`, or an + * absent state render no icon (the agent finished or the pane is gone). + * Text fallbacks follow the `[TEXT]` convention for noIcons mode. + */ +export function agentStatusIcon(state: string | undefined, opts?: IconOptions): string { + const key = (state || '').toLowerCase(); + if (opts?.noIcons) { + return AGENT_STATE_FALLBACK[key] || ''; + } + return AGENT_STATE_ICONS[key] || ''; +} + /** * Get the risk icon. */ @@ -304,16 +341,24 @@ const ICON_PREFIX_WIDTH = 12; * regardless of how many icon fields are present. * * Column layout (left to right): + * 0. Agent status (fixed-width reserved slot, WL-0MSBQUJQX005RAT9) * 1. Status icon * 2. Stage icon (for in_review items, shows audit-aware icon instead) * 3. Producer review flag * 4. Optional epic icon + child count */ export function getIconPrefix( - item: { status: string; stage?: string; priority?: string; auditResult?: boolean | null; auditedAt?: string | null; needsProducerReview?: boolean; updatedAt?: string; issueType?: string; childCount?: number }, + item: { status: string; stage?: string; priority?: string; auditResult?: boolean | null; auditedAt?: string | null; needsProducerReview?: boolean; updatedAt?: string; issueType?: string; childCount?: number; agentState?: string }, opts?: IconOptions, ): string { const noIcons = opts?.noIcons ?? false; + + // Column 0: agent status — fixed-width reserved slot so rows with and + // without an agent keep the remaining icons and the item-ID column at + // identical columns (AC3, WL-0MSBQUJQX005RAT9). + const agentIcon = agentStatusIcon(item.agentState, { noIcons }); + const agentSlot = agentIcon !== '' ? agentIcon : ' '.repeat(AGENT_SLOT_WIDTH); + const sIcon = statusIcon(item.status, { noIcons }); // Column 2: stage or audit-aware icon for in_review @@ -344,8 +389,10 @@ export function getIconPrefix( // Column 4: epic icon (child count is no longer shown in prefix) const epicSuffix = item.issueType === 'epic' ? epicIcon({ noIcons }) : ''; - // Build full prefix and pad to fixed width for alignment - let prefix = [coreIcons, epicSuffix].filter(Boolean).join(''); + // Build full prefix and pad to fixed width for alignment. The agent slot + // is included in the total, so rows with and without an agent still land + // at exactly ICON_PREFIX_WIDTH cells. + let prefix = [agentSlot, coreIcons, epicSuffix].filter(Boolean).join(''); const width = stringDisplayWidth(prefix); if (width < ICON_PREFIX_WIDTH) { const padCount = ICON_PREFIX_WIDTH - width; diff --git a/packages/herdr/src/index.test.ts b/packages/herdr/src/index.test.ts index 478f060f9..40b8a6041 100644 --- a/packages/herdr/src/index.test.ts +++ b/packages/herdr/src/index.test.ts @@ -6,12 +6,24 @@ */ import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; -import { stripCommandPrefix, routeCommand, stripAgentPromptPrefix, buildSendToPiArgs } from './index.js'; +import { + stripCommandPrefix, + routeCommand, + stripAgentPromptPrefix, + buildSendToPiArgs, + createDowntimeDeps, + capturePaneIdFromFile, + parsePaneIdFile, + CAPTURE_TIMEOUT_MS, +} from './index.js'; +import { DOWNTIME_LOG_FILE } from './downtime-log.js'; +import { DOWNTIME_WL_TIMEOUT_MS } from './downtime-worker.js'; import { fetchItemsByStage, resetExecFileAsync, resetWorklogDir, setExecFileAsync, + setWorklogDir, } from './fetcher.js'; // --------------------------------------------------------------------------- @@ -266,8 +278,7 @@ describe('shortcuts.json command routing', () => { describe('routeCommand', () => { - describe('agent commands', () => { - it('routes /skill: commands to the agent pane', () => { + describe('agent commands', () => { it('routes /skill: commands to the agent pane', () => { expect(routeCommand('/skill:implement ')).toBe('agent'); expect(routeCommand('/skill:audit ')).toBe('agent'); }); @@ -473,3 +484,593 @@ describe('configureWorklogTarget', () => { expect(report).toContain('Showing empty worklist. Navigate to a project with \'worklog init\' to see items.'); }); }); + +// --------------------------------------------------------------------------- +// createDowntimeDeps tests (WL-0MSF49FMW009M06K, F4 wiring) +// --------------------------------------------------------------------------- + +describe('createDowntimeDeps', () => { + afterEach(() => { + resetExecFileAsync(); + resetWorklogDir(); + }); + + it('getNextItem runs wl next --stage and parses the first workItem', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ success: true, workItem: { id: 'WL-ABC', title: 'Some task' } }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextItem('intake_complete'); + + expect(mockExec).toHaveBeenCalledWith( + 'wl', + ['next', '--stage', 'intake_complete', '--json'], + expect.anything(), + ); + expect(result).toEqual({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }); + }); + + it('getNextItem passes --worklog-dir when the tab resolved a worklog root', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ success: true, workItem: { id: 'SA-ABC', title: 'Sorra task' } }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + setWorklogDir('/home/user/projects/SorraAgents/.worklog'); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextItem('intake_complete'); + + // The global option must appear BEFORE the subcommand, exactly as the + // worklist's runWl() prepends it (WL-0MSI7DQL10016QYX). + expect(mockExec).toHaveBeenCalledWith( + 'wl', + [ + '--worklog-dir', + '/home/user/projects/SorraAgents/.worklog', + 'next', + '--stage', + 'intake_complete', + '--json', + ], + expect.anything(), + ); + expect(result).toEqual({ ok: true, candidate: { id: 'SA-ABC', title: 'Sorra task', stage: 'intake_complete' } }); + }); + + it('getNextItem reports ok:true with no candidate when wl reports no item', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ success: false, workItem: null, reason: 'none' }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + expect(await deps.getNextItem('idea')).toEqual({ ok: true, candidate: null }); + }); + + it('getNextItem fails closed ({ok:false}) when wl errors', async () => { + setExecFileAsync(vi.fn().mockRejectedValue(new Error('wl boom')) as never); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + expect(await deps.getNextItem('idea')).toEqual({ ok: false }); + }); + + it('getNextItem passes a bounded timeout so a hung wl fails closed', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ success: true, workItem: { id: 'WL-ABC', title: 'Some task' } }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await deps.getNextItem('intake_complete'); + + // AC1: the invocation must carry the bounded timeout so a hung wl child + // is killed by execFile instead of wedging the dispatch task. + const [, , options] = mockExec.mock.calls[0]; + expect(options).toMatchObject({ timeout: DOWNTIME_WL_TIMEOUT_MS }); + }); + + it('getNextItem fails closed within the timeout when wl hangs (AC4 hang path)', async () => { + vi.useFakeTimers(); + try { + // A hung wl child: the mock never resolves on its own — the bounded + // timeout (enforced by execFile in production) rejects the invocation + // after DOWNTIME_WL_TIMEOUT_MS, so the lookup fails closed to busy. + const hungExec = vi.fn( + (_bin: string, _args: string[], opts?: { timeout?: number }) => + new Promise((_resolve, reject) => { + setTimeout( + () => reject(Object.assign(new Error('ETIMEDOUT'), { code: 'ETIMEDOUT' })), + opts?.timeout ?? DOWNTIME_WL_TIMEOUT_MS, + ); + }), + ); + setExecFileAsync(hungExec as never); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + + const resultPromise = deps.getNextItem('idea'); + await vi.advanceTimersByTimeAsync(DOWNTIME_WL_TIMEOUT_MS - 1); + // Still pending just before the timeout — no premature resolution. + await vi.advanceTimersByTimeAsync(1); + await expect(resultPromise).resolves.toEqual({ ok: false }); + + // The hung invocation received the bounded timeout option. + expect(hungExec.mock.calls[0][2]).toMatchObject({ timeout: DOWNTIME_WL_TIMEOUT_MS }); + } finally { + vi.useRealTimers(); + } + }); + + it('getNextAuditCandidate runs wl list completed/in_review and selects the first stale-audit item', async () => { + // Fixture times are relative to now so the 7-day recency filter passes + // (selectAuditCandidate defaults to Date.now()). + const now = Date.now(); + const HOUR_MS = 60 * 60 * 1000; + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 2, + workItems: [ + { id: 'WL-FRESH', title: 'Fresh audit', auditedAt: new Date(now - 30 * 60 * 1000).toISOString(), updatedAt: new Date(now - HOUR_MS).toISOString(), sortIndex: 100 }, + { id: 'WL-STALE', title: 'Stale audit', auditedAt: new Date(now - 2 * HOUR_MS).toISOString(), updatedAt: new Date(now - HOUR_MS).toISOString(), sortIndex: 200 }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextAuditCandidate(); + + expect(mockExec).toHaveBeenCalledWith( + 'wl', + ['list', '--status', 'completed', '--stage', 'in_review', '--json'], + expect.anything(), + ); + expect(result).toEqual({ id: 'WL-STALE', title: 'Stale audit', stage: 'audit' }); + }); + + it('getNextAuditCandidate returns null when no stale/missing-audit item exists', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 1, + workItems: [ + { id: 'WL-FRESH', title: 'Fresh audit', auditedAt: '2026-01-01T00:00:30.000Z', updatedAt: '2026-01-01T00:00:00.000Z', sortIndex: 100 }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + expect(await deps.getNextAuditCandidate()).toBeNull(); + }); + + it('getNextAuditCandidate fails closed (null) when wl errors', async () => { + setExecFileAsync(vi.fn().mockRejectedValue(new Error('wl boom')) as never); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + expect(await deps.getNextAuditCandidate()).toBeNull(); + }); + + it('getNextAuditCandidate passes a bounded timeout so a hung wl fails closed', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ success: true, count: 0, workItems: [] }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await deps.getNextAuditCandidate(); + + // AC1: the audit-tier invocation must carry the bounded timeout too. + const [, , options] = mockExec.mock.calls[0]; + expect(options).toMatchObject({ timeout: DOWNTIME_WL_TIMEOUT_MS }); + }); + + it('getNextAuditCandidate fails closed (null) within the timeout when wl hangs', async () => { + vi.useFakeTimers(); + try { + const hungExec = vi.fn( + (_bin: string, _args: string[], opts?: { timeout?: number }) => + new Promise((_resolve, reject) => { + setTimeout( + () => reject(Object.assign(new Error('ETIMEDOUT'), { code: 'ETIMEDOUT' })), + opts?.timeout ?? DOWNTIME_WL_TIMEOUT_MS, + ); + }), + ); + setExecFileAsync(hungExec as never); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + + const resultPromise = deps.getNextAuditCandidate(); + await vi.advanceTimersByTimeAsync(DOWNTIME_WL_TIMEOUT_MS); + await expect(resultPromise).resolves.toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('getNextAuditCandidate fails closed (null) on malformed wl output', async () => { + const mockExec = vi.fn().mockResolvedValue({ stdout: 'not json', stderr: '' }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + expect(await deps.getNextAuditCandidate()).toBeNull(); + }); + + it('claimItem runs wl update --status in_progress --assignee', async () => { + const mockExec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await deps.claimItem('WL-ABC'); + + expect(mockExec).toHaveBeenCalledWith( + 'wl', + ['update', 'WL-ABC', '--status', 'in_progress', '--assignee', 'Map', '--json'], + expect.anything(), + ); + }); + + it('spawnAgentPane spawns send-to-pi.sh with the derived pane name and args', async () => { + const spawnFn = vi.fn(() => ({ unref: vi.fn() })); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map', spawnFn); + + await deps.spawnAgentPane('Run /skill:plan WL-ABC — Some task.', { + model: 'plan', + cwd: '/repo', + }); + + expect(spawnFn).toHaveBeenCalledWith( + '/path/to/send-to-pi.sh', + [ + '--pane-name', + 'Downtime plan', + '--no-focus', + '--cwd', + '/repo', + '--model', + 'plan', + 'Run /skill:plan WL-ABC — Some task.', + ], + { cwd: '/repo' }, + ); + const handle = (spawnFn as ReturnType).mock.results[0].value; + expect(handle.unref).toHaveBeenCalled(); + }); + + it('spawnAgentPane derives the intake pane name from the prompt', async () => { + const spawnFn = vi.fn(() => ({ unref: vi.fn() })); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map', spawnFn); + + await deps.spawnAgentPane('Run /skill:intake WL-DEF — An idea.', { + model: 'plan', + cwd: '/repo', + }); + + expect(spawnFn).toHaveBeenCalledWith( + '/path/to/send-to-pi.sh', + expect.arrayContaining(['--pane-name', 'Downtime intake']), + expect.anything(), + ); + }); + + it('spawnAgentPane derives the audit pane name from the prompt', async () => { + const spawnFn = vi.fn(() => ({ unref: vi.fn() })); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map', spawnFn); + + await deps.spawnAgentPane('Run /skill:audit WL-AUD — Audit me.', { + model: 'plan', + cwd: '/repo', + }); + + expect(spawnFn).toHaveBeenCalledWith( + '/path/to/send-to-pi.sh', + expect.arrayContaining(['--pane-name', 'Downtime audit']), + expect.anything(), + ); + }); +}); + +describe('createDowntimeDeps recordDispatch', () => { + afterEach(() => { + resetExecFileAsync(); + resetWorklogDir(); + for (const dir of tempDirs) { + try { rmSync(dir, { recursive: true }); } catch { /* ignore */ } + } + tempDirs.length = 0; + }); + + it('adds an audit comment via wl comment add with the herdr-downtime author', async () => { + const mockExec = vi.fn().mockResolvedValue({ stdout: '{}', stderr: '' }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await deps.recordDispatch({ + itemId: 'WL-ABC', + kind: 'plan', + dispatchedAt: '2026-01-01T00:00:00.000Z', + cwd: '/repo', + }); + + expect(mockExec).toHaveBeenCalledWith( + 'wl', + [ + 'comment', + 'add', + 'WL-ABC', + '--comment', + expect.stringContaining('/skill:plan WL-ABC'), + '--author', + 'herdr-downtime', + '--json', + ], + expect.anything(), + ); + }); + + it('passes --worklog-dir to wl comment add when the tab resolved a worklog root', async () => { + const mockExec = vi.fn().mockResolvedValue({ stdout: '{}', stderr: '' }); + setExecFileAsync(mockExec as never); + setWorklogDir('/home/user/projects/SorraAgents/.worklog'); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await deps.recordDispatch({ + itemId: 'SA-ABC', + kind: 'plan', + dispatchedAt: '2026-01-01T00:00:00.000Z', + cwd: '/repo', + }); + + // The audit comment must land on the item in ITS project's DB, not the + // plugin process's cwd (WL-0MSI7DQL10016QYX). + expect(mockExec).toHaveBeenCalledWith( + 'wl', + [ + '--worklog-dir', + '/home/user/projects/SorraAgents/.worklog', + 'comment', + 'add', + 'SA-ABC', + '--comment', + expect.stringContaining('/skill:plan SA-ABC'), + '--author', + 'herdr-downtime', + '--json', + ], + expect.anything(), + ); + }); + + it('writes a JSONL entry to .worklog/downtime-dispatches.log under the cwd', async () => { + setExecFileAsync(vi.fn().mockResolvedValue({ stdout: '{}', stderr: '' }) as never); + const cwd = makeTempDir(); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await deps.recordDispatch({ + itemId: 'WL-ABC', + kind: 'intake', + dispatchedAt: '2026-01-01T00:00:00.000Z', + cwd, + }); + + const raw = readFileSync(join(cwd, '.worklog', DOWNTIME_LOG_FILE), 'utf8'); + const lines = raw.split('\n').filter((l) => l.trim() !== ''); + expect(lines).toHaveLength(1); + const entry = JSON.parse(lines[0]); + expect(entry.itemId).toBe('WL-ABC'); + expect(entry.kind).toBe('intake'); + expect(entry.dispatchedAt).toBe('2026-01-01T00:00:00.000Z'); + }); + + it('is fail-closed when wl comment add fails (no throw, dispatch unaffected)', async () => { + setExecFileAsync(vi.fn().mockRejectedValue(new Error('wl boom')) as never); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + + await expect( + deps.recordDispatch({ + itemId: 'WL-ABC', + kind: 'plan', + dispatchedAt: '2026-01-01T00:00:00.000Z', + cwd: '/repo', + }), + ).resolves.toBeUndefined(); + }); + + it('is fail-closed when the log write fails (e.g. .worklog path is a file)', async () => { + setExecFileAsync(vi.fn().mockResolvedValue({ stdout: '{}', stderr: '' }) as never); + const cwd = makeTempDir(); + writeFileSync(join(cwd, '.worklog'), 'not a directory', 'utf8'); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await expect( + deps.recordDispatch({ + itemId: 'WL-ABC', + kind: 'plan', + dispatchedAt: '2026-01-01T00:00:00.000Z', + cwd, + }), + ).resolves.toBeUndefined(); + }); +}); + +describe('createDowntimeDeps recordError', () => { + afterEach(() => { + for (const dir of tempDirs) { + try { rmSync(dir, { recursive: true }); } catch { /* ignore */ } + } + tempDirs.length = 0; + }); + + it('writes a JSONL entry to the rolling downtime log under the cwd', async () => { + const cwd = makeTempDir(); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await deps.recordError({ + cwd, + at: '2026-01-01T00:00:00.000Z', + message: 'Downtime worker: 3 consecutive wl CLI errors', + }); + + const raw = readFileSync(join(cwd, '.worklog', DOWNTIME_LOG_FILE), 'utf8'); + const lines = raw.split('\n').filter((l) => l.trim() !== ''); + expect(lines).toHaveLength(1); + const entry = JSON.parse(lines[0]); + expect(entry.at).toBe('2026-01-01T00:00:00.000Z'); + expect(entry.message).toContain('3 consecutive'); + }); + + it('is fail-closed when the log write fails (e.g. .worklog path is a file)', async () => { + const cwd = makeTempDir(); + writeFileSync(join(cwd, '.worklog'), 'not a directory', 'utf8'); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await expect( + deps.recordError({ cwd, at: '2026-01-01T00:00:00.000Z', message: 'boom' }), + ).resolves.toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Agent-pane association capture (WL-0MSBQUJQX005RAT9) +// +// AC1: when an agent command carrying a work-item ID is dispatched, the +// plugin captures the new pane ID (via --pane-id-file) and records the +// work-item ↔ pane association. AC6: only worklist-spawned agent commands +// are tracked; commands without an ID are not. +// --------------------------------------------------------------------------- + +describe('buildSendToPiArgs — pane-id capture flag', () => { + it('forwards --pane-id-file when provided', () => { + expect( + buildSendToPiArgs('/skill:implement ', '/project', 'code', '/tmp/pane.json'), + ).toEqual([ + '--cwd', + '/project', + '--model', + 'code', + '--pane-id-file', + '/tmp/pane.json', + '/skill:implement ', + ]); + }); + + it('omits --pane-id-file when not provided (backward compatible)', () => { + expect(buildSendToPiArgs('/skill:implement ', '/project')).toEqual([ + '--cwd', + '/project', + '/skill:implement ', + ]); + }); +}); + +describe('parsePaneIdFile', () => { + it('parses the pane_id written by send-to-pi.sh', () => { + expect(parsePaneIdFile('{"pane_id":"grid-pane-5"}')).toBe('grid-pane-5'); + expect(parsePaneIdFile('{"pane_id": "plain-pane-1"}')).toBe('plain-pane-1'); + }); + + it('tolerates log lines prefixed before the JSON', () => { + expect(parsePaneIdFile('some noise\n{"pane_id":"p1"}')).toBe('p1'); + }); + + it('returns undefined for unparseable content', () => { + expect(parsePaneIdFile('')).toBeUndefined(); + expect(parsePaneIdFile('not json')).toBeUndefined(); + expect(parsePaneIdFile('{"no_pane":1}')).toBeUndefined(); + }); +}); + +describe('capturePaneIdFromFile — dispatch-time association capture', () => { + it('polls for the pane-id file, records the association, and cleans up', async () => { + const record = vi.fn().mockResolvedValue(undefined); + const unlink = vi.fn(); + let fileExists = false; + let fileContent = ''; + + const promise = capturePaneIdFromFile('WL-ABC', '/tmp/pane.json', record, { + existsSync: () => fileExists, + readFile: () => fileContent, + unlink, + sleep: async () => { + // Simulate the pane-id file appearing after the first poll. + fileExists = true; + fileContent = '{"pane_id":"grid-pane-5"}'; + }, + now: () => 0, + }); + + const paneId = await promise; + expect(paneId).toBe('grid-pane-5'); + expect(record).toHaveBeenCalledWith('WL-ABC', 'grid-pane-5'); + expect(unlink).toHaveBeenCalledWith('/tmp/pane.json'); + }); + + it('records nothing when the file never appears (split failed)', async () => { + const record = vi.fn(); + let t = 0; + const paneId = await capturePaneIdFromFile('WL-ABC', '/tmp/pane.json', record, { + existsSync: () => false, + readFile: () => '', + // Advance the clock past the deadline so the loop terminates (a + // constant `now` would make the timeout unreachable and spin forever). + sleep: async () => { t += CAPTURE_TIMEOUT_MS; }, + now: () => t, + }); + expect(paneId).toBeUndefined(); + expect(record).not.toHaveBeenCalled(); + }); + + it('is fail-open: a recording error never throws', async () => { + const record = vi.fn().mockRejectedValue(new Error('tracker boom')); + const unlink = vi.fn(); + const paneId = await capturePaneIdFromFile('WL-ABC', '/tmp/pane.json', record, { + existsSync: () => true, + readFile: () => '{"pane_id":"p1"}', + unlink, + sleep: async () => {}, + now: () => 0, + }); + expect(paneId).toBe('p1'); + expect(record).toHaveBeenCalledWith('WL-ABC', 'p1'); + expect(unlink).toHaveBeenCalledWith('/tmp/pane.json'); + }); + + it('keeps polling while the file is unreadable or mid-write', async () => { + const record = vi.fn(); + let readable = false; + const paneId = await capturePaneIdFromFile('WL-ABC', '/tmp/pane.json', record, { + existsSync: () => true, + readFile: () => { + if (!readable) throw new Error('mid-write'); + return '{"pane_id":"p1"}'; + }, + sleep: async () => { readable = true; }, + now: () => 0, + }); + expect(paneId).toBe('p1'); + expect(record).toHaveBeenCalledWith('WL-ABC', 'p1'); + }); + + it('gives up once the timeout is reached', async () => { + const record = vi.fn(); + let t = 0; + const paneId = await capturePaneIdFromFile('WL-ABC', '/tmp/pane.json', record, { + existsSync: () => true, + readFile: () => 'not json', + sleep: async () => { t += 200; }, + now: () => t, + }); + expect(paneId).toBeUndefined(); + expect(record).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/herdr/src/index.ts b/packages/herdr/src/index.ts index 1e3701993..93a01d649 100644 --- a/packages/herdr/src/index.ts +++ b/packages/herdr/src/index.ts @@ -21,11 +21,45 @@ import { spawn } from 'child_process'; import { fileURLToPath } from 'url'; import { dirname, resolve, join } from 'path'; +import { tmpdir } from 'node:os'; +import { existsSync, readFileSync, unlinkSync } from 'node:fs'; import { resolveWorklogRoot } from '@worklog/shared/worklog-paths'; -import { checkWlAvailable, fetchNextItems, fetchItemsByStage, setWorklogDir, claimWorkItem } from './fetcher.js'; +import { + checkWlAvailable, + fetchNextItems, + fetchItemsByStage, + setWorklogDir, + claimWorkItem, + getExecFileAsync, + buildWlArgs, +} from './fetcher.js'; +import { AgentTracker, AGENT_PANES_FILE, mergeAgentStates } from './agent-tracker.js'; import { runWorklistTui, getTermSize } from './worklist.js'; import { loadShortcutConfig } from './shortcut-config.js'; import { loadSettings, getDefaultSettingsPath, clampBrowseItemCount, defaultSettings } from './settings.js'; +import { + createDowntimeWorker, + createDowntimePoller, + buildDowntimePaneArgs, + spawnDowntimePane, + parseNextItemOutput, + parseAuditCandidatesOutput, + selectAuditCandidate, + toDowntimeCandidate, + skillKindFromPrompt, + type DowntimeWorker, + type DowntimeWorkerDeps, + type DowntimeStage, + type DowntimeCandidate, + type DowntimeDispatchEvent, + type DowntimeErrorEvent, + type DowntimeNextResult, + type DowntimeSpawn, + defaultDowntimeSpawn, + buildDowntimeDispatchComment, + DOWNTIME_WL_TIMEOUT_MS, +} from './downtime-worker.js'; +import { appendDowntimeLogEntry } from './downtime-log.js'; // Resolve path to the send-to-pi.sh script (relative to this source file) // At runtime (tsx or dist), __dirname equivalent from import.meta.url @@ -106,17 +140,123 @@ export function stripAgentPromptPrefix(command: string): string { * '/skill:implement '`). Free-form `/prompt:` commands have their routing * prefix stripped here so pi receives only the prompt text. Commands without * a model get no `--model` flag. + * + * When `paneIdFile` is provided (agent commands carrying a work-item ID, + * WL-0MSBQUJQX005RAT9), `--pane-id-file ` is forwarded so the script + * writes the new pane ID immediately after the split succeeds — the plugin + * reads it back to record the work-item ↔ pane association. */ -export function buildSendToPiArgs(command: string, targetCwd: string, model?: string): string[] { +export function buildSendToPiArgs( + command: string, + targetCwd: string, + model?: string, + paneIdFile?: string, +): string[] { const agentPrompt = stripAgentPromptPrefix(command); const args = ['--cwd', targetCwd]; if (model) { args.push('--model', model); } + if (paneIdFile) { + args.push('--pane-id-file', paneIdFile); + } args.push(agentPrompt); return args; } +/** + * Parse the pane-ID file written by send-to-pi.sh (`--pane-id-file`). + * + * The script writes `{"pane_id": ""}` immediately after the pane split + * succeeds. Tolerates log lines prefixed before the JSON envelope. + * Returns undefined when the file is absent, unparseable, or has no pane id. + */ +export function parsePaneIdFile(raw: string): string | undefined { + const start = raw.indexOf('{'); + if (start < 0) return undefined; + try { + const obj = JSON.parse(raw.slice(start)) as Record; + const paneId = obj?.pane_id ?? obj?.paneId; + return typeof paneId === 'string' && paneId !== '' ? paneId : undefined; + } catch { + return undefined; + } +} + +// ── Pane-ID capture (WL-0MSBQUJQX005RAT9) ───────────────────────────── + +/** Poll interval for the pane-ID file capture loop. */ +export const CAPTURE_POLL_INTERVAL_MS = 200; +/** Total time budget for the pane-ID file capture loop. */ +export const CAPTURE_TIMEOUT_MS = 5000; + +/** + * Injectable filesystem/timer dependencies for {@link capturePaneIdFromFile} + * (tests replace these so no real polling or temp files are needed). + */ +export interface PaneIdFileDeps { + existsSync?: (p: string) => boolean; + readFile?: (p: string) => string; + unlink?: (p: string) => void; + sleep?: (ms: number) => Promise; + now?: () => number; +} + +/** + * Poll for the pane-ID file written by send-to-pi.sh, record the + * work-item ↔ pane association, and clean up the temp file. + * + * Fire-and-forget (never awaited by the TUI): the loop polls briefly for + * the file so pane-ID capture never blocks the TUI event loop or changes + * spawn behavior. A missing file (split failed) is a no-op — no entry is + * recorded. All failures are swallowed (fail-open). + * + * @param workItemId - Work item the agent command was dispatched for. + * @param paneIdFile - Path passed to send-to-pi.sh via `--pane-id-file`. + * @param record - Records the association (typically + * `tracker.recordAgentForWorkItem`). + * @param deps - Injectable fs/timer dependencies (tests). + * @returns The captured pane ID, or undefined on timeout. + */ +export async function capturePaneIdFromFile( + workItemId: string, + paneIdFile: string, + record: (workItemId: string, paneId: string) => void | Promise, + deps: PaneIdFileDeps = {}, +): Promise { + const existsSyncFn = deps.existsSync ?? ((p: string) => existsSync(p)); + const readFileFn = deps.readFile ?? ((p: string) => readFileSync(p, 'utf8')); + const unlinkFn = deps.unlink ?? ((p: string) => { try { unlinkSync(p); } catch { /* ignore */ } }); + const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const now = deps.now ?? (() => Date.now()); + const deadline = now() + CAPTURE_TIMEOUT_MS; + + while (now() < deadline) { + if (existsSyncFn(paneIdFile)) { + let raw: string | null = null; + try { + raw = readFileFn(paneIdFile); + } catch { + raw = null; // file mid-write — keep polling + } + const paneId = raw !== null ? parsePaneIdFile(raw) : undefined; + if (paneId) { + try { + await record(workItemId, paneId); + } catch { + // Recording must never break dispatch; the temp file is still + // cleaned up below. + } + unlinkFn(paneIdFile); + return paneId; + } + } + await sleep(CAPTURE_POLL_INTERVAL_MS); + } + // Timed out — the split may have failed or the file never appeared. + return undefined; +} + /** * Check if a command is an agent command that should be sent to a pi pane. * Agent commands are those starting with /skill:, /intake, /plan, or /prompt:. @@ -183,6 +323,117 @@ export async function claimItemForAgentCommand(command: string): Promise { } } +/** + * Build the real downtime-worker dependencies (WL-0MSF49FMW009M06K): + * `wl next --stage --json` for dispatch selection, `wl update + * --status in_progress` for the pre-dispatch claim, and + * `send-to-pi.sh` for the visible (non-focus-stealing) agent pane. Every + * boundary is fail-closed: a wl failure yields no candidate (no dispatch) + * rather than an exception. + */ +export function createDowntimeDeps( + scriptPath: string, + assignee: string, + spawnFn: DowntimeSpawn = defaultDowntimeSpawn, +): DowntimeWorkerDeps { + return { + async getNextItem(stage: DowntimeStage): Promise { + try { + // buildWlArgs() prepends the tab's resolved --worklog-dir override + // (WL-0MSI7DQL10016QYX): the downtime worker must select candidates + // from the SAME worklog root the worklist uses, not the plugin + // process's own cwd. Without the override the vector is unchanged. + // The bounded timeout (WL-0MSJIPHD0001L1J9) kills a hung wl child + // so the lookup fails closed to a strike instead of wedging the + // dispatch task until the pane restarts. + const { stdout } = await getExecFileAsync()( + 'wl', + buildWlArgs(['next', '--stage', stage, '--json']), + { encoding: 'utf8', timeout: DOWNTIME_WL_TIMEOUT_MS }, + ); + return { ok: true, candidate: parseNextItemOutput(stdout, stage) }; + } catch { + // Transient wl failure → fail closed to busy: no dispatch, and the + // worker must NOT treat it as an empty backlog (no cooldown). + return { ok: false }; + } + }, + async getNextAuditCandidate(): Promise { + try { + // Audit tier (WL-0MSI8H3HP000K0RG): select the first completed / + // in_review item WITHOUT a valid audit so the producer-review queue + // (the release gate) is drained during idle time. Same fail-closed + // semantics as getNextItem: a wl failure yields no candidate. The + // bounded timeout (WL-0MSJIPHD0001L1J9) applies here too. + const { stdout } = await getExecFileAsync()( + 'wl', + buildWlArgs(['list', '--status', 'completed', '--stage', 'in_review', '--json']), + { encoding: 'utf8', timeout: DOWNTIME_WL_TIMEOUT_MS }, + ); + const candidates = parseAuditCandidatesOutput(stdout); + const selected = candidates === null ? null : selectAuditCandidate(candidates); + return selected === null ? null : toDowntimeCandidate(selected); + } catch { + // Fail-closed: a wl failure yields no candidate (no dispatch). + return null; + } + }, + async claimItem(itemId: string): Promise { + // claimWorkItem never throws (failures are returned and logged). + await claimWorkItem(itemId, assignee); + }, + async spawnAgentPane(prompt: string, opts: { model: string; cwd: string }): Promise { + const kind = skillKindFromPrompt(prompt); + spawnDowntimePane( + scriptPath, + buildDowntimePaneArgs(kind, prompt, opts), + { cwd: opts.cwd }, + spawnFn, + ); + }, + async recordDispatch(event: DowntimeDispatchEvent): Promise { + // 1. Durable trail: a comment on the item itself (survives wl sync). + // buildWlArgs() prepends the resolved --worklog-dir override so the + // comment lands on the item in ITS project's DB, not the plugin + // process's own cwd (WL-0MSI7DQL10016QYX). + try { + await getExecFileAsync()( + 'wl', + buildWlArgs([ + 'comment', + 'add', + event.itemId, + '--comment', + buildDowntimeDispatchComment(event.itemId, event.kind, event.dispatchedAt), + '--author', + 'herdr-downtime', + '--json', + ]), + { timeout: 5000 }, + ); + } catch { + // fail-closed: audit logging must never crash the worker + } + // 2. Rolling local log (bounded JSONL under /.worklog). + try { + await appendDowntimeLogEntry(event.cwd, JSON.stringify(event)); + } catch { + // fail-closed + } + }, + async recordError(event: DowntimeErrorEvent): Promise { + // Persistent CLI-error trail (three-strike rule): rolling JSONL log + // under /.worklog — the same bounded log as dispatch audit + // entries (WL-0MSGPI4AR000YOK8). Fail-closed: never crash the worker. + try { + await appendDowntimeLogEntry(event.cwd, JSON.stringify(event)); + } catch { + // fail-closed + } + }, + }; +} + // Load settings const settings = loadSettings(); @@ -238,9 +489,6 @@ async function main(): Promise { process.exit(1); } - // Load shortcut config - const shortcutRegistry = loadShortcutConfig(); - // Use HERDR_RESOLVED_CWD when set (passed via --env from open.sh) // as the starting directory for worklog discovery. The resolved root // is passed to child `wl` processes via --worklog-dir (setWorklogDir), @@ -255,6 +503,22 @@ async function main(): Promise { process.stderr.write(uninitializedReport(resolvedCwd ?? process.cwd())); } + // Load shortcut config: bundled defaults merged with a project-local + // /shortcuts.json when present (local wins on chord+view, + // WL-0MSHUMX5C004NC4O). Loaded AFTER configureWorklogTarget so the + // resolved wlRoot (when found) is available for local override discovery; + // without a worklog root the registry is the bundled-only default. + const shortcutRegistry = loadShortcutConfig(wlRoot); + + // Agent tracker (WL-0MSBQUJQX005RAT9): records which worklist-spawned + // agent pane is attached to each work item. The state file lives in the + // project's gitignored .worklog/ directory and is shared across worklist + // panes/tabs. Fail-open: without a valid worklog root the tracker runs + // in-memory only (no persistence). + const agentTracker = new AgentTracker({ + stateFile: wlRoot ? join(wlRoot, '.worklog', AGENT_PANES_FILE) : undefined, + }); + // Create a fetcher that loads items using the current browseItemCount setting // Each call reads from settings so changes take effect on next auto-refresh // Smart selection (see fetchNextItems) guarantees all critical and @@ -269,7 +533,11 @@ async function main(): Promise { try { const currentSettings = loadSettings(); const count = clampBrowseItemCount(currentSettings.browseItemCount ?? defaultSettings.browseItemCount); - return await fetchNextItems(count); + const items = await fetchNextItems(count); + // Merge agent-status state into the fetched items (fail-open: no herdr + // CLI → no icons, list still works). Also covers the initial load. + await mergeAgentStates(items, agentTracker); + return items; } catch { return []; } @@ -284,6 +552,28 @@ async function main(): Promise { // Settings are re-read so browseItemCount (per fetch) and showHelpText // (per render) changes apply without a plugin restart. const runSettings = loadSettings(); + // Downtime worker (local-LLM idle dispatch, WL-0MSF49FMW009M06K): built + // when enabled; settings are re-read every tick via `config()` so changes + // apply without a plugin restart. The dispatch panes open in the resolved + // worklog root (--cwd). + const targetCwd = wlRoot ?? resolvedCwd ?? process.cwd(); + const downtimeWorker: DowntimeWorker | undefined = runSettings.downtimeEnabled + ? createDowntimeWorker({ + poller: createDowntimePoller(runSettings.downtimeProxyUrl), + deps: createDowntimeDeps(SEND_TO_PI_SCRIPT, AGENT_ASSIGNEE), + config: () => { + const s = loadSettings(); + return { + enabled: s.downtimeEnabled, + thresholdMs: s.downtimeIdleThresholdMs, + requiredFreeSlots: s.downtimeRequiredFreeSlots, + model: s.downtimeModel, + cwd: targetCwd, + noCandidateCooldownMs: s.downtimeNoCandidateCooldownMs, + }; + }, + }) + : undefined; const selectedItem = await runWorklistTui( fetcher, undefined, @@ -294,9 +584,19 @@ async function main(): Promise { autoSync: runSettings.autoSync, syncIntervalMs: runSettings.syncIntervalMs, showHelpText: runSettings.showHelpText, + showIcons: runSettings.showIcons, + downtimeWorker, + downtimePollIntervalMs: runSettings.downtimePollIntervalMs, // Re-read on every render so a showHelpText change applies on the next // refresh (no plugin restart needed), matching browseItemCount behavior. getShowHelpText: () => loadSettings().showHelpText ?? true, + // Re-read on every render so a showIcons change applies on the next + // refresh (no plugin restart needed), matching showHelpText behavior. + getShowIcons: () => loadSettings().showIcons ?? true, + // Merge agent-status state into freshly fetched items (top-level + + // expanded children) on every refresh cycle (WL-0MSBQUJQX005RAT9). + // Fail-open: herdr errors yield no icons; the list keeps working. + mergeAgentStates: (items) => mergeAgentStates(items, agentTracker), onCommand: async (command: string, model?: string) => { // Agent commands (/skill:*, /intake, /plan) are routed to a new pi agent // pane opened to the right. Commands prefixed with `!!`/`!` (shell-executed @@ -326,13 +626,22 @@ async function main(): Promise { } catch { // Belt-and-suspenders: a claim failure must never block the pane. } + // Agent-pane association capture (WL-0MSBQUJQX005RAT9): when the + // command carries a work-item ID, ask send-to-pi.sh to write the + // new pane ID to a temp file (--pane-id-file) right after the split + // succeeds, then record the work-item ↔ pane association + // fire-and-forget. Commands without an ID are not tracked (AC6). + const itemId = extractWorkItemId(command); + const paneIdFile = itemId + ? join(tmpdir(), `herdr-pane-${process.pid}-${Date.now()}.json`) + : undefined; // Spawn send-to-pi.sh asynchronously — detached and with stdio ignored // so the TUI loop is not blocked or affected by the script's output. // The `model` from the shortcut entry (if any) is forwarded as // `--model ` so the pi CLI opens with the right model. const child = spawn( SEND_TO_PI_SCRIPT, - buildSendToPiArgs(command, targetCwd, model), + buildSendToPiArgs(command, targetCwd, model, paneIdFile), { detached: true, stdio: 'ignore', @@ -341,6 +650,13 @@ async function main(): Promise { }, ); child.unref(); // Allow the parent to exit independently + if (itemId && paneIdFile) { + // Fire-and-forget: polling must never block the TUI loop. A + // missing file (split failed) is a no-op — no entry recorded. + void capturePaneIdFromFile(itemId, paneIdFile, (wid, pid) => + agentTracker.recordAgentForWorkItem(wid, pid), + ); + } } else if (route === 'pane') { // Strip `!!` / `!` bash history-expansion prefixes, then run the // command visibly in a new herdr pane via run-in-pane.sh. diff --git a/packages/herdr/src/scheduler.test.ts b/packages/herdr/src/scheduler.test.ts new file mode 100644 index 000000000..f0f436a83 --- /dev/null +++ b/packages/herdr/src/scheduler.test.ts @@ -0,0 +1,418 @@ +/** + * packages/herdr/src/scheduler.test.ts — Unit tests for the single-loop + * task scheduler (WL-0MSG4NMF0000YBRP). + * + * The scheduler drives ALL periodic plugin work (auto-refresh, auto-sync, + * visibility resume-poll, and future downtime-worker ticks) through one + * `setInterval`. Tests cover due-work dispatch, independent intervals, + * fire-immediately tasks, disable/enable (the mechanism behind + * pause-when-hidden), single-flight (no overlapping runs, skipped ticks are + * not coalesced), disabled 0-interval tasks, and stop(). + * + * Run: npx vitest run packages/herdr/src/scheduler.test.ts + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TaskScheduler, DEFAULT_SCHEDULER_TICK_MS } from './scheduler.js'; + +describe('TaskScheduler', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('due-work dispatch', () => { + it('fires a task when its interval elapses, then repeats at the same cadence', async () => { + const run = vi.fn(); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 30_000, run }); + scheduler.start(); + + expect(run).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(29_000); + expect(run).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1_000); // t=30s + expect(run).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(30_000); // t=60s + expect(run).toHaveBeenCalledTimes(2); + + scheduler.stop(); + }); + + it('dispatches independent tasks at their own deadlines', async () => { + const fast = vi.fn(); + const slow = vi.fn(); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'fast', intervalMs: 2_000, run: fast }); + scheduler.addTask({ id: 'slow', intervalMs: 5_000, run: slow }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(4_000); + expect(fast).toHaveBeenCalledTimes(2); // t=2s, t=4s + expect(slow).toHaveBeenCalledTimes(0); + + await vi.advanceTimersByTimeAsync(1_000); // t=5s + expect(slow).toHaveBeenCalledTimes(1); + expect(fast).toHaveBeenCalledTimes(2); // t=6s not reached + + scheduler.stop(); + }); + + it('is a no-op when start() is called multiple times', async () => { + const run = vi.fn(); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 2_000, run }); + scheduler.start(); + scheduler.start(); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(2_000); + expect(run).toHaveBeenCalledTimes(1); // not 3 + + scheduler.stop(); + }); + }); + + describe('fireImmediately', () => { + it('runs fireImmediately tasks once on start(), then at the interval', async () => { + const run = vi.fn(); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'sync', intervalMs: 60_000, run, fireImmediately: true }); + scheduler.start(); + + expect(run).toHaveBeenCalledTimes(1); // immediate first tick + + await vi.advanceTimersByTimeAsync(60_000); + expect(run).toHaveBeenCalledTimes(2); + + scheduler.stop(); + }); + + it('does not run a fireImmediately task that is disabled at start', async () => { + const run = vi.fn(); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ + id: 'poll', + intervalMs: 2_000, + run, + fireImmediately: true, + disabled: true, + }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(4_000); + expect(run).not.toHaveBeenCalled(); + + scheduler.stop(); + }); + }); + + describe('disable / enable (hidden-pane skipping mechanism)', () => { + it('skips a disabled task; enabling it arms it from the next interval', async () => { + const run = vi.fn(); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'poll', intervalMs: 2_000, run, disabled: true }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(10_000); + expect(run).not.toHaveBeenCalled(); // disabled → never fires + + scheduler.setDisabled('poll', false); + await vi.advanceTimersByTimeAsync(1_999); + expect(run).not.toHaveBeenCalled(); // armed from enable time + + await vi.advanceTimersByTimeAsync(1); + expect(run).toHaveBeenCalledTimes(1); + + // Disabling again stops it; re-enabling restarts the cadence. + scheduler.setDisabled('poll', true); + await vi.advanceTimersByTimeAsync(10_000); + expect(run).toHaveBeenCalledTimes(1); + + scheduler.stop(); + }); + + it('setDisabled on an unknown id is a safe no-op', () => { + const scheduler = new TaskScheduler(1000); + expect(() => scheduler.setDisabled('nope', true)).not.toThrow(); + expect(() => scheduler.setDisabled('nope', false)).not.toThrow(); + }); + }); + + describe('single-flight', () => { + it('skips a tick whose previous run is still pending, and does not coalesce it', async () => { + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + const run = vi.fn(() => gate); + + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 30_000, run, singleFlight: true }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(30_000); + expect(run).toHaveBeenCalledTimes(1); // first tick starts (pending) + + // Next tick while pending: skipped (not coalesced) — next run only at + // the following interval boundary after the pending run settles. + await vi.advanceTimersByTimeAsync(30_000); + expect(run).toHaveBeenCalledTimes(1); + + release(); + await vi.advanceTimersByTimeAsync(0); + expect(run).toHaveBeenCalledTimes(1); // settling does not re-fire + + await vi.advanceTimersByTimeAsync(30_000); + expect(run).toHaveBeenCalledTimes(2); + + scheduler.stop(); + }); + + it('runs overlapping ticks when singleFlight is false', async () => { + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + const run = vi.fn(() => gate); + + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 2_000, run }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(2_000); + expect(run).toHaveBeenCalledTimes(1); + + // Without singleFlight the second tick fires even though the first + // run is still pending. + await vi.advanceTimersByTimeAsync(2_000); + expect(run).toHaveBeenCalledTimes(2); + + release(); + await vi.advanceTimersByTimeAsync(0); + scheduler.stop(); + }); + }); + + describe('error resilience', () => { + it('a rejecting task does not crash the loop and still runs on later ticks', async () => { + const run = vi.fn().mockRejectedValueOnce(new Error('boom')).mockResolvedValue(undefined); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 2_000, run }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(2_000); + expect(run).toHaveBeenCalledTimes(1); // rejected, but loop survives + + await vi.advanceTimersByTimeAsync(2_000); + expect(run).toHaveBeenCalledTimes(2); // cadence resumes + + scheduler.stop(); + }); + + it('a rejecting fireImmediately task does not crash start()', async () => { + const run = vi.fn().mockRejectedValue(new Error('boom')); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 60_000, run, fireImmediately: true }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(0); + expect(run).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(60_000); + expect(run).toHaveBeenCalledTimes(2); + + scheduler.stop(); + }); + }); + + describe('disabled zero/negative intervals', () => { + it('never fires a task with intervalMs <= 0', async () => { + const run = vi.fn(); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'zero', intervalMs: 0, run }); + scheduler.addTask({ id: 'neg', intervalMs: -5, run }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(10_000); + expect(run).not.toHaveBeenCalled(); + + scheduler.stop(); + }); + }); + + describe('stop', () => { + it('stop() prevents further ticks', async () => { + const run = vi.fn(); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 2_000, run }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(2_000); + expect(run).toHaveBeenCalledTimes(1); + + scheduler.stop(); + const countAfterStop = run.mock.calls.length; + + await vi.advanceTimersByTimeAsync(10_000); + expect(run).toHaveBeenCalledTimes(countAfterStop); + }); + + it('stop() before start() is a safe no-op', () => { + const scheduler = new TaskScheduler(1000); + expect(() => scheduler.stop()).not.toThrow(); + }); + }); + + describe('process lifetime', () => { + it('unrefs its interval so the loop does not keep the process alive', () => { + const unref = vi.fn(); + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval').mockReturnValue({ + unref, + hasRef: () => false, + refresh: () => {}, + } as any); + const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval').mockImplementation(() => {}); + + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 2_000, run: vi.fn() }); + scheduler.start(); + + expect(setIntervalSpy).toHaveBeenCalled(); + expect(unref).toHaveBeenCalled(); + + scheduler.stop(); + expect(clearIntervalSpy).toHaveBeenCalled(); + + setIntervalSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + }); + }); + + it('exposes DEFAULT_SCHEDULER_TICK_MS (1s)', () => { + expect(DEFAULT_SCHEDULER_TICK_MS).toBe(1000); + }); + + describe('run timeout watchdog (runTimeoutMs)', () => { + it('abandons a hung run after runTimeoutMs and retries on the next tick', async () => { + const hung = new Promise(() => {}); // never resolves + const run = vi.fn(() => hung); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 30_000, run, singleFlight: true, runTimeoutMs: 5_000 }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(30_000); + expect(run).toHaveBeenCalledTimes(1); // first run starts and hangs + + // Watchdog fires: the run is abandoned and the in-flight flag resets, + // so the task is NOT permanently wedged (WL-0MSJIPHD0001L1J9). + await vi.advanceTimersByTimeAsync(5_000); + + // The next tick retries instead of skipping forever. + await vi.advanceTimersByTimeAsync(25_000); // t=60s + expect(run).toHaveBeenCalledTimes(2); + + scheduler.stop(); + }); + + it('settling the abandoned run late does not double-fire or disturb the cadence', async () => { + let release!: () => void; + const firstGate = new Promise((r) => { release = r; }); + const run = vi.fn() + .mockReturnValueOnce(firstGate) // first run hangs + .mockResolvedValue(undefined); // later runs settle immediately + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 30_000, run, singleFlight: true, runTimeoutMs: 5_000 }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(30_000); + expect(run).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(5_000); // watchdog abandons run 1 + await vi.advanceTimersByTimeAsync(25_000); // t=60s: next tick retries + expect(run).toHaveBeenCalledTimes(2); + + // The abandoned first run settles LATE (after run 2 completed): it + // must not trigger a spurious third invocation. + release(); + await vi.advanceTimersByTimeAsync(0); + expect(run).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(30_000); // t=90s: normal cadence + expect(run).toHaveBeenCalledTimes(3); + + scheduler.stop(); + }); + + it('does not trip on a run that settles within runTimeoutMs', async () => { + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + const run = vi.fn(() => gate); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 30_000, run, singleFlight: true, runTimeoutMs: 5_000 }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(30_000); + expect(run).toHaveBeenCalledTimes(1); + + release(); // settles well before the 5s watchdog + await vi.advanceTimersByTimeAsync(0); + + await vi.advanceTimersByTimeAsync(30_000); // normal cadence continues + expect(run).toHaveBeenCalledTimes(2); + scheduler.stop(); + }); + + it('keeps single-flight skipping during a bounded run (no overlap)', async () => { + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + const run = vi.fn(() => gate); + const scheduler = new TaskScheduler(1000); + // Watchdog window (60s) spans two 30s intervals: the tick at t=60s + // fires BEFORE the watchdog at t=90s, so the pending run is skipped. + scheduler.addTask({ id: 'a', intervalMs: 30_000, run, singleFlight: true, runTimeoutMs: 60_000 }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(30_000); + expect(run).toHaveBeenCalledTimes(1); + + // Tick while the run is pending: skipped (not coalesced), watchdog + // has not fired yet. + await vi.advanceTimersByTimeAsync(30_000); + expect(run).toHaveBeenCalledTimes(1); + + release(); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(30_000); + expect(run).toHaveBeenCalledTimes(2); + scheduler.stop(); + }); + + it('logs an abandonment to stderr for observability', async () => { + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const hung = new Promise(() => {}); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ + id: 'downtime', + intervalMs: 30_000, + run: vi.fn(() => hung), + singleFlight: true, + runTimeoutMs: 5_000, + }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(30_000); + await vi.advanceTimersByTimeAsync(5_000); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining("[worklog-plugin] Task 'downtime'"), + ); + scheduler.stop(); + } finally { + stderrSpy.mockRestore(); + } + }); + }); +}); diff --git a/packages/herdr/src/scheduler.ts b/packages/herdr/src/scheduler.ts new file mode 100644 index 000000000..1e954c5d9 --- /dev/null +++ b/packages/herdr/src/scheduler.ts @@ -0,0 +1,250 @@ +/** + * packages/herdr/src/scheduler.ts — Single-loop task scheduler + * + * Consolidates ALL periodic plugin work (auto-refresh, auto-sync, + * visibility resume-poll, and future downtime-worker ticks) into ONE + * `setInterval` loop that dispatches due work by deadline + * (WL-0MSG4NMF0000YBRP). + * + * Key design decisions: + * - One loop: a single tick at `DEFAULT_SCHEDULER_TICK_MS` walks the task + * table and runs every task whose due time has passed, so tick cadence, + * pause-when-hidden gating, and shutdown cleanup live in one place. + * - Sequential dispatch: due tasks run one at a time (each awaited) so a + * task that memoizes a shared resource (e.g. the PollGate visibility + * cache) settles before the next task reads it. + * - Deadline scheduling: each task carries its own `intervalMs` and due + * time; the base tick only needs to be at the finest granularity needed + * (1s for the 2s resume-poll cadence). + * - Per-task single-flight: a task with `singleFlight: true` whose previous + * run is still pending has its tick SKIPPED and rescheduled to the next + * interval (a skipped tick is not coalesced) — same semantics as the + * refresh/sync guards they replace. + * - Error resilience: a rejecting task never takes down the loop; its + * cadence resumes on the next interval. + * - Disable/enable: tasks can start disabled (e.g. the resume-poll only + * runs while the pane is hidden) and be toggled at runtime without + * restarting the loop — the mechanism behind pause-when-hidden. + * - Unref'd: the interval is unref'd so the loop never keeps the process + * alive on its own. + */ + +// ── Constants ───────────────────────────────────────────────────────── + +/** + * Base cadence of the scheduler loop in milliseconds. The finest period any + * registered task needs is the visibility resume-poll (2s), so a 1s tick + * dispatches every due task within 1s of its deadline. + */ +export const DEFAULT_SCHEDULER_TICK_MS = 1000; + +// ── Task contract ───────────────────────────────────────────────────── + +/** + * A unit of periodic work scheduled by the loop. + */ +export interface SchedulerTask { + /** Unique task id, used to enable/disable the task at runtime. */ + id: string; + /** Period between runs in milliseconds (must be > 0). */ + intervalMs: number; + /** The work to run when the task is due. May be async. */ + run: () => Promise | void; + /** + * Skip (and reschedule) a tick when the previous run is still pending, so + * slow tasks never overlap. Skipped ticks are not coalesced: the next run + * happens at the following interval boundary. + */ + singleFlight?: boolean; + /** Run once immediately when the scheduler starts (first tick). */ + fireImmediately?: boolean; + /** Start disabled (skipped until `setDisabled(id, false)`). */ + disabled?: boolean; + /** + * Bounded-run watchdog (WL-0MSJIPHD0001L1J9): when set, a run that does + * not settle within this many milliseconds is abandoned (the run promise + * is not cancellable, but the loop stops awaiting it) and the task's + * single-flight `inFlight` flag resets so the NEXT tick retries — a hung + * run can never permanently wedge the task. Runs that settle first clear + * the watchdog timer, so a healthy cadence is unchanged. The abandoned + * run's own guards (e.g. the downtime worker's dispatching single-flight) + * prevent overlapping work once it settles. + */ + runTimeoutMs?: number; +} + +/** Internal bookkeeping for a registered task. */ +interface TaskEntry { + task: SchedulerTask; + /** Absolute time (ms) of the next scheduled run. */ + dueAt: number; + /** True while the task's previous run is still pending. */ + inFlight: boolean; +} + +// ── Scheduler ───────────────────────────────────────────────────────── + +/** + * A single-interval task scheduler that dispatches due work by deadline. + * + * ``` + * const scheduler = new TaskScheduler(DEFAULT_SCHEDULER_TICK_MS); + * scheduler.addTask({ id: 'refresh', intervalMs: 30_000, run: doRefresh }); + * scheduler.start(); + * ... + * scheduler.stop(); + * ``` + */ +export class TaskScheduler { + private timer: ReturnType | null = null; + private readonly tasks = new Map(); + + constructor(private readonly tickMs: number) {} + + /** + * Register a periodic task. Tasks with `intervalMs <= 0` are treated as + * disabled and never fire. Adding a task while the scheduler is running + * is supported; it is armed from `now + intervalMs`. + */ + addTask(task: SchedulerTask): void { + if (task.intervalMs <= 0) { + task = { ...task, disabled: true }; + } + this.tasks.set(task.id, { + task, + dueAt: Date.now() + task.intervalMs, + inFlight: false, + }); + } + + /** + * Enable or disable a task at runtime. Disabled tasks are skipped by the + * loop; re-enabling arms the task from now, so `setDisabled(id, false)` + * behaves like starting the task's interval from scratch. + */ + setDisabled(id: string, disabled: boolean): void { + const entry = this.tasks.get(id); + if (!entry) return; + entry.task.disabled = disabled; + if (!disabled) { + // (Re)arm from now — mirrors starting a fresh interval. + entry.dueAt = Date.now() + entry.task.intervalMs; + } + } + + /** + * Start the loop. Tasks flagged `fireImmediately` run once right away + * (promise fire-and-forget), then settle into their interval cadence. + * No-op if already running. + */ + start(): void { + if (this.timer !== null) return; + const now = Date.now(); + for (const entry of this.tasks.values()) { + if (entry.task.fireImmediately && !entry.task.disabled) { + void this.runTask(entry, now); + } + } + this.timer = setInterval(() => { + void this.tick(); + }, this.tickMs); + // Don't keep the process alive just for the loop. + if (this.timer.unref) this.timer.unref(); + } + + /** + * Stop the loop and clear the interval. Safe to call when not running. + */ + stop(): void { + if (this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + } + } + + /** + * One scheduler tick: dispatch every due, enabled, non-in-flight task. + * Tasks are dispatched SEQUENTIALLY (each run is awaited before the next + * task is dispatched) so a task that memoizes a shared resource (e.g. the + * PollGate visibility cache) settles before the next task reads it — the + * same inter-timer behavior the independent intervals used to have. + */ + private readonly tick = async (): Promise => { + const now = Date.now(); + for (const entry of this.tasks.values()) { + if (entry.task.disabled) continue; + if (now < entry.dueAt) continue; + if (entry.task.singleFlight && entry.inFlight) { + // Skipped tick is not coalesced — reschedule to the next interval. + entry.dueAt = now + entry.task.intervalMs; + continue; + } + await this.runTask(entry, now); + } + }; + + /** + * Run a task: reschedule the next fire before running (so a slow run does + * not delay the cadence, matching `setInterval` semantics) and track the + * in-flight flag for single-flight tasks. Errors are swallowed so a + * failing task never takes down the loop; tasks that need to surface + * errors should catch them in their own `run` (as the worklist's + * doRefresh/doSync do). + * + * When the task carries a `runTimeoutMs` watchdog, a run that hangs past + * the bound is abandoned: the loop stops awaiting it and resets the + * in-flight flag so the next tick retries (a hung run must never + * permanently wedge the task, WL-0MSJIPHD0001L1J9). + */ + private async runTask(entry: TaskEntry, now: number): Promise { + entry.dueAt = now + entry.task.intervalMs; + entry.inFlight = true; + try { + const timeoutMs = entry.task.runTimeoutMs; + if (timeoutMs !== undefined && timeoutMs > 0) { + await runWithTimeout(entry.task.run(), timeoutMs, () => { + process.stderr.write( + `[worklog-plugin] Task '${entry.task.id}' run exceeded ${timeoutMs}ms — abandoning; next tick will retry\n`, + ); + }); + } else { + await entry.task.run(); + } + } catch { + // A failing task must never take down the loop or the plugin. + } finally { + entry.inFlight = false; + } + } +} + +/** + * Await `run` but give up after `timeoutMs` when it hangs: the caller + * proceeds (the abandoned promise is not cancellable, but its own guards — + * e.g. the downtime worker's dispatching single-flight — prevent + * overlapping work once it settles). The timer is cleared when the run + * settles first so a bounded run never keeps the process alive. + */ +function runWithTimeout( + run: Promise | void, + timeoutMs: number, + onTimeout: () => void, +): Promise { + const runPromise = Promise.resolve(run); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + onTimeout(); + resolve(); + }, timeoutMs); + runPromise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + }, + ); + }); +} diff --git a/packages/herdr/src/setWorklogDir.test.ts b/packages/herdr/src/setWorklogDir.test.ts index 905ef245c..2fd2bcd34 100644 --- a/packages/herdr/src/setWorklogDir.test.ts +++ b/packages/herdr/src/setWorklogDir.test.ts @@ -128,4 +128,20 @@ describe('setWorklogDir / runWl with --worklog-dir', () => { const callArgs = mockFn.mock.calls[0][1] as string[]; expect(callArgs).not.toContain('--worklog-dir'); }); + + it('filters fetchItemsByStage to open root items in the stage (--status open)', async () => { + const mockFn = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ workItems: [] }), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + await fetchItemsByStage('in_progress'); + + const callArgs = mockFn.mock.calls[0][1] as string[]; + expect(callArgs).toContain('list'); + expect(callArgs[callArgs.indexOf('--status') + 1]).toBe('open'); + expect(callArgs[callArgs.indexOf('--stage') + 1]).toBe('in_progress'); + expect(callArgs).toContain('--root-only'); + }); }); diff --git a/packages/herdr/src/settings.test.ts b/packages/herdr/src/settings.test.ts new file mode 100644 index 000000000..d8e0c5071 --- /dev/null +++ b/packages/herdr/src/settings.test.ts @@ -0,0 +1,117 @@ +/** + * Tests for the herdr settings system — default browseItemCount and + * load/merge/clamp behavior. + * + * Run: npx vitest run packages/herdr/src/settings.test.ts + */ + +import { describe, it, expect } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + defaultSettings, + loadSettings, + saveSettings, + clampBrowseItemCount, + MIN_BROWSE_ITEM_COUNT, + MAX_BROWSE_ITEM_COUNT, +} from './settings.js'; + +function tempSettingsPath(): string { + const dir = mkdtempSync(join(tmpdir(), 'herdr-settings-test-')); + return join(dir, 'worklog-plugin.json'); +} + +describe('defaultSettings', () => { + it('browseItemCount defaults to 20', () => { + expect(defaultSettings.browseItemCount).toBe(20); + }); +}); + +describe('loadSettings', () => { + it('returns defaults (browseItemCount 20) when no settings file exists', () => { + const path = tempSettingsPath(); + const settings = loadSettings(path); + expect(settings.browseItemCount).toBe(20); + expect(existsSync(path)).toBe(false); + }); + + it('a persisted browseItemCount overrides the default', () => { + const path = tempSettingsPath(); + saveSettings(path, { ...defaultSettings, browseItemCount: 30 }); + const settings = loadSettings(path); + expect(settings.browseItemCount).toBe(30); + }); + + it('clamps an out-of-range persisted browseItemCount into [1, 50]', () => { + const path = tempSettingsPath(); + saveSettings(path, { ...defaultSettings, browseItemCount: 999 }); + expect(loadSettings(path).browseItemCount).toBe(MAX_BROWSE_ITEM_COUNT); + + saveSettings(path, { ...defaultSettings, browseItemCount: -5 }); + expect(loadSettings(path).browseItemCount).toBe(MIN_BROWSE_ITEM_COUNT); + }); + + it('falls back to the default browseItemCount when the persisted value is not a number', () => { + const path = tempSettingsPath(); + writeFileSync(path, JSON.stringify({ ...defaultSettings, browseItemCount: 'many' }), 'utf-8'); + expect(loadSettings(path).browseItemCount).toBe(20); + }); + + it('does not touch the real user config when given a custom path', () => { + const path = tempSettingsPath(); + const settings = loadSettings(path); + expect(settings).toBeTruthy(); + rmSync(join(path, '..'), { recursive: true, force: true }); + }); +}); + +describe('clampBrowseItemCount', () => { + it('keeps in-range values', () => { + expect(clampBrowseItemCount(20)).toBe(20); + expect(clampBrowseItemCount(MIN_BROWSE_ITEM_COUNT)).toBe(MIN_BROWSE_ITEM_COUNT); + expect(clampBrowseItemCount(MAX_BROWSE_ITEM_COUNT)).toBe(MAX_BROWSE_ITEM_COUNT); + }); + + it('clamps below the minimum to 1', () => { + expect(clampBrowseItemCount(0)).toBe(MIN_BROWSE_ITEM_COUNT); + expect(clampBrowseItemCount(-10)).toBe(MIN_BROWSE_ITEM_COUNT); + }); + + it('clamps above the maximum to 50', () => { + expect(clampBrowseItemCount(51)).toBe(MAX_BROWSE_ITEM_COUNT); + }); + + it('rounds fractional values', () => { + expect(clampBrowseItemCount(20.6)).toBe(21); + expect(clampBrowseItemCount(20.4)).toBe(20); + }); + + it('returns the default (20) for non-finite input', () => { + expect(clampBrowseItemCount(NaN)).toBe(20); + expect(clampBrowseItemCount(Infinity)).toBe(20); + }); +}); + +describe('downtimeNoCandidateCooldownMs', () => { + it('defaults to 3_600_000 ms (60 minutes)', () => { + expect(defaultSettings.downtimeNoCandidateCooldownMs).toBe(3_600_000); + }); + + it('loads a persisted value and clamps below the 60s floor', () => { + const path = tempSettingsPath(); + saveSettings(path, { ...defaultSettings, downtimeNoCandidateCooldownMs: 1_000 }); + expect(loadSettings(path).downtimeNoCandidateCooldownMs).toBe(60_000); + + saveSettings(path, { ...defaultSettings, downtimeNoCandidateCooldownMs: 3_600_000 }); + expect(loadSettings(path).downtimeNoCandidateCooldownMs).toBe(3_600_000); + }); + + it('falls back to the default when the persisted value is not a number', () => { + const path = tempSettingsPath(); + writeFileSync(path, JSON.stringify({ ...defaultSettings, downtimeNoCandidateCooldownMs: 'soon' }), 'utf-8'); + expect(loadSettings(path).downtimeNoCandidateCooldownMs).toBe(3_600_000); + }); +}); diff --git a/packages/herdr/src/settings.ts b/packages/herdr/src/settings.ts index aed8dca3e..83cd69855 100644 --- a/packages/herdr/src/settings.ts +++ b/packages/herdr/src/settings.ts @@ -7,6 +7,18 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { clampSyncInterval } from './auto-sync.js'; +import { + clampDowntimeIdleThresholdMs, + clampDowntimeNoCandidateCooldownMs, + clampDowntimePollInterval, + clampDowntimeRequiredFreeSlots, + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + DEFAULT_DOWNTIME_MODEL, + DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, + DEFAULT_DOWNTIME_POLL_INTERVAL_MS, + DEFAULT_DOWNTIME_PROXY_URL, + DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS, +} from './downtime-worker.js'; import { dirname } from 'node:path'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -28,6 +40,24 @@ export interface PluginSettings { browseItemCount: number; /** Show chord help bar at the bottom of the list. */ showHelpText: boolean; + /** Enable the downtime worker (local-LLM idle dispatch). */ + downtimeEnabled: boolean; + /** Minimum continuous idle duration before dispatch (ms). */ + downtimeIdleThresholdMs: number; + /** Required free slots; 0 = all slots (default), or a positive integer N. */ + downtimeRequiredFreeSlots: number; + /** Poll interval for the proxy status endpoint (hard floor 10s). */ + downtimePollIntervalMs: number; + /** Base URL of the llama-proxy (e.g. http://192.168.0.199:8000). */ + downtimeProxyUrl: string; + /** pi model pattern for dispatched agent panes (default `plan`). */ + downtimeModel: string; + /** + * Full pause (no proxy polling, no idle tracking, no dispatch) after the + * worker finds no candidate in either stage (genuine empty backlog). + * Floor 60s; default 3_600_000 ms (60 min). + */ + downtimeNoCandidateCooldownMs: number; } // ── Defaults ────────────────────────────────────────────────────────── @@ -38,8 +68,15 @@ export const defaultSettings: PluginSettings = { showIcons: true, autoSync: true, syncIntervalMs: 60000, - browseItemCount: 10, + browseItemCount: 20, showHelpText: true, + downtimeEnabled: true, + downtimeIdleThresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + downtimeRequiredFreeSlots: DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS, + downtimePollIntervalMs: DEFAULT_DOWNTIME_POLL_INTERVAL_MS, + downtimeProxyUrl: DEFAULT_DOWNTIME_PROXY_URL, + downtimeModel: DEFAULT_DOWNTIME_MODEL, + downtimeNoCandidateCooldownMs: DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, }; /** Minimum allowed browseItemCount. */ @@ -109,6 +146,24 @@ export function loadSettings(settingsPath?: string): PluginSettings { ? clampBrowseItemCount(parsed.browseItemCount) : defaultSettings.browseItemCount, showHelpText: typeof parsed.showHelpText === 'boolean' ? parsed.showHelpText : defaultSettings.showHelpText, + downtimeEnabled: typeof parsed.downtimeEnabled === 'boolean' + ? parsed.downtimeEnabled : defaultSettings.downtimeEnabled, + downtimeIdleThresholdMs: typeof parsed.downtimeIdleThresholdMs === 'number' + ? clampDowntimeIdleThresholdMs(parsed.downtimeIdleThresholdMs) + : defaultSettings.downtimeIdleThresholdMs, + downtimeRequiredFreeSlots: typeof parsed.downtimeRequiredFreeSlots === 'number' + ? clampDowntimeRequiredFreeSlots(parsed.downtimeRequiredFreeSlots) + : defaultSettings.downtimeRequiredFreeSlots, + downtimePollIntervalMs: typeof parsed.downtimePollIntervalMs === 'number' + ? clampDowntimePollInterval(parsed.downtimePollIntervalMs) + : defaultSettings.downtimePollIntervalMs, + downtimeProxyUrl: typeof parsed.downtimeProxyUrl === 'string' && parsed.downtimeProxyUrl.length > 0 + ? parsed.downtimeProxyUrl : defaultSettings.downtimeProxyUrl, + downtimeModel: typeof parsed.downtimeModel === 'string' && parsed.downtimeModel.length > 0 + ? parsed.downtimeModel : defaultSettings.downtimeModel, + downtimeNoCandidateCooldownMs: typeof parsed.downtimeNoCandidateCooldownMs === 'number' + ? clampDowntimeNoCandidateCooldownMs(parsed.downtimeNoCandidateCooldownMs) + : defaultSettings.downtimeNoCandidateCooldownMs, }; } catch { return { ...defaultSettings }; diff --git a/packages/herdr/src/shortcut-config.test.ts b/packages/herdr/src/shortcut-config.test.ts index 28a170117..cde3b48a6 100644 --- a/packages/herdr/src/shortcut-config.test.ts +++ b/packages/herdr/src/shortcut-config.test.ts @@ -14,6 +14,9 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { ShortcutRegistry, parseShortcutEntry, loadShortcutConfig } from './shortcut-config.js'; import type { ShortcutEntry } from './shortcut-config.js'; @@ -44,6 +47,101 @@ const omittedEntry: ShortcutEntry = { // ── parseShortcutEntry ─────────────────────────────────────────────────── +describe('parseShortcutEntry — work_item_types field', () => { + it('parses work_item_types as a string array', () => { + const entry = parseShortcutEntry({ + chord: ['w'], + command: '/skill:wiki-podcast-script ', + view: 'both', + work_item_types: ['podcast'], + }); + expect(entry?.workItemTypes).toEqual(['podcast']); + }); + + it('treats a missing work_item_types as omit (backward compatible)', () => { + const entry = parseShortcutEntry({ + chord: ['s'], + command: '!!wl search ', + view: 'both', + }); + expect(entry?.workItemTypes).toBeUndefined(); + }); + + it('logs and treats invalid work_item_types as omit', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const cases: unknown[] = [ + 'podcast', // not an array + [], // empty array + ['podcast', 42], // non-string element + [''], // empty string element + ]; + for (const bad of cases) { + const entry = parseShortcutEntry({ + chord: ['x'], + command: '!!wl close ', + view: 'both', + work_item_types: bad, + }); + expect(entry?.workItemTypes).toBeUndefined(); + } + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('keeps other fields intact when work_item_types is present', () => { + const entry = parseShortcutEntry({ + chord: ['w'], + command: '/skill:wiki-podcast-script ', + view: 'both', + label: 'write script', + stages: ['intake_complete'], + work_item_types: ['podcast'], + }); + expect(entry).toMatchObject({ + chord: ['w'], + command: '/skill:wiki-podcast-script ', + view: 'both', + label: 'write script', + stages: ['intake_complete'], + workItemTypes: ['podcast'], + }); + }); +}); + +// ── loadShortcutConfig ─────────────────────────────────────────────────── + +describe('loadShortcutConfig — production shortcuts.json', () => { + it('marks the implement (i) shortcut with code_freeze "block"', () => { + const registry = loadShortcutConfig(); + const entry = registry.lookupChordEntry(['i'], 'list', undefined, false); + expect(entry?.codeFreeze).toBe('block'); + expect(entry?.command).toBe('/skill:implement '); + }); + + it('restricts the bundled code-workflow chords n/p/i to code issue types', () => { + const registry = loadShortcutConfig(); + const codeTypes = ['bug', 'feature', 'task', 'chore', 'epic']; + for (const chord of ['n', 'p', 'i']) { + const entry = registry.lookupChordEntry([chord], 'list'); + expect(entry?.workItemTypes).toEqual(codeTypes); + } + // Generic chords stay untyped. + expect(registry.lookupChordEntry(['r'], 'list')?.workItemTypes).toBeUndefined(); + expect(registry.lookupChordEntry(['c'], 'list')?.workItemTypes).toBeUndefined(); + expect(registry.lookupChordEntry(['a', 'a'], 'list')?.workItemTypes).toBeUndefined(); + }); + + it('registers the f s sprint chord to return to the default view (WL-0MSGSE15000746F7)', () => { + const registry = loadShortcutConfig(); + const entry = registry.lookupChordEntry(['f', 's'], 'list', undefined, false); + expect(entry).toBeDefined(); + expect(entry?.command).toBe('/wl'); + expect(entry?.label).toBe('sprint'); + }); +}); + +// ── parseShortcutEntry ─────────────────────────────────────────────────── + describe('parseShortcutEntry — code_freeze field', () => { it('parses code_freeze: "block"', () => { const entry = parseShortcutEntry({ @@ -105,17 +203,6 @@ describe('parseShortcutEntry — code_freeze field', () => { }); }); -// ── loadShortcutConfig ─────────────────────────────────────────────────── - -describe('loadShortcutConfig — production shortcuts.json', () => { - it('marks the implement (i) shortcut with code_freeze "block"', () => { - const registry = loadShortcutConfig(); - const entry = registry.lookupChordEntry(['i'], 'list', undefined, false); - expect(entry?.codeFreeze).toBe('block'); - expect(entry?.command).toBe('/skill:implement '); - }); -}); - // ── Registry filtering ─────────────────────────────────────────────────── describe('ShortcutRegistry — codeFreezeActive filtering', () => { @@ -224,3 +311,205 @@ describe('ShortcutRegistry — codeFreezeActive filtering', () => { }); }); }); + +// ── Issue-type filtering (WL-0MSKH1J0R003BM2M) ───────────────────────── +// +// A shortcut entry carrying a `workItemTypes` allowlist is visible ONLY on +// work items whose issue type is listed. Entries without an allowlist, and +// lookups without a supplied issueType, behave exactly as before. + +describe('ShortcutRegistry — issue-type filtering', () => { + const podcastEntry: ShortcutEntry = { + chord: ['w'], + command: '/skill:wiki-podcast-script ', + view: 'both', + label: 'write script', + workItemTypes: ['podcast'], + }; + + const codeEntry: ShortcutEntry = { + chord: ['i'], + command: '/skill:implement ', + view: 'both', + label: 'implement', + workItemTypes: ['bug', 'feature', 'task', 'chore', 'epic'], + }; + + const untypedEntry: ShortcutEntry = { + chord: ['s'], + command: '!!wl search ', + view: 'both', + label: 'search', + }; + + let registry: ShortcutRegistry; + + beforeEach(() => { + registry = new ShortcutRegistry([podcastEntry, codeEntry, untypedEntry]); + }); + + it('resolves a podcast-gated chord only for podcast items', () => { + expect(registry.lookupChord(['w'], 'list', undefined, false, 'podcast')).toBe('/skill:wiki-podcast-script '); + expect(registry.lookupChord(['w'], 'list', undefined, false, 'feature')).toBeUndefined(); + expect(registry.lookupChord(['w'], 'list', undefined, false, 'docs')).toBeUndefined(); + }); + + it('resolves a code-gated chord only for code item types', () => { + for (const t of ['bug', 'feature', 'task', 'chore', 'epic']) { + expect(registry.lookupChord(['i'], 'list', undefined, false, t)).toBe('/skill:implement '); + } + expect(registry.lookupChord(['i'], 'list', undefined, false, 'podcast')).toBeUndefined(); + expect(registry.lookupChord(['i'], 'list', undefined, false, 'docs')).toBeUndefined(); + }); + + it('keeps untyped chords available on every type', () => { + expect(registry.lookupChord(['s'], 'list', undefined, false, 'podcast')).toBe('!!wl search '); + expect(registry.lookupChord(['s'], 'list', undefined, false, 'feature')).toBe('!!wl search '); + expect(registry.lookupChord(['s'], 'list', undefined, false, 'docs')).toBe('!!wl search '); + }); + + it('keeps every chord visible when no issueType is supplied (backward compatible)', () => { + expect(registry.lookupChord(['w'], 'list')).toBe('/skill:wiki-podcast-script '); + expect(registry.lookupChord(['i'], 'list')).toBe('/skill:implement '); + expect(registry.lookupChord(['s'], 'list')).toBe('!!wl search '); + expect(registry.getEntries()).toHaveLength(3); + }); + + it('lookupChordEntry honors the allowlist', () => { + expect(registry.lookupChordEntry(['w'], 'list', undefined, false, 'podcast')?.label).toBe('write script'); + expect(registry.lookupChordEntry(['w'], 'list', undefined, false, 'task')).toBeUndefined(); + }); + + it('getEntriesForStage excludes entries whose allowlist misses the type', () => { + const forPodcast = registry.getEntriesForStage(undefined, false, 'podcast'); + expect(forPodcast.map(e => e.label)).toEqual(['write script', 'search']); + const forCode = registry.getEntriesForStage(undefined, false, 'task'); + expect(forCode.map(e => e.label)).toEqual(['implement', 'search']); + }); + + it('getChordByPrefix excludes entries whose allowlist misses the type', () => { + expect(registry.getChordByPrefix(['w'], 'list', undefined, false, 'podcast')).toHaveLength(1); + expect(registry.getChordByPrefix(['w'], 'list', undefined, false, 'chore')).toHaveLength(0); + expect(registry.getChordByPrefix(['i'], 'list', undefined, false, 'podcast')).toHaveLength(0); + expect(registry.getChordByPrefix(['i'], 'list', undefined, false, 'epic')).toHaveLength(1); + }); + + it('getChordByLeader honors the allowlist', () => { + expect(registry.getChordByLeader('w', 'list', false, 'podcast')).toHaveLength(1); + expect(registry.getChordByLeader('w', 'list', false, 'feature')).toHaveLength(0); + }); + + it('getChordEntries honors the allowlist', () => { + expect(registry.getChordEntries(false, 'podcast').map(e => e.label)).toEqual(['write script', 'search']); + expect(registry.getChordEntries(false, 'task').map(e => e.label)).toEqual(['implement', 'search']); + }); +}); + +// ── Project-local overrides (WL-0MSHUMX5C004NC4O) ─────────────────────── +// +// Merge contract for a project-local /shortcuts.json loaded +// over the bundled defaults: local-wins override on chord+view, new local +// chords appended, deterministic and deduplicated, malformed local file +// falls back to bundled-only with a logged error. + +describe('loadShortcutConfig — project-local shortcuts.json overrides', () => { + let tempRoots: string[] = []; + + /** Create a temp worklog root; `files` maps relative path -> content. */ + function makeLocalRoot(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), 'herdr-shortcut-override-')); + tempRoots.push(dir); + for (const [rel, content] of Object.entries(files)) { + writeFileSync(join(dir, rel), content); + } + return dir; + } + + afterEach(() => { + for (const dir of tempRoots) { + try { rmSync(dir, { recursive: true }); } catch { /* ignore */ } + } + tempRoots = []; + vi.restoreAllMocks(); + }); + + it('is byte-identical to bundled-only when no local file exists', () => { + const bundled = loadShortcutConfig(); + const root = makeLocalRoot({}); // empty dir — no shortcuts.json + const registry = loadShortcutConfig(root); + expect(registry.getEntries()).toEqual(bundled.getEntries()); + }); + + it('is byte-identical to bundled-only when worklogRoot is undefined', () => { + const bundled = loadShortcutConfig(); + expect(loadShortcutConfig(undefined).getEntries()).toEqual(bundled.getEntries()); + }); + + it('appends a local entry with a new chord and resolves it via lookupChord', () => { + const bundled = loadShortcutConfig(); + const root = makeLocalRoot({ + 'shortcuts.json': JSON.stringify([ + { chord: ['w'], command: '/prompt:Write a podcast script for ', view: 'both', label: 'podcast script' }, + ]), + }); + const registry = loadShortcutConfig(root); + const entries = registry.getEntries(); + expect(entries).toHaveLength(bundled.getEntries().length + 1); + expect(registry.lookupChord(['w'], 'list')).toBe('/prompt:Write a podcast script for '); + // Bundled entries are unchanged. + expect(registry.lookupChord(['i'], 'list')).toBe(bundled.lookupChord(['i'], 'list')); + }); + + it('replaces a bundled entry when the local file overrides the same chord+view', () => { + const bundled = loadShortcutConfig(); + const root = makeLocalRoot({ + 'shortcuts.json': JSON.stringify([ + { chord: ['i'], command: '/prompt:overridden', view: 'both', label: 'overridden', model: 'author', stages: ['in_review'] }, + ]), + }); + const registry = loadShortcutConfig(root); + const entry = registry.lookupChordEntry(['i'], 'list'); + expect(entry?.command).toBe('/prompt:overridden'); + expect(entry?.label).toBe('overridden'); + expect(entry?.model).toBe('author'); + // The local entry's stages replace the bundled stages (full-entry replacement). + expect(entry?.stages).toEqual(['in_review']); + // Replaced, not appended: total count unchanged and no duplicate remains. + expect(registry.getEntries()).toHaveLength(bundled.getEntries().length); + const matches = registry.getEntries().filter(e => e.chord.join(',') === 'i' && e.view === 'both'); + expect(matches).toHaveLength(1); + }); + + it('falls back to bundled-only and logs an error for a malformed local file', () => { + const bundled = loadShortcutConfig(); + const cases: Array<[string, string]> = [ + ['invalid JSON', '{ not json'], + ['non-array', '{"chord": ["x"]}'], + ['invalid entries', JSON.stringify([{ chord: ['x'], command: '', view: 'both' }])], + ]; + for (const [name, content] of cases) { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const root = makeLocalRoot({ 'shortcuts.json': content }); + const registry = loadShortcutConfig(root); + expect(registry.getEntries()).toEqual(bundled.getEntries()); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + } + }); + + it('deduplicates chord+view within the local file (last wins) deterministically', () => { + const root = makeLocalRoot({ + 'shortcuts.json': JSON.stringify([ + { chord: ['w'], command: '/prompt:first', view: 'both', label: 'first' }, + { chord: ['w'], command: '/prompt:second', view: 'both', label: 'second' }, + ]), + }); + const a = loadShortcutConfig(root); + const b = loadShortcutConfig(root); + const matches = a.getEntries().filter(e => e.chord.join(',') === 'w' && e.view === 'both'); + expect(matches).toHaveLength(1); + expect(matches[0].command).toBe('/prompt:second'); + // Merge order is stable across runs. + expect(JSON.stringify(a.getEntries())).toBe(JSON.stringify(b.getEntries())); + }); +}); diff --git a/packages/herdr/src/shortcut-config.ts b/packages/herdr/src/shortcut-config.ts index b45986dae..4ae95a0a8 100644 --- a/packages/herdr/src/shortcut-config.ts +++ b/packages/herdr/src/shortcut-config.ts @@ -6,7 +6,7 @@ * and stage-aware visibility. Ported from the Pi TUI shortcut-config.ts. */ -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -41,6 +41,16 @@ export interface ShortcutEntry { * logged and treated as omitted. */ codeFreeze?: 'block' | 'allow'; + /** + * Issue-type allowlist (WL-0MSKH1J0R003BM2M). When present, the shortcut + * is visible ONLY on work items whose issue type (e.g. `bug`, `feature`, + * `task`, `chore`, `epic`, `podcast`) is listed. Entries without an + * allowlist are available on all types (backward compatible). + * Parsed from the `work_item_types` key in shortcuts.json (snake_case in + * JSON, camelCase in TS — matching the `code_freeze`→`codeFreeze` + * convention); invalid values are logged and treated as omitted. + */ + workItemTypes?: string[]; } // ── Registry ────────────────────────────────────────────────────────── @@ -61,20 +71,34 @@ export class ShortcutRegistry { return codeFreezeActive === true && entry.codeFreeze === 'block'; } + /** + * True when an entry is hidden by issue-type gating (WL-0MSKH1J0R003BM2M): + * the entry carries a `workItemTypes` allowlist AND the selected item's + * issueType is supplied AND the allowlist does not include it. When either + * the allowlist or the issueType is missing, the entry is always visible + * (backward compatible). + */ + private isBlockedByIssueType(entry: ShortcutEntry, issueType?: string): boolean { + if (issueType === undefined) return false; + if (entry.workItemTypes === undefined || entry.workItemTypes.length === 0) return false; + return !entry.workItemTypes.includes(issueType); + } + /** * Look up a chord by its full key sequence (supports any length). */ - lookupChord(chordKeys: string[], view: string, stage?: string, codeFreezeActive?: boolean): string | undefined { - return this.lookupChordEntry(chordKeys, view, stage, codeFreezeActive)?.command; + lookupChord(chordKeys: string[], view: string, stage?: string, codeFreezeActive?: boolean, issueType?: string): string | undefined { + return this.lookupChordEntry(chordKeys, view, stage, codeFreezeActive, issueType)?.command; } /** * Look up a chord by its full key sequence and return the matching entry * (command, model, label, ...). Returns undefined when no entry matches. */ - lookupChordEntry(chordKeys: string[], view: string, stage?: string, codeFreezeActive?: boolean): ShortcutEntry | undefined { + lookupChordEntry(chordKeys: string[], view: string, stage?: string, codeFreezeActive?: boolean, issueType?: string): ShortcutEntry | undefined { return this.entries.find(entry => { if (this.isBlockedByFreeze(entry, codeFreezeActive)) return false; + if (this.isBlockedByIssueType(entry, issueType)) return false; const chord = entry.chord; if (chord.length !== chordKeys.length) return false; for (let i = 0; i < chord.length; i++) { @@ -91,9 +115,10 @@ export class ShortcutRegistry { /** * Return all entries visible for the given stage. */ - getEntriesForStage(stage?: string, codeFreezeActive?: boolean): ShortcutEntry[] { + getEntriesForStage(stage?: string, codeFreezeActive?: boolean, issueType?: string): ShortcutEntry[] { return this.entries.filter(entry => { if (this.isBlockedByFreeze(entry, codeFreezeActive)) return false; + if (this.isBlockedByIssueType(entry, issueType)) return false; if (entry.stages === undefined || entry.stages.length === 0) return true; if (stage === undefined) return false; return entry.stages.includes(stage); @@ -110,17 +135,18 @@ export class ShortcutRegistry { /** * Get chord entries whose leader key matches. */ - getChordByLeader(leaderKey: string, view?: string, codeFreezeActive?: boolean): ShortcutEntry[] { - return this.getChordByPrefix([leaderKey], view, undefined, codeFreezeActive); + getChordByLeader(leaderKey: string, view?: string, codeFreezeActive?: boolean, issueType?: string): ShortcutEntry[] { + return this.getChordByPrefix([leaderKey], view, undefined, codeFreezeActive, issueType); } /** * Get chord entries whose chord array starts with the given prefix. */ - getChordByPrefix(prefix: string[], view?: string, stage?: string, codeFreezeActive?: boolean): ShortcutEntry[] { + getChordByPrefix(prefix: string[], view?: string, stage?: string, codeFreezeActive?: boolean, issueType?: string): ShortcutEntry[] { const result: ShortcutEntry[] = []; for (const entry of this.entries) { if (this.isBlockedByFreeze(entry, codeFreezeActive)) continue; + if (this.isBlockedByIssueType(entry, issueType)) continue; const chord = entry.chord; if (chord.length < prefix.length) continue; @@ -143,11 +169,16 @@ export class ShortcutRegistry { /** * Return all entries (each has a chord, any length). When a freeze is - * active, entries marked `code_freeze: 'block'` are omitted. + * active, entries marked `code_freeze: 'block'` are omitted; when an + * issueType is supplied, entries whose `workItemTypes` allowlist does not + * include it are omitted too. */ - getChordEntries(codeFreezeActive?: boolean): ShortcutEntry[] { - if (codeFreezeActive !== true) return this.entries; - return this.entries.filter(entry => !this.isBlockedByFreeze(entry, true)); + getChordEntries(codeFreezeActive?: boolean, issueType?: string): ShortcutEntry[] { + return this.entries.filter(entry => { + if (this.isBlockedByFreeze(entry, codeFreezeActive)) return false; + if (this.isBlockedByIssueType(entry, issueType)) return false; + return true; + }); } } @@ -223,6 +254,16 @@ export function parseShortcutEntry(raw: unknown): ShortcutEntry | undefined { console.error(`[shortcut-config] Invalid code_freeze value "${String(codeFreeze)}" for shortcut "${command}"; expected "block" or "allow", treating as omitted`); } + const workItemTypes = entry.work_item_types; + if (Array.isArray(workItemTypes) && workItemTypes.length > 0 && workItemTypes.every(t => typeof t === 'string' && t.trim().length > 0)) { + shortcutEntry.workItemTypes = workItemTypes.map(String); + } else if (workItemTypes !== undefined) { + // Invalid values are logged and treated as omit (available on all types) + // — a bad value must never hide or break a shortcut + // (WL-0MSKH1J0R003BM2M). + console.error(`[shortcut-config] Invalid work_item_types value for shortcut "${command}"; expected a non-empty array of strings, treating as omitted`); + } + // Agent-bound commands without an explicit model run on the default // `plan` model so every pi pane spawned from a shortcut opens with a // deterministic model (WL-0MSD48ZFC0043AO3). Non-agent commands (shell @@ -235,16 +276,19 @@ export function parseShortcutEntry(raw: unknown): ShortcutEntry | undefined { } /** - * Load and validate shortcut config from shortcuts.json. + * Load and validate the bundled default entries from src/shortcuts.json. + * + * Returns an empty array when the bundled file is missing or malformed, + * mirroring the pre-extensibility behaviour of {@link loadShortcutConfig}. */ -export function loadShortcutConfig(): ShortcutRegistry { +function loadBundledEntries(): ShortcutEntry[] { const configPath = join(__dirname, 'shortcuts.json'); let raw: string; try { raw = readFileSync(configPath, 'utf-8'); } catch { - return new ShortcutRegistry([]); + return []; } let parsed: unknown; @@ -252,12 +296,12 @@ export function loadShortcutConfig(): ShortcutRegistry { parsed = JSON.parse(raw); } catch { console.error('[shortcut-config] Malformed shortcuts.json'); - return new ShortcutRegistry([]); + return []; } if (!Array.isArray(parsed)) { console.error('[shortcut-config] shortcuts.json must be an array'); - return new ShortcutRegistry([]); + return []; } const validEntries: ShortcutEntry[] = []; @@ -269,7 +313,100 @@ export function loadShortcutConfig(): ShortcutRegistry { } } - return new ShortcutRegistry(validEntries); + return validEntries; +} + +/** + * Load project-local shortcut entries from /shortcuts.json. + * + * The local file (WL-0MSHUMX5C004NC4O) lets a consumer project add its own + * chords and override bundled defaults without editing the plugin bundle. + * Entries are validated with the same {@link parseShortcutEntry} rules as the + * bundled file; invalid entries are logged and skipped (never crash). A + * missing, unreadable, malformed (bad JSON / non-array) local file falls back + * to bundled-only with an error logged. + */ +function loadLocalEntries(worklogRoot: string): ShortcutEntry[] { + const localPath = join(worklogRoot, 'shortcuts.json'); + if (!existsSync(localPath)) return []; + + let raw: string; + try { + raw = readFileSync(localPath, 'utf-8'); + } catch { + console.error(`[shortcut-config] Could not read project-local shortcuts.json at ${localPath}; using bundled defaults only`); + return []; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + console.error(`[shortcut-config] Malformed project-local shortcuts.json at ${localPath}; using bundled defaults only`); + return []; + } + + if (!Array.isArray(parsed)) { + console.error(`[shortcut-config] Project-local shortcuts.json at ${localPath} must be an array; using bundled defaults only`); + return []; + } + + const localEntries: ShortcutEntry[] = []; + for (const entry of parsed) { + const parsedEntry = parseShortcutEntry(entry); + if (parsedEntry) { + localEntries.push(parsedEntry); + } else { + console.error(`[shortcut-config] Invalid entry in project-local shortcuts.json at ${localPath}; skipping entry`); + } + } + return localEntries; +} + +/** + * Merge bundled defaults with project-local entries. + * + * Local wins: an entry with the same view+chord replaces the bundled one; + * entries with new chords are appended. Bundled order is preserved for + * untouched entries (a Map keyed by view+chord keeps the first-inserted + * position when a key is re-set), so the resulting registry is deterministic + * with no duplicate view+chord pairs. Within the local file, later entries + * win for the same view+chord. + */ +function mergeShortcutEntries(bundled: ShortcutEntry[], local: ShortcutEntry[]): ShortcutEntry[] { + const key = (e: ShortcutEntry): string => `${e.view}\u0000${e.chord.join('\u0001')}`; + const merged = new Map(); + for (const entry of bundled) { + merged.set(key(entry), entry); + } + for (const entry of local) { + merged.set(key(entry), entry); + } + return [...merged.values()]; +} + +/** + * Load and validate shortcut config from shortcuts.json. + * + * Loads the bundled defaults first, then merges a project-local + * `/shortcuts.json` over them when present (local wins on + * chord+view, WL-0MSHUMX5C004NC4O). Without a local file — or when + * `worklogRoot` is undefined — the registry is byte-identical to the + * bundled-only output. + */ +export function loadShortcutConfig(worklogRoot?: string): ShortcutRegistry { + const bundledEntries = loadBundledEntries(); + + if (!worklogRoot) { + return new ShortcutRegistry(bundledEntries); + } + + const localEntries = loadLocalEntries(worklogRoot); + if (localEntries.length === 0) { + return new ShortcutRegistry(bundledEntries); + } + + return new ShortcutRegistry(mergeShortcutEntries(bundledEntries, localEntries)); } /** diff --git a/packages/herdr/src/shortcuts.json b/packages/herdr/src/shortcuts.json index b4f9f6169..bc726d00f 100644 --- a/packages/herdr/src/shortcuts.json +++ b/packages/herdr/src/shortcuts.json @@ -20,7 +20,8 @@ ], "label": "intake", "description": "Ensure that the selected item is reasonably well defined in terms of objectives.", - "model": "plan" + "model": "plan", + "work_item_types": ["bug", "feature", "task", "chore", "epic"] }, { "chord": [ @@ -33,7 +34,8 @@ ], "label": "plan", "description": "Run the plan workflow on the selected work item", - "model": "plan" + "model": "plan", + "work_item_types": ["bug", "feature", "task", "chore", "epic"] }, { "chord": [ @@ -49,7 +51,8 @@ "label": "implement", "description": "Run the implement workflow on the selected work item", "model": "code", - "code_freeze": "block" + "code_freeze": "block", + "work_item_types": ["bug", "feature", "task", "chore", "epic"] }, { "chord": [ @@ -254,5 +257,15 @@ "view": "both", "label": "filter in_review", "description": "Filter browse list to items in the in_review stage." + }, + { + "chord": [ + "f", + "s" + ], + "command": "/wl", + "view": "both", + "label": "sprint", + "description": "Return to the default unfiltered browse list (WL-0MSGSE15000746F7)." } ] diff --git a/packages/herdr/src/worklist-agent-state.test.ts b/packages/herdr/src/worklist-agent-state.test.ts new file mode 100644 index 000000000..75d92c7b7 --- /dev/null +++ b/packages/herdr/src/worklist-agent-state.test.ts @@ -0,0 +1,208 @@ +/** + * packages/herdr/src/worklist-agent-state.test.ts — Integration tests for + * merging agent-status state into fetched work items (WL-0MSBQUJQX005RAT9). + * + * runWorklistTui accepts a `mergeAgentStates` callback that is applied to + * freshly fetched items (top-level + expanded children) on every refresh + * cycle. This verifies the plumbing: when the callback stamps an + * `agentState` on an item, the row renders the agent-status icon; without + * it, the row renders no agent icon. + * + * Run: npx vitest run packages/herdr/src/worklist-agent-state.test.ts + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Mock } from 'vitest'; + +vi.mock('./auto-sync.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runSync: vi.fn().mockResolvedValue({ success: true }), + }; +}); + +vi.mock('./notify.js', () => ({ + showToast: vi.fn(), +})); + +import { runWorklistTui } from './worklist.js'; +import { setExecFileAsync, resetExecFileAsync } from './fetcher.js'; +import { stringDisplayWidth } from './icons.js'; +import type { WorkItem } from './fetcher.js'; + +let dataHandler: ((chunk: Buffer) => void) | undefined; +let writes: string[]; + +beforeEach(() => { + vi.clearAllMocks(); + dataHandler = undefined; + writes = []; + + for (const prop of ['on', 'removeListener', 'pause', 'resume', 'setRawMode'] as const) { + if (!(prop in process.stdin)) { + Object.defineProperty(process.stdin, prop, { + value: vi.fn(), + configurable: true, + writable: true, + }); + } + } + (process.stdin as any).on = vi.fn((event: string, cb: (chunk: Buffer) => void) => { + if (event === 'data') dataHandler = cb; + return process.stdin; + }); + (process.stdin as any).removeListener = vi.fn(() => process.stdin); + (process.stdin as any).pause = vi.fn(() => process.stdin); + (process.stdin as any).resume = vi.fn(() => process.stdin); + (process.stdin as any).setRawMode = vi.fn(() => process.stdin); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + vi.spyOn(process.stdout, 'write').mockImplementation(((s: any) => { + writes.push(String(s)); + return true; + }) as any); + vi.spyOn(process.stdout, 'on').mockImplementation((() => process.stdout as any) as any); + vi.spyOn(process.stdout, 'removeListener').mockImplementation((() => process.stdout as any) as any); +}); + +afterEach(() => { + resetExecFileAsync(); + delete process.env.HERDR_PANE_ID; + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function makeExecMock(): Mock { + return vi.fn(async (bin: string, args: string[]) => { + if (bin === 'herdr' && args[0] === 'pane' && args[1] === 'get') { + return { + stdout: JSON.stringify({ id: 'cli:pane:get', result: { pane: { focused: true } } }), + stderr: '', + }; + } + if (args.includes('list') && args.includes('--status')) { + return { stdout: JSON.stringify({ count: 5 }), stderr: '' }; + } + return { stdout: JSON.stringify({ workItems: [] }), stderr: '' }; + }); +} + +async function quit(p: Promise): Promise { + dataHandler?.(Buffer.from('q')); + await p; +} + +const GREEN_CIRCLE = '\u{1F7E2}'; // 🟢 — the agent working icon + +describe('worklist — agent-state merge into fetched items', () => { + it('renders the agent-status icon when mergeAgentStates stamps an agentState', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeExecMock() as any); + + const fetcher = vi.fn(async (): Promise => [ + { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea' }, + ]); + + const p = runWorklistTui(fetcher, [], undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + mergeAgentStates: async (items: WorkItem[]) => { + for (const it of items) { + if (it.id === 'WL-1') it.agentState = 'working'; + } + }, + }); + await vi.advanceTimersByTimeAsync(0); + fetcher.mockClear(); + + // Trigger the first auto-refresh cycle, which applies mergeAgentStates. + await vi.advanceTimersByTimeAsync(30_000); + + const output = writes.join(''); + expect(output).toContain(GREEN_CIRCLE); + expect(output).toContain('WL-1'); + + await quit(p); + }); + + it('renders no agent icon when no agentState is stamped', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeExecMock() as any); + + const fetcher = vi.fn(async (): Promise => [ + { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea' }, + ]); + + const p = runWorklistTui(fetcher, [], undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + }); + await vi.advanceTimersByTimeAsync(0); + fetcher.mockClear(); + + await vi.advanceTimersByTimeAsync(30_000); + + const output = writes.join(''); + expect(output).toContain('WL-1'); + expect(output).not.toContain(GREEN_CIRCLE); + + await quit(p); + }); + + it('keeps rows aligned: item IDs share a column with and without the agent icon', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeExecMock() as any); + + const fetcher = vi.fn(async (): Promise => [ + { id: 'WL-AGENT', title: 'Has agent', status: 'open', stage: 'idea' }, + { id: 'WL-PLAIN', title: 'No agent', status: 'open', stage: 'idea' }, + ]); + + const p = runWorklistTui(fetcher, [], undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + mergeAgentStates: async (items: WorkItem[]) => { + for (const it of items) { + if (it.id === 'WL-AGENT') it.agentState = 'working'; + } + }, + }); + await vi.advanceTimersByTimeAsync(0); + fetcher.mockClear(); + + await vi.advanceTimersByTimeAsync(30_000); + + const output = writes.join(''); + const lines = output.split('\n'); + const findLine = (id: string): string => lines.find((l) => l.includes(id)) ?? ''; + const agentLine = findLine('WL-AGENT'); + const plainLine = findLine('WL-PLAIN'); + expect(agentLine).not.toBe(''); + expect(plainLine).not.toBe(''); + // Compare DISPLAY columns. Strip ANSI colour codes (the selected row + // carries a reverse-video marker) and normalize the selection marker: + // stringDisplayWidth models `▸` (U+25B8) as 2 cells, but terminals + // render it as 1, so the selected row measures 1 cell wider than the + // non-selected row even though real columns align. Ignoring surrogate-pair + // code-unit indexes too (emoji), which differ though columns don't. + const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); + const norm = (s: string): string => stripAnsi(s).replace('▸', ' '); + const idCol = (line: string, id: string): number => { + const vis = norm(line); + return stringDisplayWidth(vis.slice(0, vis.indexOf(id))); + }; + expect(idCol(agentLine, 'WL-AGENT')).toBe(idCol(plainLine, 'WL-PLAIN')); + + await quit(p); + }); +}); diff --git a/packages/herdr/src/worklist-expand-refresh.test.ts b/packages/herdr/src/worklist-expand-refresh.test.ts new file mode 100644 index 000000000..6def58a25 --- /dev/null +++ b/packages/herdr/src/worklist-expand-refresh.test.ts @@ -0,0 +1,267 @@ +/** + * packages/herdr/src/worklist-expand-refresh.test.ts — TUI-level tests for + * expanded items surviving a list refresh without collapsing + * (WL-0MSBVBNGH002RDP5). + * + * The production fetcher returns fresh top-level item objects that never + * carry a `children` array (normalizeItem drops it). Before the fix, a + * refresh swapped those objects in and only re-fetched children afterwards, + * so any render that fired in that window showed every expanded parent + * collapsed (the "momentary collapse" flicker). These tests drive the real + * `runWorklistTui` refresh path (auto-refresh timer tick) with + * production-shaped fetcher responses and assert the expanded children stay + * visible at every point of the refresh cycle. + * + * Run: npx vitest run packages/herdr/src/worklist-expand-refresh.test.ts + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Mock } from 'vitest'; + +// --------------------------------------------------------------------------- +// Module mocks (must be hoisted before worklist.js is imported) +// --------------------------------------------------------------------------- +// Keep the REAL fetcher + visibility modules (so the exec seam and PollGate +// run for real) but avoid real `wl`/`herdr` process spawns by injecting a +// mock execFileAsync via setExecFileAsync() in each test. + +vi.mock('./auto-sync.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // Keep the real scheduler/sync plumbing but stub runSync so no real + // `wl sync` is spawned. + runSync: vi.fn().mockResolvedValue({ success: true }), + }; +}); + +vi.mock('./notify.js', () => ({ + showToast: vi.fn(), +})); + +import { runWorklistTui } from './worklist.js'; +import { setExecFileAsync, resetExecFileAsync } from './fetcher.js'; +import type { WorkItem } from './fetcher.js'; + +// --------------------------------------------------------------------------- +// Fake stdin/stdout harness (same pattern as worklist-inflight.test.ts) +// --------------------------------------------------------------------------- + +let dataHandler: ((chunk: Buffer) => void) | undefined; +let writes: string[]; + +beforeEach(() => { + vi.clearAllMocks(); + dataHandler = undefined; + writes = []; + + // Define missing stdin properties (vitest's process.stdin may not expose them) + for (const prop of ['on', 'removeListener', 'pause', 'resume', 'setRawMode'] as const) { + if (!(prop in process.stdin)) { + Object.defineProperty(process.stdin, prop, { + value: vi.fn(), + configurable: true, + writable: true, + }); + } + } + (process.stdin as any).on = vi.fn((event: string, cb: (chunk: Buffer) => void) => { + if (event === 'data') dataHandler = cb; + return process.stdin; + }); + (process.stdin as any).removeListener = vi.fn(() => process.stdin); + (process.stdin as any).pause = vi.fn(() => process.stdin); + (process.stdin as any).resume = vi.fn(() => process.stdin); + (process.stdin as any).setRawMode = vi.fn(() => process.stdin); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + vi.spyOn(process.stdout, 'write').mockImplementation(((s: any) => { + writes.push(String(s)); + return true; + }) as any); + vi.spyOn(process.stdout, 'on').mockImplementation((() => process.stdout as any) as any); + vi.spyOn(process.stdout, 'removeListener').mockImplementation((() => process.stdout as any) as any); +}); + +afterEach(() => { + resetExecFileAsync(); + delete process.env.HERDR_PANE_ID; + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +/** Quit the TUI and await its promise (cleans up timers/listeners). */ +async function quit(p: Promise): Promise { + dataHandler?.(Buffer.from('q')); + await p; +} + +/** + * Build a minimal child work item (fetcher-style, no nested children). + */ +function makeChild(id: string): WorkItem { + return { id, title: `Child ${id}`, status: 'open' }; +} + +/** + * Production-shaped top-level item: childCount present, NO `children` array + * (normalizeItem never populates it). + */ +function makeTopLevel(id: string, title: string, childCount: number): WorkItem { + return { id, title, status: 'open', childCount }; +} + +/** + * Exec-mock helper: visible pane (focused=true) so auto-refresh ticks fire, + * `wl list --parent` returns the expanded parent's children, and other wl + * calls are benign. + */ +function makeChildrenExecMock(): Mock { + return vi.fn(async (bin: string, args: string[]) => { + if (bin === 'herdr' && args[0] === 'pane' && args[1] === 'get') { + return { + stdout: JSON.stringify({ + id: 'cli:pane:get', + result: { pane: { focused: true } }, + }), + stderr: '', + }; + } + if (args.includes('list') && args.includes('--parent')) { + return { + stdout: JSON.stringify({ + workItems: [makeChild('C1'), makeChild('C2')], + }), + stderr: '', + }; + } + if (args.includes('list') && args.includes('--status')) { + return { stdout: JSON.stringify({ count: 5 }), stderr: '' }; + } + return { stdout: JSON.stringify({ workItems: [] }), stderr: '' }; + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('worklist expand-across-refresh (WL-0MSBVBNGH002RDP5)', () => { + it('auto-refresh keeps expanded children visible — production fetcher shape (children-less objects)', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeChildrenExecMock() as any); + + // Startup fetch returns the parent; the refresh tick returns a NEW parent + // object (fresh title) that again lacks `children`. + const fetcher = vi.fn() + .mockResolvedValueOnce([makeTopLevel('P', 'Parent', 2)]) + .mockResolvedValueOnce([makeTopLevel('P', 'Parent (refreshed)', 2)]); + + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + }); + await vi.advanceTimersByTimeAsync(0); + fetcher.mockClear(); + + // Expand the parent with Tab: children fetched on demand and shown. + dataHandler?.(Buffer.from('\t')); + await vi.advanceTimersByTimeAsync(0); + expect(writes.join('')).toContain('C1'); + expect(writes.join('')).toContain('C2'); + + // Auto-refresh tick: the fetcher returns a NEW children-less parent + // object. The expanded children must remain in the flattened view. + writes.length = 0; + await vi.advanceTimersByTimeAsync(30_000); + + const output = writes.join(''); + expect(output).toContain('Parent (refreshed)'); + expect(output).toContain('C1'); + expect(output).toContain('C2'); + + await quit(p); + }); + + it('a render fired mid-refresh (children re-fetch pending) keeps expanded children visible', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + + // The refresh's children re-fetch is deferred so the test can fire a + // render while the refresh cycle is still awaiting its children call. + let childFetchCount = 0; + let resolveDeferredChildren!: () => void; + const deferredChildren = new Promise((r) => { resolveDeferredChildren = r; }); + + setExecFileAsync(vi.fn(async (bin: string, args: string[]) => { + if (bin === 'herdr' && args[0] === 'pane' && args[1] === 'get') { + return { + stdout: JSON.stringify({ + id: 'cli:pane:get', + result: { pane: { focused: true } }, + }), + stderr: '', + }; + } + if (args.includes('list') && args.includes('--parent')) { + childFetchCount += 1; + if (childFetchCount === 1) { + // Expand-time fetch: immediate. + return { stdout: JSON.stringify({ workItems: [makeChild('C1'), makeChild('C2')] }), stderr: '' }; + } + // Refresh-time re-fetch: deferred until the test resolves it. + await deferredChildren; + return { stdout: JSON.stringify({ workItems: [makeChild('C1'), makeChild('C2')] }), stderr: '' }; + } + if (args.includes('list') && args.includes('--status')) { + return { stdout: JSON.stringify({ count: 5 }), stderr: '' }; + } + return { stdout: JSON.stringify({ workItems: [] }), stderr: '' }; + }) as any); + + const fetcher = vi.fn() + .mockResolvedValueOnce([makeTopLevel('P', 'Parent', 2)]) + .mockResolvedValueOnce([makeTopLevel('P', 'Parent (refreshed)', 2)]); + + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + }); + await vi.advanceTimersByTimeAsync(0); + + // Expand the parent with Tab: children fetched and shown. + dataHandler?.(Buffer.from('\t')); + await vi.advanceTimersByTimeAsync(0); + expect(writes.join('')).toContain('C1'); + + // Auto-refresh tick starts a refresh cycle; the children re-fetch is + // still pending here (the swap has NOT happened yet). + writes.length = 0; + await vi.advanceTimersByTimeAsync(30_000); + + // A render fired mid-refresh (keypress navigation) must still show the + // expanded children — never a collapsed intermediate frame. + writes.length = 0; + dataHandler?.(Buffer.from('j')); + await vi.advanceTimersByTimeAsync(0); + const midRefreshOutput = writes.join(''); + expect(midRefreshOutput).toContain('C1'); + expect(midRefreshOutput).toContain('C2'); + + // Complete the refresh: children re-fetch resolves, the atomic swap + // applies, and the expanded view remains with fresh data. + resolveDeferredChildren(); + await vi.advanceTimersByTimeAsync(0); + const afterRefreshOutput = writes.join(''); + expect(afterRefreshOutput).toContain('Parent (refreshed)'); + expect(afterRefreshOutput).toContain('C1'); + expect(afterRefreshOutput).toContain('C2'); + + await quit(p); + }); +}); diff --git a/packages/herdr/src/worklist-icons.test.ts b/packages/herdr/src/worklist-icons.test.ts new file mode 100644 index 000000000..81ab6f63c --- /dev/null +++ b/packages/herdr/src/worklist-icons.test.ts @@ -0,0 +1,201 @@ +/** + * packages/herdr/src/worklist-icons.test.ts — TUI-level integration tests + * for the showIcons setting gating (WL-0MSBV4RYO008JL70). + * + * runWorklistTui accepts a `getShowIcons` getter (wired from the settings + * file in index.ts) that is consulted on every render. When it returns + * false the item lines render text fallbacks ([OPEN], [IDEA], ...) instead + * of emoji icons; when true (the default) icons are rendered. Because the + * getter is re-read per render, editing showIcons in the config applies on + * the next render without a plugin restart. + * + * Run: npx vitest run packages/herdr/src/worklist-icons.test.ts + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Mock } from 'vitest'; + +// --------------------------------------------------------------------------- +// Module mocks (must be hoisted before worklist.js is imported) +// --------------------------------------------------------------------------- +// Keep the REAL fetcher + visibility modules (so the exec seam and PollGate +// run for real) but avoid real `wl`/`herdr` process spawns by injecting a +// mock execFileAsync via setExecFileAsync() in each test. + +vi.mock('./auto-sync.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runSync: vi.fn().mockResolvedValue({ success: true }), + }; +}); + +vi.mock('./notify.js', () => ({ + showToast: vi.fn(), +})); + +import { runWorklistTui } from './worklist.js'; +import { setExecFileAsync, resetExecFileAsync } from './fetcher.js'; +import type { WorkItem } from './fetcher.js'; + +// --------------------------------------------------------------------------- +// Fake stdin/stdout harness (same pattern as worklist-agent-state.test.ts) +// --------------------------------------------------------------------------- + +let dataHandler: ((chunk: Buffer) => void) | undefined; +let writes: string[]; + +beforeEach(() => { + vi.clearAllMocks(); + dataHandler = undefined; + writes = []; + + for (const prop of ['on', 'removeListener', 'pause', 'resume', 'setRawMode'] as const) { + if (!(prop in process.stdin)) { + Object.defineProperty(process.stdin, prop, { + value: vi.fn(), + configurable: true, + writable: true, + }); + } + } + (process.stdin as any).on = vi.fn((event: string, cb: (chunk: Buffer) => void) => { + if (event === 'data') dataHandler = cb; + return process.stdin; + }); + (process.stdin as any).removeListener = vi.fn(() => process.stdin); + (process.stdin as any).pause = vi.fn(() => process.stdin); + (process.stdin as any).resume = vi.fn(() => process.stdin); + (process.stdin as any).setRawMode = vi.fn(() => process.stdin); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + vi.spyOn(process.stdout, 'write').mockImplementation(((s: any) => { + writes.push(String(s)); + return true; + }) as any); + vi.spyOn(process.stdout, 'on').mockImplementation((() => process.stdout as any) as any); + vi.spyOn(process.stdout, 'removeListener').mockImplementation((() => process.stdout as any) as any); +}); + +afterEach(() => { + resetExecFileAsync(); + delete process.env.HERDR_PANE_ID; + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function makeExecMock(): Mock { + return vi.fn(async (bin: string, args: string[]) => { + if (bin === 'herdr' && args[0] === 'pane' && args[1] === 'get') { + return { + stdout: JSON.stringify({ id: 'cli:pane:get', result: { pane: { focused: true } } }), + stderr: '', + }; + } + if (args.includes('list') && args.includes('--status')) { + return { stdout: JSON.stringify({ count: 5 }), stderr: '' }; + } + return { stdout: JSON.stringify({ workItems: [] }), stderr: '' }; + }); +} + +async function quit(p: Promise): Promise { + dataHandler?.(Buffer.from('q')); + await p; +} + +const OPEN_ICON = '\u{1F513}'; // 🔓 — the open-status icon +const AUDIT_UNKNOWN_ICON = '\u{2753}'; // ❓ — audit-unknown icon (metadata panel) + +describe('worklist — showIcons gating through runWorklistTui', () => { + it('renders item icons by default (backwards compatible)', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeExecMock() as any); + + const fetcher = vi.fn(async (): Promise => [ + { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea' }, + ]); + + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: false, + autoSync: false, + showHelpText: false, + }); + await vi.advanceTimersByTimeAsync(0); + + const output = writes.join(''); + expect(output).toContain(OPEN_ICON); + expect(output).toContain('WL-1'); + expect(output).not.toContain('[OPEN]'); + expect(output).toContain(AUDIT_UNKNOWN_ICON); // metadata panel audit icon still shown + + await quit(p); + }); + + it('omits item icons when getShowIcons returns false', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeExecMock() as any); + + const fetcher = vi.fn(async (): Promise => [ + { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea', priority: 'high' }, + ]); + + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: false, + autoSync: false, + showHelpText: false, + getShowIcons: () => false, + }); + await vi.advanceTimersByTimeAsync(0); + + const output = writes.join(''); + expect(output).not.toContain(OPEN_ICON); + expect(output).toContain('[OPEN]'); // status text fallback + expect(output).toContain('WL-1'); + expect(output).not.toContain(AUDIT_UNKNOWN_ICON); // metadata panel audit icon + expect(output).toContain('[?]'); // audit text fallback + + await quit(p); + }); + + it('applies a showIcons change on the next render (per-render re-read)', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeExecMock() as any); + + // Mutable flag standing in for the settings file on disk: the getter + // re-reads it on every render, so flipping it between renders applies + // without a plugin restart (mirrors getShowHelpText behavior). + let showIcons = true; + const fetcher = vi.fn(async (): Promise => [ + { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea' }, + ]); + + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + getShowIcons: () => showIcons, + }); + await vi.advanceTimersByTimeAsync(0); + fetcher.mockClear(); + + // First render (initial load) — icons shown. + expect(writes.join('')).toContain(OPEN_ICON); + + // User edits the config to disable icons, then the next refresh renders. + showIcons = false; + writes = []; // ignore the initial render — assert only the refresh render + await vi.advanceTimersByTimeAsync(30_000); + + const output = writes.join(''); + expect(output).not.toContain(OPEN_ICON); + expect(output).toContain('[OPEN]'); + expect(output).toContain('WL-1'); + + await quit(p); + }); +}); diff --git a/packages/herdr/src/worklist.test.ts b/packages/herdr/src/worklist.test.ts index e644f0d05..9d817c5d9 100644 --- a/packages/herdr/src/worklist.test.ts +++ b/packages/herdr/src/worklist.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs'; import { WorkItemListState, getTermSize, @@ -16,6 +17,7 @@ import { formatCodeFreezeDialog, ANSI, createListRenderer, + renderDowntimeStatus, isChordLeader, processChordInput, createChordState, @@ -25,11 +27,18 @@ import { formatMetadataPanel, formatTimestamp, buildMetaRows, + resolveKeyFilePath, + formatDetailContent, + formatDetailView, + fetchItemsForView, + formatChordHintsForHelp, + resolvePodcastTarget, } from './worklist.js'; +import type { DowntimeWorker } from './downtime-worker.js'; import { setLogPath, resetLogPath, recordCommand, getLastCommand } from './command-log.js'; import { loadShortcutConfig, ShortcutRegistry } from './shortcut-config.js'; -import { regroupWorkItems } from './grouping.js'; -import type { WorkItem } from './fetcher.js'; +import { regroupWorkItems, extractFilePaths } from './grouping.js'; +import { setWorklogDir, resetWorklogDir, setExecFileAsync, resetExecFileAsync, type WorkItem } from './fetcher.js'; // ── ANSI helpers ─────────────────────────────────────────────────────── // Regression test: the sync-failed status indicator uses ANSI.yellow @@ -117,7 +126,7 @@ describe('createListRenderer — line-count invariant', () => { const merged: WorkItem[] = [ { ...makeItem('NEXT-PLAN'), stage: 'plan_complete', priority: 'high', group: 2, groupLabel: 'Group 1' }, { ...makeItem('NEXT-REVIEW'), stage: 'in_review', priority: 'medium', status: 'in-progress', group: 5, groupLabel: 'In Review' }, - { ...makeItem('NEXT-OTHER'), stage: 'in_progress', priority: 'medium', group: 3, groupLabel: 'Other' }, + { ...makeItem('NEXT-OTHER'), stage: 'custom', priority: 'medium', group: 3, groupLabel: 'Other' }, // Mandatory wl list subsets — no group metadata. { ...makeItem('LIST-CRIT'), stage: 'plan_complete', priority: 'critical' }, { ...makeItem('LIST-REV'), stage: 'in_review', priority: 'medium', status: 'completed' }, @@ -132,6 +141,70 @@ describe('createListRenderer — line-count invariant', () => { }); }); +// ── Filter chrome removal (WL-0MSGTSPXK007POB1) ──────────────────────── +// The list mode no longer emits a blank line after the header/banner nor a +// standalone filter status bar; the active stage filter is indicated in the +// header only (via filterLabel). The two freed rows are given back to the +// item list (page size 11 → 13 on 80x24). + +describe('createListRenderer — no blank line, no filter bar', () => { + const renderer = createListRenderer(); + + it('does not render a blank line between the header and the first item', () => { + const output = renderer([makeItem('A'), makeItem('B'), makeItem('C')], 0, 0, TERM_80x24, null, 'list', null); + const lines = output.split('\n'); + expect(lines[0]).toContain('Work Items'); + expect(lines[1].trim()).not.toBe(''); + expect(lines[1]).toContain('A'); + }); + + it('does not render a blank line between the code-freeze banner and the first item', () => { + const output = renderer( + [makeItem('A'), makeItem('B')], + 0, + 0, + TERM_80x24, + null, + 'list', + null, + undefined, + null, + 0, + false, + undefined, + undefined, + 0, + false, + true, // codeFreezeActive + ); + const lines = output.split('\n'); + expect(lines[0]).toContain('Work Items'); + expect(lines[1]).toContain('CODE FREEZE'); + expect(lines[2].trim()).not.toBe(''); + expect(lines[2]).toContain('A'); + }); + + it('renders no standalone filter bar when unfiltered', () => { + const output = renderer([makeItem('A')], 0, 0, TERM_80x24, null, 'list', null); + expect(output).not.toContain('No filter'); + expect(output).not.toContain('press [f]'); + }); + + it('renders no standalone filter bar when filtered (header carries the indication)', () => { + const output = renderer([makeItem('A')], 0, 0, TERM_80x24, 'in_review', 'list', null); + expect(output).not.toMatch(/Filter: /); + }); + + it('still indicates an active stage filter in the header', () => { + const output = renderer([makeItem('A')], 0, 0, TERM_80x24, 'in_review', 'list', null); + const firstLine = output.split('\n')[0]; + expect(firstLine).toContain('Work Items'); + expect(firstLine).toContain('(filtered: in_review)'); + const unfiltered = renderer([makeItem('A')], 0, 0, TERM_80x24, null, 'list', null); + expect(unfiltered.split('\n')[0]).not.toContain('filtered:'); + }); +}); + describe('WorkItemListState.refreshItems — preserve selection by ID', () => { let items: WorkItem[]; @@ -286,6 +359,105 @@ describe('WorkItemListState.refreshItems — preserve selection by ID', () => { expect(state.getFlattenedItems()[state.selectedIndex].id).toBe('CHILD-A1'); }); + it('keeps expanded children visible when refresh supplies new objects without children (production fetcher shape)', () => { + // The production fetcher (normalizeItem) never populates `children` on + // top-level items — each refresh returns NEW object references that only + // carry childCount. This test models that shape (WL-0MSBVBNGH002RDP5). + const parentA = makeItem('PARENT-A', 'idea'); + parentA.children = [makeItem('CHILD-A1'), makeItem('CHILD-A2')]; + parentA.childCount = 2; + + const parentB = makeItem('PARENT-B', 'in_progress'); + parentB.children = [makeItem('CHILD-B1')]; + parentB.childCount = 1; + + const state = new WorkItemListState([parentA, parentB], TERM_80x24); + state.toggleExpand('PARENT-A'); + + // Flattened: [PARENT-A, CHILD-A1, CHILD-A2, PARENT-B] + expect(state.getFlattenedItems().length).toBe(4); + expect(state.getFlattenedItems()[1].id).toBe('CHILD-A1'); + + // Refresh with brand-new objects that LACK `children` — exactly what the + // fetcher returns (children are fetched separately for expanded parents). + const freshParentA = { ...makeItem('PARENT-A', 'idea'), childCount: 2 }; + const freshParentB = { ...makeItem('PARENT-B', 'in_progress'), childCount: 1 }; + state.refreshItems([freshParentB, freshParentA]); + + // Expanded children must remain in the flattened view — no momentary + // collapse window after the swap. + expect(state.getFlattenedItems().map((i) => i.id)).toEqual([ + 'PARENT-B', + 'PARENT-A', + 'CHILD-A1', + 'CHILD-A2', + ]); + }); + + it('restores selection on a child of an expanded parent after a children-less refresh', () => { + // A selected CHILD must survive a refresh that swaps in children-less + // objects (previously the new flattened list lost the child, so selection + // jumped to the top of the list) — WL-0MSBVBNGH002RDP5 AC-4. + const parentA = makeItem('PARENT-A', 'idea'); + parentA.children = [makeItem('CHILD-A1'), makeItem('CHILD-A2')]; + parentA.childCount = 2; + + const parentB = makeItem('PARENT-B', 'in_progress'); + parentB.childCount = 1; + + const state = new WorkItemListState([parentA, parentB], TERM_80x24); + state.toggleExpand('PARENT-A'); + + // Select CHILD-A1 (flattened index 1). + state.selectedIndex = 1; + expect(state.getFlattenedItems()[1].id).toBe('CHILD-A1'); + + // Refresh with children-less NEW objects, reordered (B first). + const freshParentA = { ...makeItem('PARENT-A', 'idea'), childCount: 2 }; + const freshParentB = { ...makeItem('PARENT-B', 'in_progress'), childCount: 1 }; + state.refreshItems([freshParentB, freshParentA]); + + // CHILD-A1 is still visible and selected (now flattened index 2 after + // the reorder) — the selection followed the child, not the top of the list. + expect(state.getFlattenedItems()[2].id).toBe('CHILD-A1'); + expect(state.getFlattenedItems()[state.selectedIndex].id).toBe('CHILD-A1'); + }); + + it('does not overwrite fresh children already attached to the new objects (carry-over only fills gaps)', () => { + // doRefresh attaches freshly fetched children to the new parent objects + // BEFORE refreshItems; the carry-over must never clobber fresh data with + // stale children (WL-0MSBVBNGH002RDP5 AC-3 freshness). + const parentA = makeItem('PARENT-A', 'idea'); + parentA.children = [makeItem('CHILD-OLD')]; + parentA.childCount = 1; + + const state = new WorkItemListState([parentA], TERM_80x24); + state.toggleExpand('PARENT-A'); + + const freshParentA = { ...makeItem('PARENT-A', 'idea'), childCount: 1 }; + freshParentA.children = [makeItem('CHILD-FRESH')]; + state.refreshItems([freshParentA]); + + expect(state.getFlattenedItems().map((i) => i.id)).toEqual(['PARENT-A', 'CHILD-FRESH']); + }); + + it('does not carry children over for a parent that no longer exists after refresh', () => { + const parentA = makeItem('PARENT-A', 'idea'); + parentA.children = [makeItem('CHILD-A1')]; + parentA.childCount = 1; + + const state = new WorkItemListState([parentA], TERM_80x24); + state.toggleExpand('PARENT-A'); + expect(state.getFlattenedItems().length).toBe(2); + + // Refresh: PARENT-A is gone; only PARENT-B remains (no children). + const freshParentB = { ...makeItem('PARENT-B', 'in_progress'), childCount: 0 }; + state.refreshItems([freshParentB]); + + // No orphan children may leak into the flattened view. + expect(state.getFlattenedItems().map((i) => i.id)).toEqual(['PARENT-B']); + }); + it('prefers the selected item ID over the collapsed child position', () => { // Test that when parent is collapsed and then refreshed with expanded // children, the same child ID is found in the new flattened list @@ -350,6 +522,25 @@ describe('executeResolvedCommand', () => { expect(state.activeFilter).toBe('idea'); }); + it('routes unknown /wl stage arguments to the callback (error notification, no crash)', () => { + const state = new WorkItemListState([makeItem('A')], TERM_80x24); + const onCommand = vi.fn(); + const result = executeResolvedCommand('/wl bogus', state, onCommand); + expect(result).toBe('callback'); + expect(onCommand).toHaveBeenCalledWith('/wl bogus', undefined); + expect(state.activeFilter).toBeNull(); + }); + + it('returns dispatched for /wl with no arguments and clears the filter (sprint)', () => { + const state = new WorkItemListState([makeItem('A', 'idea')], TERM_80x24); + state.applyFilter('idea'); + const onCommand = vi.fn(); + const result = executeResolvedCommand('/wl', state, onCommand); + expect(result).toBe('dispatched'); + expect(state.activeFilter).toBeNull(); + expect(onCommand).not.toHaveBeenCalled(); + }); + it('returns dispatched for /skill:implement with resolved ', () => { const state = new WorkItemListState([makeItem('TEST-123')], TERM_80x24); state.selectedIndex = 0; @@ -383,6 +574,41 @@ describe('dispatchChordCommand', () => { expect(state.activeFilter).toBe('in_review'); }); + it('accepts canonical stage names and the progress alias for /wl commands', () => { + const state = new WorkItemListState([makeItem('A', 'idea')], TERM_80x24); + expect(dispatchChordCommand('/wl progress', state)).toBe(true); + expect(state.activeFilter).toBe('in_progress'); + expect(dispatchChordCommand('/wl intake_complete', state)).toBe(true); + expect(state.activeFilter).toBe('intake_complete'); + expect(dispatchChordCommand('/wl plan_complete', state)).toBe(true); + expect(state.activeFilter).toBe('plan_complete'); + expect(dispatchChordCommand('/wl in_review', state)).toBe(true); + expect(state.activeFilter).toBe('in_review'); + }); + + it('leaves unknown /wl stage arguments unhandled (no crash, no filter)', () => { + const state = new WorkItemListState([makeItem('A', 'idea')], TERM_80x24); + const result = dispatchChordCommand('/wl bogus', state); + expect(result).toBe(false); + expect(state.activeFilter).toBeNull(); + }); + + it('clears the stage filter for /wl with no arguments (sprint, WL-0MSGSE15000746F7)', () => { + const state = new WorkItemListState([makeItem('A', 'idea')], TERM_80x24); + state.applyFilter('idea'); + expect(state.activeFilter).toBe('idea'); + const result = dispatchChordCommand('/wl', state); + expect(result).toBe(true); + expect(state.activeFilter).toBeNull(); + }); + + it('shows the sprint chord in the f-chord help line (WL-0MSGSE15000746F7)', () => { + const registry = loadShortcutConfig(); + const chords = registry.getChordByPrefix(['f'], 'list', undefined, false); + const hints = formatChordHintsForHelp(chords, ['f']); + expect(hints).toContain('s:sprint'); + }); + it('routes agent commands through onCommand', () => { const state = new WorkItemListState([makeItem('TEST-123')], TERM_80x24); state.selectedIndex = 0; @@ -448,6 +674,48 @@ describe('dispatchChordCommand', () => { }); }); +describe('fetchItemsForView — stage-filtered fetch', () => { + beforeEach(() => { + resetExecFileAsync(); + }); + + it('fetches all open root items in the stage when a filter is active', async () => { + const stageItems = [makeItem('A', 'idea'), makeItem('B', 'idea')]; + const mockFn = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ workItems: stageItems }), + stderr: '', + }); + setExecFileAsync(mockFn as any); + const defaultFetcher = vi.fn().mockResolvedValue([makeItem('C')]); + + const items = await fetchItemsForView('idea', defaultFetcher); + + expect(items.map((i) => i.id)).toEqual(['A', 'B']); + expect(defaultFetcher).not.toHaveBeenCalled(); + const callArgs = mockFn.mock.calls[0][1] as string[]; + expect(callArgs).toContain('list'); + expect(callArgs[callArgs.indexOf('--status') + 1]).toBe('open'); + expect(callArgs[callArgs.indexOf('--stage') + 1]).toBe('idea'); + expect(callArgs).toContain('--root-only'); + }); + + it('uses the default fetcher when no filter is active', async () => { + const defaultFetcher = vi.fn().mockResolvedValue([makeItem('C')]); + const items = await fetchItemsForView(null, defaultFetcher); + expect(items.map((i) => i.id)).toEqual(['C']); + expect(defaultFetcher).toHaveBeenCalledTimes(1); + }); + + it('falls back to the default fetcher when the stage fetch fails', async () => { + const mockFn = vi.fn().mockRejectedValue(new Error('wl failed')); + setExecFileAsync(mockFn as any); + const defaultFetcher = vi.fn().mockResolvedValue([makeItem('C')]); + + const items = await fetchItemsForView('idea', defaultFetcher); + expect(items.map((i) => i.id)).toEqual(['C']); + }); +}); + describe('Chord-complete error notification handling', () => { it('tests that executeResolvedCommand noop is returned correctly when no item selected', () => { // This tests the underlying behavior that the chord-complete handler @@ -594,6 +862,197 @@ describe('code-freeze shortcut filtering — worklist integration', () => { }); }); +// ── Issue-type shortcut filtering — worklist integration (WL-0MSKH1J0R003BM2M) +// +// The selected work item's issueType is threaded into the shortcut lookup and +// hint paths so a type-gated chord (e.g. a project-local `w` bound to +// `wiki-podcast-script` for `podcast` items) is hidden on non-matching types, +// and the bundled code-workflow chords n/p/i are hidden on podcast items. + +describe('issue-type shortcut filtering — worklist integration', () => { + let registry: ShortcutRegistry; + + beforeEach(() => { + registry = loadShortcutConfig(); + }); + + it('treats i as a chord leader only for code item types', () => { + expect(isChordLeader('i', registry, false, 'feature')).toBe(true); + expect(isChordLeader('i', registry, false, 'podcast')).toBe(false); + expect(isChordLeader('i', registry, false, 'docs')).toBe(false); + }); + + it('treats n and p as chord leaders only for code item types', () => { + expect(isChordLeader('n', registry, false, 'bug')).toBe(true); + expect(isChordLeader('n', registry, false, 'podcast')).toBe(false); + expect(isChordLeader('p', registry, false, 'task')).toBe(true); + expect(isChordLeader('p', registry, false, 'podcast')).toBe(false); + }); + + it('keeps generic chords as leaders on every type', () => { + expect(isChordLeader('r', registry, false, 'podcast')).toBe(true); + expect(isChordLeader('c', registry, false, 'podcast')).toBe(true); + expect(isChordLeader('s', registry, false, 'podcast')).toBe(true); + expect(isChordLeader('a', registry, false, 'podcast')).toBe(true); + expect(isChordLeader('u', registry, false, 'podcast')).toBe(true); + }); + + it('does not resolve the i chord via processChordInput on a podcast item', () => { + const chordState = createChordState(); + chordState.pendingKeys = ['i']; + const result = processChordInput(chordState, 'i', registry, 'list', 'plan_complete', false, 'podcast'); + expect(result).toBe('chord-cancel'); + expect(chordState.resolvedCommand).toBeNull(); + }); + + it('resolves the i chord via processChordInput on a code item', () => { + const chordState = createChordState(); + const result = processChordInput(chordState, 'i', registry, 'list', 'plan_complete', false, 'feature'); + expect(result).toBe('chord-complete'); + expect(chordState.resolvedCommand).toBe('/skill:implement '); + }); + + it('omits code-workflow chords from stage entries used for footer hints on podcast items', () => { + const podcastEntries = registry.getEntriesForStage('plan_complete', false, 'podcast'); + expect(podcastEntries.some(e => e.chord[0] === 'i')).toBe(false); + expect(podcastEntries.some(e => e.chord[0] === 'p')).toBe(false); + const codeEntries = registry.getEntriesForStage('plan_complete', false, 'feature'); + expect(codeEntries.some(e => e.chord[0] === 'i')).toBe(true); + }); + + it('keeps generic housekeeping chords in footer hints on podcast items', () => { + const podcastEntries = registry.getEntriesForStage('in_review', false, 'podcast'); + const chords = podcastEntries.map(e => e.chord.join('')); + expect(chords).toContain('aa'); + expect(chords).toContain('ay'); + expect(chords).toContain('ar'); + expect(chords).toContain('r'); + }); + + it('excludes a type-gated local chord on non-matching types via the merged registry', () => { + // A project-local podcast-gated chord merges over the bundled defaults; + // verify the merged registry honors the gating per item type. + const root = mkdtempSync(join(tmpdir(), 'herdr-issue-type-')); + try { + writeFileSync(join(root, 'shortcuts.json'), JSON.stringify([ + { chord: ['w'], command: '/skill:wiki-podcast-script ', view: 'both', label: 'write script', work_item_types: ['podcast'] }, + ])); + const merged = loadShortcutConfig(root); + expect(merged.lookupChord(['w'], 'list', undefined, false, 'podcast')).toBe('/skill:wiki-podcast-script '); + expect(merged.lookupChord(['w'], 'list', undefined, false, 'feature')).toBeUndefined(); + // Bundled code chords still gated by type in the merged registry. + expect(merged.lookupChord(['i'], 'list', undefined, false, 'feature')).toBe('/skill:implement '); + expect(merged.lookupChord(['i'], 'list', undefined, false, 'podcast')).toBeUndefined(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +// ── Podcast-progression dispatch (OSL-0MSKFXM380098LFL / OSL-0MSHFQ51L009IUOS) +// +// The project-local `w` (wiki-podcast-script) and `t` (wiki-tts-generate) +// chords use `` / `` markers resolved from +// the selected item's `Key Files:` + lifecycle context at dispatch time. + +describe('resolvePodcastTarget — podcast-progression dispatch', () => { + const sourcedItem: WorkItem = { + id: 'OSL-1', + title: 'Episode', + status: 'open', + stage: 'intake_complete', + issueType: 'podcast', + description: '## Key Files:\n- wiki/syntheses/foo.md\n', + }; + + const draftedItem: WorkItem = { + id: 'OSL-2', + title: 'Episode', + status: 'open', + stage: 'in_review', + issueType: 'podcast', + description: '## Key Files:\n- foo/foo.podcast.md\n', + }; + + const noKeyFilesItem: WorkItem = { + id: 'OSL-3', + title: 'Episode', + status: 'open', + stage: 'intake_complete', + issueType: 'podcast', + description: 'No key files here.', + }; + + it('returns the command unchanged when it carries no podcast markers', async () => { + const result = await resolvePodcastTarget('/skill:audit ', sourcedItem); + expect(result).toEqual({ command: '/skill:audit ' }); + }); + + it('errors when no item is selected', async () => { + const result = await resolvePodcastTarget('/skill:wiki-podcast-script ', null); + expect(result.error).toMatch(/no work item selected/i); + expect(result.command).toBeUndefined(); + }); + + it('resolves to --doc --force-single on sourced episodes', async () => { + const result = await resolvePodcastTarget('/skill:wiki-podcast-script ', sourcedItem); + expect(result).toEqual({ command: '/skill:wiki-podcast-script --doc wiki/syntheses/foo.md --force-single' }); + }); + + it('errors on a sourced episode with no synthesis in Key Files', async () => { + const result = await resolvePodcastTarget('/skill:wiki-podcast-script ', noKeyFilesItem); + expect(result.error).toMatch(/no source synthesis/i); + }); + + it('resolves to --rewrite when open note children exist', async () => { + const children = [ + { id: 'OSL-2-N1', title: 'Note', status: 'open' } as WorkItem, + { id: 'OSL-2-N2', title: 'Done note', status: 'completed' } as WorkItem, + ]; + const result = await resolvePodcastTarget('/skill:wiki-podcast-script ', draftedItem, async () => children); + expect(result).toEqual({ command: '/skill:wiki-podcast-script --rewrite foo/foo.podcast.md' }); + }); + + it('belt-and-braces: errors when a script exists but there are no open notes', async () => { + const children = [ + { id: 'OSL-2-N2', title: 'Done note', status: 'completed' } as WorkItem, + ]; + const result = await resolvePodcastTarget('/skill:wiki-podcast-script ', draftedItem, async () => children); + expect(result.error).toMatch(/already present/); + expect(result.command).toBeUndefined(); + }); + + it('errors when rewrite is requested but no script is in Key Files', async () => { + const noScriptItem: WorkItem = { ...draftedItem, description: '## Key Files:\n- wiki/syntheses/foo.md\n' }; + const result = await resolvePodcastTarget('/skill:wiki-podcast-script ', noScriptItem, async () => [ + { id: 'X', title: 'Note', status: 'open' } as WorkItem, + ]); + expect(result.error).toMatch(/no podcast script/i); + }); + + it('resolves to a wiki-dir-relative podcast file path', async () => { + const result = await resolvePodcastTarget('/skill:wiki-tts-generate --podcast-file ', draftedItem); + expect(result).toEqual({ command: '/skill:wiki-tts-generate --podcast-file podcast/foo/foo.podcast.md' }); + }); + + it('keeps an already-wiki-relative path as-is', async () => { + const wikiItem: WorkItem = { ...draftedItem, description: '## Key Files:\n- wiki/podcast/foo/foo.podcast.md\n' }; + const result = await resolvePodcastTarget('/skill:wiki-tts-generate --podcast-file ', wikiItem); + expect(result).toEqual({ command: '/skill:wiki-tts-generate --podcast-file wiki/podcast/foo/foo.podcast.md' }); + }); + + it('errors on when no script exists', async () => { + const result = await resolvePodcastTarget('/skill:wiki-tts-generate --podcast-file ', sourcedItem); + expect(result.error).toMatch(/no podcast script/i); + }); + + it('errors on for a synthesis-only episode (script must exist first)', async () => { + const synthItem: WorkItem = { ...sourcedItem, description: '## Key Files:\n- wiki/syntheses/foo.md\n' }; + const result = await resolvePodcastTarget('/skill:wiki-tts-generate --podcast-file ', synthItem); + expect(result.error).toMatch(/no podcast script/i); + }); +}); + // ── Code Freeze: banner rendering ──────────────────────────────────────── // The banner must appear only when freeze is active and must never break the // `rows - 1` line-count invariant (WL-0MSAAON63003N6LO). @@ -895,10 +1354,11 @@ describe('handleKeypress — metadata scroll keys', () => { state.pageUp(); expect(state.selectedIndex).toBe(0); - // pageDown advances by the list page size (11 rows on 80x24) + // pageDown advances by the list page size (13 rows on 80x24 — the + // freed blank + filter-bar chrome rows are given back to the list) state.selectedIndex = 0; state.pageDown(); - expect(state.selectedIndex).toBe(11); + expect(state.selectedIndex).toBe(13); // pageDown clamps at the last item state.selectedIndex = 29; @@ -908,7 +1368,7 @@ describe('handleKeypress — metadata scroll keys', () => { // pageUp moves back exactly one page state.selectedIndex = 23; state.pageUp(); - expect(state.selectedIndex).toBe(12); + expect(state.selectedIndex).toBe(10); }); it('goToFirst/goToLast jump to the ends via state', () => { @@ -936,12 +1396,12 @@ describe('handleKeypress — metadata scroll keys', () => { // PgUp (\x1b[5~) → pageup expect(handleKeypress(state, '\x1b[5~', TERM_80x24)).toBe('pageup'); - expect(state.selectedIndex).toBe(18); // 29 - 11 + expect(state.selectedIndex).toBe(16); // 29 - 13 // PgDn (\x1b[6~) → pagedown state.selectedIndex = 0; expect(handleKeypress(state, '\x1b[6~', TERM_80x24)).toBe('pagedown'); - expect(state.selectedIndex).toBe(11); + expect(state.selectedIndex).toBe(13); }); }); @@ -1127,3 +1587,463 @@ describe('buildMetaRows — timestamps rendered via formatTimestamp', () => { expect(rows.has('Audited At')).toBe(false); }); }); + +// ── Downtime status indicator (WL-0MSF49FMW009M06K, F4) ─────────────── + +describe('renderDowntimeStatus', () => { + it('renders nothing when no worker is present', () => { + expect(renderDowntimeStatus(undefined)).toBe(''); + }); + + it('renders the continuous idle duration as m:ss', () => { + const worker = { + idleSince: Date.now() - 192_000, // 3:12 + dispatching: false, + enabled: true, + } as unknown as DowntimeWorker; + const status = renderDowntimeStatus(worker); + expect(status).toContain('downtime idle 3:12'); + expect(status).toContain('⏳'); + }); + + it('renders the dispatching state', () => { + const worker = { + idleSince: Date.now() - 60_000, + dispatching: true, + enabled: true, + } as unknown as DowntimeWorker; + expect(renderDowntimeStatus(worker)).toContain('downtime dispatching'); + }); + + it('renders the disabled state', () => { + const worker = { + idleSince: null, + dispatching: false, + enabled: false, + } as unknown as DowntimeWorker; + expect(renderDowntimeStatus(worker)).toContain('downtime disabled'); + }); + + it('renders the paused state during the no-candidate cooldown (no stale idle duration)', () => { + const worker = { + idleSince: null, + dispatching: false, + enabled: true, + paused: true, + } as unknown as DowntimeWorker; + const status = renderDowntimeStatus(worker); + expect(status).toContain('downtime paused'); + expect(status).not.toContain('downtime idle'); + }); + + it('renders busy when the proxy is not idle', () => { + const worker = { + idleSince: null, + dispatching: false, + enabled: true, + } as unknown as DowntimeWorker; + expect(renderDowntimeStatus(worker)).toContain('downtime busy'); + }); + + it('appends the status inline to the list header without adding a row', () => { + const items = [makeItem('WL-1', 'open')]; + const renderer = createListRenderer(); + const output = renderer( + items, 0, 0, TERM_80x24, null, 'list', null, + undefined, null, 0, true, undefined, undefined, 0, false, false, + 0, undefined, undefined, + ' [⏳ downtime idle 0:05]', + ); + expect(output).toContain('Work Items'); + expect(output).toContain('[⏳ downtime idle 0:05]'); + expect(output.split('\n').length).toBeLessThanOrEqual(TERM_80x24.rows - 1); + }); +}); + +// ── showIcons gating (WL-0MSBV4RYO008JL70) ───────────────────────────── +// The renderer consults the getShowIcons getter on EVERY render, so a +// showIcons setting change applies without a plugin restart (same pattern +// as getShowHelpText). When the getter returns false, item lines render +// text fallbacks ([OPEN], [IDEA], ...) instead of emoji icons. + +const OPEN_ICON = '\u{1F513}'; // 🔓 — the open-status icon +const AUDIT_UNKNOWN_ICON = '\u{2753}'; // ❓ — audit-unknown icon (metadata panel) + +describe('createListRenderer — showIcons gating', () => { + it('renders emoji icons by default (backwards compatible)', () => { + const renderer = createListRenderer(); + const output = renderer([makeItem('A', 'idea')], 0, 0, TERM_80x24, null, 'list', null); + expect(output).toContain(OPEN_ICON); + expect(output).not.toContain('[OPEN]'); + }); + + it('renders text fallbacks instead of icons when getShowIcons returns false', () => { + const renderer = createListRenderer(() => false); + const output = renderer([makeItem('A', 'idea')], 0, 0, TERM_80x24, null, 'list', null); + expect(output).not.toContain(OPEN_ICON); + expect(output).toContain('[OPEN]'); // status text fallback + expect(output).toContain('[IDEA]'); // stage text fallback + expect(output).not.toContain(AUDIT_UNKNOWN_ICON); // metadata panel audit icon + expect(output).toContain('[?]'); // audit text fallback + }); + + it('re-reads the getter on every render (settings re-read path)', () => { + let showIcons = true; + const renderer = createListRenderer(() => showIcons); + + const withIcons = renderer([makeItem('A', 'idea')], 0, 0, TERM_80x24, null, 'list', null); + expect(withIcons).toContain(OPEN_ICON); + + // Simulate the user editing the config: flip the flag, render again — + // the getter is consulted per-render, so the change applies immediately. + showIcons = false; + const withoutIcons = renderer([makeItem('A', 'idea')], 0, 0, TERM_80x24, null, 'list', null); + expect(withoutIcons).not.toContain(OPEN_ICON); + expect(withoutIcons).toContain('[OPEN]'); + }); +}); + +// ── Key Files md path resolution (WL-0MSGEA9AY0080V4Q) ───────────────── +// Regression: readKeyFile resolved `Key Files:` paths against the plugin +// pane's process.cwd() (the herdr plugin source dir), NOT the worklog root, +// so episode .podcast.md files never rendered in the detail view. +// resolveKeyFilePath must prefer the configured worklog root, then the +// legacy podcast-relative base (.llm-wiki/wiki/podcast/), and only fall +// back to process.cwd() as a last resort. Fail-open: no candidate on disk +// yields null so the detail view falls back to the raw description. + +describe('resolveKeyFilePath (Key Files md resolution)', () => { + let root: string; + let originalCwd: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'herdr-keyfiles-')); + // Simulate configureWorklogTarget: the resolved worklog root is the + // parent of the configured .worklog dir + // (setWorklogDir(join(wlRoot, '.worklog'))). + setWorklogDir(join(root, '.worklog')); + originalCwd = process.cwd(); + }); + + afterEach(() => { + resetWorklogDir(); + process.chdir(originalCwd); + rmSync(root, { recursive: true, force: true }); + }); + + it('resolves Key Files paths against the worklog root, not process.cwd()', () => { + writeFileSync(join(root, 'episode.podcast.md'), '# Episode'); + const resolved = resolveKeyFilePath('episode.podcast.md'); + expect(resolved).toBe(join(root, 'episode.podcast.md')); + }); + + it('falls back to the legacy podcast-relative base under the worklog root', () => { + const podcastDir = join(root, '.llm-wiki', 'wiki', 'podcast', 'irish-folklore-fine-tuning'); + mkdirSync(podcastDir, { recursive: true }); + writeFileSync(join(podcastDir, 'irish-folklore-fine-tuning.podcast.md'), '# Episode'); + const resolved = resolveKeyFilePath('irish-folklore-fine-tuning/irish-folklore-fine-tuning.podcast.md'); + expect(resolved).toBe(join(podcastDir, 'irish-folklore-fine-tuning.podcast.md')); + }); + + it('prefers the worklog root over the podcast base when both exist', () => { + const podcastDir = join(root, '.llm-wiki', 'wiki', 'podcast'); + mkdirSync(podcastDir, { recursive: true }); + writeFileSync(join(root, 'shared.podcast.md'), 'root copy'); + writeFileSync(join(podcastDir, 'shared.podcast.md'), 'podcast copy'); + const resolved = resolveKeyFilePath('shared.podcast.md'); + expect(resolved).toBe(join(root, 'shared.podcast.md')); + }); + + it('falls back to process.cwd() as a last resort when no worklog dir is configured', () => { + resetWorklogDir(); + const cwdDir = mkdtempSync(join(tmpdir(), 'herdr-keyfiles-cwd-')); + process.chdir(cwdDir); + try { + writeFileSync(join(cwdDir, 'notes.md'), '# Notes'); + const resolved = resolveKeyFilePath('notes.md'); + expect(resolved).toBe(join(cwdDir, 'notes.md')); + } finally { + process.chdir(originalCwd); + rmSync(cwdDir, { recursive: true, force: true }); + } + }); + + it('returns null when no candidate exists on disk', () => { + expect(resolveKeyFilePath('missing/file.podcast.md')).toBeNull(); + }); + + it('accepts absolute Key Files paths directly', () => { + writeFileSync(join(root, 'abs.podcast.md'), '# Abs'); + const abs = join(root, 'abs.podcast.md'); + expect(resolveKeyFilePath(abs)).toBe(abs); + }); +}); + +// ── Related Docs row + detail ToC + open-in-viewer (WL-0MSGTLSUT002NF29) ─ +// Test-first contract (WL-0MSHWHP0S0036DDU): these tests are written BEFORE +// the Related Docs row (WL-0MSHWHRIF001YHF8) and detail ToC +// (WL-0MSHWHULZ001FL8I) implementations land. New-behavior assertions are +// expected to be RED at creation time and turn GREEN once the row and ToC +// features are implemented. Tests exercise the existing public API +// (buildMetaRows / formatMetadataPanel / formatDetailContent / +// formatDetailView / handleKeypress / WorkItemListState / resolveKeyFilePath) +// plus the ToC state fields that the ToC feature adds to WorkItemListState: +// - detailToCIndex : selected ToC entry (0-based; default 0) +// - detailToCFocus : true when keyboard focus is on the ToC, false +// when focus is on the document scroll region +// - detailRenderedIndex: which Key File's content is shown in the md +// viewer (default 0 = first file, auto-render) +// - handleKeypress in detail mode: j/k and arrow keys move detailToCIndex +// when detailToCFocus is true; navigating past the last ToC entry +// transfers focus to document scrolling (detailToCFocus = false); k at +// the top of the document returns focus to the ToC. Enter (\r) renders +// mdPaths[detailToCIndex] by setting detailRenderedIndex. +// formatDetailContent/formatDetailView accept optional trailing params: +// (…, detailToCIndex, detailToCFocus, detailRenderedIndex) and render a +// pinned "Related Docs" ToC at the top of the detail view when the item +// has ≥1 .md Key File. + +/** Build an item whose description carries a **Key Files:** section. */ +function makeKeyFilesItem(id: string, paths: string[]): WorkItem { + return { + ...makeItem(id), + description: [ + `# ${id} title`, + '', + '**Key Files:**', + ...paths.map(p => `- \`${p}\``), + '', + '## Notes', + 'Body text for the description section.', + ].join('\n'), + }; +} + +describe('buildMetaRows — Related Docs row (WL-0MSHWHRIF001YHF8)', () => { + it('emits a Related Docs row listing every .md path from Key Files, joined with ", "', () => { + const item = makeKeyFilesItem('WL-REL', ['docs/prd.md', 'docs/episode.podcast.md']); + const rows = new Map(buildMetaRows(item)); + expect(rows.get('Related Docs')).toBe('docs/prd.md, docs/episode.podcast.md'); + }); + + it('omits the row when the item has no Key Files section at all', () => { + const item = makeItem('WL-PLAIN'); + const rows = new Map(buildMetaRows(item)); + expect(rows.has('Related Docs')).toBe(false); + const panel = formatMetadataPanel(item, 80, 20, 0, null).join('\n'); + expect(panel).not.toContain('Related Docs'); + const detail = formatDetailContent(item, 80).join('\n'); + expect(detail).not.toContain('Related Docs'); + }); + + it('omits the row when Key Files contains no .md files', () => { + const item = makeKeyFilesItem('WL-NO-MD', ['src/app.ts', 'data.json']); + const rows = new Map(buildMetaRows(item)); + expect(rows.has('Related Docs')).toBe(false); + const panel = formatMetadataPanel(item, 80, 20, 0, null).join('\n'); + expect(panel).not.toContain('Related Docs'); + const detail = formatDetailContent(item, 80).join('\n'); + expect(detail).not.toContain('Related Docs'); + }); + + it('includes only .md paths from a mixed Key Files list', () => { + const item = makeKeyFilesItem('WL-MIX', ['src/app.ts', 'docs/guide.md', 'data.json', 'docs/api.md']); + const rows = new Map(buildMetaRows(item)); + expect(rows.get('Related Docs')).toBe('docs/guide.md, docs/api.md'); + }); + + it('renders the row in the metadata panel (formatMetadataPanel)', () => { + const item = makeKeyFilesItem('WL-PANEL', ['docs/prd.md', 'docs/episode.podcast.md']); + const joined = formatMetadataPanel(item, 80, 20, 0, null).join('\n'); + expect(joined).toContain('Related Docs'); + expect(joined).toContain('docs/prd.md, docs/episode.podcast.md'); + }); + + it('renders the row in the detail-view metadata table (formatDetailContent)', () => { + const item = makeKeyFilesItem('WL-DETAIL', ['docs/prd.md', 'docs/episode.podcast.md']); + const joined = formatDetailContent(item, 80).join('\n'); + expect(joined).toContain('Related Docs'); + expect(joined).toContain('docs/prd.md, docs/episode.podcast.md'); + }); + + it('truncates very long Related Docs values to the terminal width', () => { + const paths = Array.from({ length: 10 }, (_, i) => `docs/very-long-document-name-${i}.md`); + const item = makeKeyFilesItem('WL-LONG', paths); + const joined = formatMetadataPanel(item, 40, 20, 0, null).join('\n'); + for (const line of joined.split('\n')) { + const visible = line.replace(/\x1b\[[0-9;]*m/g, ''); + expect(visible.length).toBeLessThanOrEqual(40); + } + }); +}); + +describe('detail view ToC for Related Docs (WL-0MSHWHULZ001FL8I)', () => { + const twoMd = () => { + const item = makeKeyFilesItem('WL-TOC', ['docs/prd.md', 'docs/episode.podcast.md']); + // Long body so the document region is scrollable: the focus-transfer + // tests need maxScroll ≥ 2 (j,j scrolls down, k,k returns to the top, + // k returns focus to the ToC). + item.description += '\n\n' + Array.from({ length: 60 }, (_, i) => `lorem ipsum dolor line ${i}`).join('\n'); + return item; + }; + + it('renders a ToC at the top listing every Related Doc when the item has .md Key Files', () => { + const joined = formatDetailContent(twoMd(), 80, undefined, true, 0, true, 0).join('\n'); + expect(joined).toContain('Related Docs'); + expect(joined).toContain('1. docs/prd.md'); + expect(joined).toContain('2. docs/episode.podcast.md'); + }); + + it('renders no ToC when the item has no .md Key Files', () => { + const item = makeKeyFilesItem('WL-NO-TOC', ['src/app.ts']); + const joined = formatDetailContent(item, 80, undefined, true, 0, true, 0).join('\n'); + expect(joined).not.toContain('1. '); + expect(joined).not.toContain('Related Docs'); + }); + + it('marks the focused ToC entry with a focus indicator', () => { + const focusedFirst = formatDetailContent(twoMd(), 80, undefined, true, 0, true, 0).join('\n'); + expect(focusedFirst).toContain('▸ 1. docs/prd.md'); + const focusedSecond = formatDetailContent(twoMd(), 80, undefined, true, 1, true, 0).join('\n'); + expect(focusedSecond).toContain('▸ 2. docs/episode.podcast.md'); + }); + + it('moves ToC selection with j/k and arrow keys via handleKeypress', () => { + const state = new WorkItemListState([twoMd()], TERM_80x24); + state.selectedIndex = 0; + state.selectItem(); + expect(state.mode).toBe('detail'); + expect(state.detailToCIndex).toBe(0); + expect(state.detailToCFocus).toBe(true); + + handleKeypress(state, 'j', TERM_80x24); + expect(state.detailToCIndex).toBe(1); + handleKeypress(state, 'k', TERM_80x24); + expect(state.detailToCIndex).toBe(0); + + handleKeypress(state, '\x1b[B', TERM_80x24); // ↓ + expect(state.detailToCIndex).toBe(1); + handleKeypress(state, '\x1b[A', TERM_80x24); // ↑ + expect(state.detailToCIndex).toBe(0); + }); + + it('clamps ToC selection at the bounds', () => { + const state = new WorkItemListState([twoMd()], TERM_80x24); + state.selectedIndex = 0; + state.selectItem(); + handleKeypress(state, 'k', TERM_80x24); // above first entry — clamp at 0 + expect(state.detailToCIndex).toBe(0); + handleKeypress(state, 'j', TERM_80x24); + handleKeypress(state, 'j', TERM_80x24); // at last entry — stays (focus moves to doc, see below) + expect(state.detailToCIndex).toBe(1); + }); + + it('navigating past the last ToC entry transfers focus to document scrolling', () => { + const state = new WorkItemListState([twoMd()], TERM_80x24); + state.selectedIndex = 0; + state.selectItem(); + handleKeypress(state, 'j', TERM_80x24); // → entry 1 + handleKeypress(state, 'j', TERM_80x24); // past last → doc focus + expect(state.detailToCFocus).toBe(false); + // j now scrolls the document + const before = state.detailScrollOffset; + handleKeypress(state, 'j', TERM_80x24); + expect(state.detailScrollOffset).toBe(before + 1); + }); + + it('navigating up past the top of the document returns focus to the ToC', () => { + const state = new WorkItemListState([twoMd()], TERM_80x24); + state.selectedIndex = 0; + state.selectItem(); + handleKeypress(state, 'j', TERM_80x24); // → entry 1 + handleKeypress(state, 'j', TERM_80x24); // past last → doc focus + expect(state.detailToCFocus).toBe(false); + // Scroll down a couple of lines then back up to the top. + handleKeypress(state, 'j', TERM_80x24); + handleKeypress(state, 'j', TERM_80x24); + handleKeypress(state, 'k', TERM_80x24); + handleKeypress(state, 'k', TERM_80x24); + expect(state.detailScrollOffset).toBe(0); + // k at the top returns focus to the ToC + handleKeypress(state, 'k', TERM_80x24); + expect(state.detailToCFocus).toBe(true); + expect(state.detailToCIndex).toBe(1); + }); + + it('keeps the ToC pinned at the top while the document scrolls', () => { + const item = twoMd(); + // Make the document long enough to scroll well past the ToC. + item.description += '\n\n' + Array.from({ length: 60 }, (_, i) => `lorem ipsum dolor line ${i}`).join('\n'); + const view = formatDetailView(item, 80, 40, 24, undefined, true, 1, false, 0); + const lines = view.split('\n'); + const firstLines = lines.slice(0, 8).join('\n'); + expect(firstLines).toContain('Related Docs'); + expect(firstLines).toContain('1. docs/prd.md'); + }); +}); + +describe('Related Docs — open in markdown viewer (WL-0MSGTLSUT002NF29)', () => { + const readFileFor = (content: Record) => + (filePath: string): string | null => content[filePath] ?? null; + + it('auto-renders the first .md Key File by default (existing behavior preserved)', () => { + const item = makeKeyFilesItem('WL-AUTO', ['docs/prd.md', 'docs/episode.podcast.md']); + const readFile = readFileFor({ 'docs/prd.md': '# PRD content' }); + const joined = formatDetailContent(item, 80, readFile, true, 0, true, 0).join('\n'); + expect(joined).toContain('PRD content'); + }); + + it('Enter on a ToC entry renders that specific file in the markdown viewer', () => { + const item = makeKeyFilesItem('WL-OPEN', ['docs/prd.md', 'docs/episode.podcast.md']); + const state = new WorkItemListState([item], TERM_80x24); + state.selectedIndex = 0; + state.selectItem(); + handleKeypress(state, 'j', TERM_80x24); // ToC → entry 1 + handleKeypress(state, '\r', TERM_80x24); // Enter renders the selected doc + expect(state.detailRenderedIndex).toBe(1); + + const readFile = readFileFor({ + 'docs/prd.md': '# PRD content', + 'docs/episode.podcast.md': '# Episode content', + }); + const joined = formatDetailContent(item, 80, readFile, true, 1, true, 1).join('\n'); + expect(joined).toContain('Episode content'); + expect(joined).not.toContain('PRD content'); + }); + + it('unreadable or missing files fail open without crashing', () => { + const item = makeKeyFilesItem('WL-MISSING', ['docs/ghost.md']); + const joined = formatDetailContent(item, 80, readFileFor({}), true, 0, true, 0).join('\n'); + // No crash; the raw description still renders. + expect(joined).toContain('Body text for the description section.'); + }); + + it('resolves Key Files against the worklog root, never process.cwd()', () => { + const root = mkdtempSync(join(tmpdir(), 'herdr-related-docs-')); + const originalCwd = process.cwd(); + try { + setWorklogDir(join(root, '.worklog')); + mkdirSync(join(root, 'docs'), { recursive: true }); + writeFileSync(join(root, 'docs', 'episode.podcast.md'), '# Worklog root content'); + + // Decoy: same relative path in the plugin CWD — must NOT win. + const cwdDir = mkdtempSync(join(tmpdir(), 'herdr-related-docs-cwd-')); + process.chdir(cwdDir); + mkdirSync(join(cwdDir, 'docs'), { recursive: true }); + writeFileSync(join(cwdDir, 'docs', 'episode.podcast.md'), '# CWD decoy content'); + + const readFile = (filePath: string): string | null => { + const resolved = resolveKeyFilePath(filePath); + return resolved ? readFileSync(resolved, 'utf-8') : null; + }; + const item = makeKeyFilesItem('WL-ROOT', ['docs/episode.podcast.md']); + const joined = formatDetailContent(item, 80, readFile, true, 0, true, 0).join('\n'); + expect(joined).toContain('Worklog root content'); + expect(joined).not.toContain('CWD decoy content'); + } finally { + process.chdir(originalCwd); + resetWorklogDir(); + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index eb54b6f13..3fe78df56 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -10,10 +10,10 @@ * Herdr's pane-based model. */ -import { readFileSync } from 'node:fs'; -import { resolve as resolvePath } from 'node:path'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve as resolvePath } from 'node:path'; -import { fetchChildrenForItem, fetchActionableCount, getWorklogDir, type WorkItem } from './fetcher.js'; +import { fetchChildrenForItem, fetchActionableCount, fetchItemsByStage, getWorklogDir, type WorkItem } from './fetcher.js'; import { isPaneVisible, PollGate, DEFAULT_POLL_GATE_TTL_MS } from './visibility.js'; import { readCodeFreezeState } from './code-freeze.js'; import type { ShortcutRegistry, ShortcutEntry } from './shortcut-config.js'; @@ -25,11 +25,12 @@ import { needsProducerReviewIcon, getIconPrefix, applyStageColour, - iconsEnabled, stageColor, type IconOptions, } from './icons.js'; -import { runSync, createSyncTimer, clampSyncInterval } from './auto-sync.js'; +import { runSync, heartbeatTtlForInterval } from './auto-sync.js'; +import { TaskScheduler, DEFAULT_SCHEDULER_TICK_MS } from './scheduler.js'; +import { DEFAULT_DOWNTIME_POLL_INTERVAL_MS, DOWNTIME_RUN_TIMEOUT_MS, type DowntimeWorker } from './downtime-worker.js'; import { showToast } from './notify.js'; import { recordCommand, getLastCommand } from './command-log.js'; import { @@ -54,6 +55,23 @@ export const STAGES = [ export type Stage = (typeof STAGES)[number]; +// ── /wl argument map (WL-0MSDT8X1V003206G) ──────────────────── +// Maps every accepted /wl stage argument — shorthand aliases and canonical +// stage names — to the internal stage name used for filtering. Matches the +// Pi TUI extension's STAGE_MAP so `/wl ` behaviour is identical. +export const STAGE_MAP: Record = { + intake: 'intake_complete', + plan: 'plan_complete', + progress: 'in_progress', + review: 'in_review', + // Canonical names mapped to themselves for validation + idea: 'idea', + intake_complete: 'intake_complete', + plan_complete: 'plan_complete', + in_progress: 'in_progress', + in_review: 'in_review', +}; + // Re-export stage colors from icons for backward compatibility export const STAGE_COLORS: Record = { idea: 247, @@ -289,6 +307,22 @@ export class WorkItemListState { /** Scroll offset within the detail view. */ detailScrollOffset = 0; + /** Selected Related Docs ToC entry (detail mode, 0-based). */ + detailToCIndex = 0; + + /** + * Keyboard focus in the detail view: true = ToC navigation, false = + * document scroll region (WL-0MSHWHULZ001FL8I). + */ + detailToCFocus = true; + + /** + * Which Key File's content is rendered in the md viewer (default 0 = + * first file, auto-render preserved). Enter on a ToC entry sets this to + * detailToCIndex. + */ + detailRenderedIndex = 0; + /** * Scroll offset within the metadata panel. Reset to 0 whenever the * selection changes so a stale position from a previous item never @@ -448,6 +482,12 @@ export class WorkItemListState { this.detailItem = item; this.mode = 'detail'; this.detailScrollOffset = 0; + // Reset ToC/focus state per item (WL-0MSHWHULZ001FL8I): first entry + // selected, keyboard focus on the ToC, auto-render of the first file + // remains the initial default. + this.detailToCIndex = 0; + this.detailToCFocus = true; + this.detailRenderedIndex = 0; this._resetMetaScroll(); } @@ -470,8 +510,12 @@ export class WorkItemListState { detailScrollDown(amount = 1): void { const maxCols = this.termSize.cols; const viewportHeight = Math.max(10, this.termSize.rows - 4); + // The ToC is pinned at the top of the detail view, so the scrollable + // region is the body below it (WL-0MSHWHULZ001FL8I). const allLines = formatDetailContent(this.detailItem, maxCols); - const maxScroll = Math.max(0, allLines.length - viewportHeight); + const tocLines = formatDetailToC(this.detailItem, maxCols).length; + const bodyLines = Math.max(0, allLines.length - tocLines); + const maxScroll = Math.max(0, bodyLines - viewportHeight); this.detailScrollOffset = Math.min(maxScroll, this.detailScrollOffset + amount); } @@ -542,9 +586,35 @@ export class WorkItemListState { // Capture the currently selected item's ID before replacing items const prevSelectedId = this._captureSelectedId(); + // The fetcher returns fresh top-level objects that never carry a + // `children` array (normalizeItem drops it), so a plain swap would + // momentarily collapse every expanded parent until doRefresh re-fetches + // children asynchronously — the reported flicker (WL-0MSBVBNGH002RDP5). + // Carry the previously fetched children over to the new objects by ID so + // the flattened view — and any selection pointing at a child — survives + // the swap atomically; doRefresh replaces the carried-over data with + // fresh children immediately afterwards. + const prevChildrenById = new Map(); + for (const item of this._allItems) { + if (item.children && item.children.length > 0) { + prevChildrenById.set(item.id, item.children); + } + } + this._allItems = [...newItems]; this._applyFilters(); + // Attach carried-over children only where the new object has no children + // yet (fresh children attached by doRefresh are never clobbered). + for (const item of this._allItems) { + if (item.childCount && item.childCount > 0 && !item.children) { + const prev = prevChildrenById.get(item.id); + if (prev) { + item.children = prev; + } + } + } + // Try to restore selection by ID; fall back to clamping if not found if (!this._restoreSelectionById(prevSelectedId)) { this._clampSelection(); @@ -609,9 +679,9 @@ export class WorkItemListState { /** Number of visible list rows (accounts for the metadata panel). */ _listHeight(): number { - // Reserve 3 rows for header, 1 for filter bar, 1 for footer, 1 for status + // Reserve 3 rows for header, 1 for footer, 1 for status const panelHeight = computeMetadataPanelHeight(this.termSize.rows); - return Math.max(3, this.termSize.rows - 6 - panelHeight); + return Math.max(3, this.termSize.rows - 4 - panelHeight); } _adjustScroll(): void { @@ -791,13 +861,16 @@ export function formatTimestamp(iso: string): string { * * Includes every tracked field: ID, Title, Status, Stage, Priority, Type, * Risk, Effort, Children, Parent, Tags, GitHub Issue, Created, Updated, - * Audit, Reviewed, and Audited At. Fields that are unset are omitted. + * Audit, Reviewed, and Audited At. When the item's `Key Files:` section + * contains `.md` paths, a `Related Docs` row lists every one of them (joined + * with `, `); the row is omitted when there are no `.md` Key Files + * (WL-0MSGTLSUT002NF29). Fields that are unset are omitted. * Timestamps (Created, Updated, Audited At) are rendered in local time as * `DD/MM/YY HH:MM` via {@link formatTimestamp}. * Shared by the detail view and the list-mode metadata panel so both stay * consistent (WL-0MSAYNVBY006LM9X-FT4). */ -export function buildMetaRows(item: WorkItem): Array<[string, string]> { +export function buildMetaRows(item: WorkItem, noIcons = false): Array<[string, string]> { const metaRows: Array<[string, string]> = []; const addMeta = (label: string, value: string | undefined | null): void => { if (value != null && value !== '') { @@ -820,9 +893,20 @@ export function buildMetaRows(item: WorkItem): Array<[string, string]> { addMeta('GitHub Issue', item.githubIssueNumber ? `#${item.githubIssueNumber}` : undefined); addMeta('Created', item.createdAt ? formatTimestamp(item.createdAt) : undefined); addMeta('Updated', item.updatedAt ? formatTimestamp(item.updatedAt) : undefined); - addMeta('Audit', auditIcon(item.auditResult)); - addMeta('Reviewed', needsProducerReviewIcon(item.needsProducerReview)); + addMeta('Audit', auditIcon(item.auditResult, { noIcons })); + addMeta('Reviewed', needsProducerReviewIcon(item.needsProducerReview, { noIcons })); addMeta('Audited At', item.auditedAt ? formatTimestamp(item.auditedAt) : undefined); + + // Related Docs — every .md path referenced in the item's `Key Files:` + // section, joined with a compact delimiter so multi-file values fit the + // existing label/value row format (WL-0MSGTLSUT002NF29). Display-only: + // paths are shown as written in the description, no file I/O at render + // time; resolution happens at open time via resolveKeyFilePath. + const mdPaths = extractFilePaths(item.description ?? '').filter(p => p.endsWith('.md')); + if (mdPaths.length > 0) { + metaRows.push(['Related Docs', mdPaths.join(', ')]); + } + return metaRows; } @@ -849,6 +933,7 @@ export function formatMetadataPanel( panelRows: number, metaScrollOffset = 0, lastCommand?: string | null, + noIcons = false, ): string[] { const lines: string[] = []; if (!item) { @@ -863,7 +948,7 @@ export function formatMetadataPanel( lines.push(` ${ANSI.dim}── ${item.id} ──${ANSI.reset}`); // Metadata rows - const metaRows = buildMetaRows(item); + const metaRows = buildMetaRows(item, noIcons); if (metaRows.length > 0) { const fieldWidth = Math.max(...metaRows.map(([l]) => l.length), 6); for (const [label, value] of metaRows) { @@ -907,6 +992,45 @@ export function formatMetadataPanel( return visible; } +/** + * Build the Related Docs Table of Contents (ToC) lines for the detail view + * (WL-0MSHWHULZ001FL8I). + * + * Lists every `.md` Key File of the item, numbered, with a focus indicator + * (`▸`) on the selected entry. Returns [] when the item has no `.md` Key + * Files so the detail view renders exactly as before. + * + * @param item - Work item whose `Key Files:` section is scanned. + * @param maxCols - Terminal width. + * @param detailToCIndex - Selected ToC entry (0-based). + * @param detailToCFocus - Whether keyboard focus is on the ToC (unused for + * rendering; kept for parity with the state and future dimming). + * @param noIcons - Icons gated off (focus indicator is a plain text marker). + * @returns ToC lines, or [] when the item has no `.md` Key Files. + */ +export function formatDetailToC( + item: WorkItem | null, + maxCols: number, + detailToCIndex = 0, + detailToCFocus = true, + noIcons = false, +): string[] { + if (!item) return []; + const mdPaths = extractFilePaths(item.description ?? '').filter(p => p.endsWith('.md')); + if (mdPaths.length === 0) return []; + + const lines: string[] = []; + lines.push(''); + lines.push(` ${ANSI.underline}Related Docs${ANSI.reset}`); + lines.push(''); + mdPaths.forEach((path, i) => { + const marker = i === detailToCIndex ? '▸' : ' '; + lines.push(` ${marker} ${i + 1}. ${path}`); + }); + lines.push(''); + return lines; +} + /** * Build the full content lines for a detail view (without scrolling). * Returns an array of lines ready for viewport rendering. @@ -917,11 +1041,20 @@ export function formatMetadataPanel( * (ISO timestamp). Rendered as a markdown table. ID and Title are shown in * the header (from the shared {@link buildMetaRows} set they are omitted * from the table to avoid duplication). + * + * When the item has ≥1 `.md` Key File, a pinned `Related Docs` ToC is + * rendered at the top (see {@link formatDetailToC}); the md viewer section + * below renders the file at `detailRenderedIndex` (default 0 = first file, + * preserving the existing auto-render behavior). */ export function formatDetailContent( item: WorkItem | null, maxCols: number, readFile?: (filePath: string) => string | null, + noIcons = false, + detailToCIndex = 0, + detailToCFocus = true, + detailRenderedIndex = 0, ): string[] { if (!item) return []; @@ -935,10 +1068,14 @@ export function formatDetailContent( lines.push(` ${ANSI.bold}${item.title}${ANSI.reset}`); lines.push(separator); + // Related Docs ToC — pinned at the top of the detail view + // (WL-0MSHWHULZ001FL8I). + lines.push(...formatDetailToC(item, maxCols, detailToCIndex, detailToCFocus, noIcons)); + // Metadata — rendered as a markdown table (shared row builder; ID and // Title are already shown in the header above, so they are filtered out // here to avoid duplicating them). - const metaRows = buildMetaRows(item).filter(([label]) => label !== 'ID' && label !== 'Title'); + const metaRows = buildMetaRows(item, noIcons).filter(([label]) => label !== 'ID' && label !== 'Title'); // Render the metadata as a markdown table if (metaRows.length > 0) { @@ -983,8 +1120,13 @@ export function formatDetailContent( // (preview-only; no notes editor). When a readFile callback is provided // and the item references a readable .md file, render it in place of the // raw description so the producer sees the paragraph-format episode. + // Renders the file at detailRenderedIndex (default 0 = first file, the + // existing auto-render); Enter on a ToC entry selects another file + // (WL-0MSHWHULZ001FL8I). if (readFile) { - const mdViewerLines = renderFileViewer(item, maxCols, readFile); + const mdPaths = extractFilePaths(item.description ?? '').filter(p => p.endsWith('.md')); + const mdPath = mdPaths[detailRenderedIndex] ?? mdPaths[0]; + const mdViewerLines = renderFileViewer(item, maxCols, readFile, mdPath); if (mdViewerLines.length > 0) { lines.push(''); lines.push(` ${ANSI.underline}Episode file (md viewer)${ANSI.reset}`); @@ -1013,17 +1155,20 @@ export function formatDetailContent( * @param item - The work item whose description carries a `Key Files:` path. * @param maxCols - Terminal width. * @param readFile - Synchronous file reader (path -> content or null). + * @param mdPath - Optional explicit .md path to render. When omitted, + * falls back to the first .md Key File (existing auto-render behavior). * @returns Viewer lines, or [] when no readable .md file is referenced. */ export function renderFileViewer( item: WorkItem | null, maxCols: number, readFile: (filePath: string) => string | null, + mdPath?: string, ): string[] { if (!item) return []; - const mdPath = firstMarkdownKeyFile(item.description); - if (!mdPath) return []; - const content = readFile(mdPath); + const path = mdPath ?? firstMarkdownKeyFile(item.description); + if (!path) return []; + const content = readFile(path); if (content == null) return []; return renderMarkdownViewer(content, maxCols); } @@ -1042,6 +1187,47 @@ export function firstMarkdownKeyFile(description: string | undefined): string { return md ?? ''; } +/** + * Resolve a `Key Files:` markdown path to an absolute filesystem path. + * + * Key Files paths are documented as relative to the worklog root, but the + * plugin pane's process CWD is the plugin source directory — NOT the worklog + * root — so resolving against `process.cwd()` alone is wrong + * (WL-0MSGEA9AY0080V4Q). Candidates are tried in order: + * + * 1. the resolved worklog root (the directory containing `.worklog/`, + * derived from the configured worklog dir — see configureWorklogTarget / + * HERDR_RESOLVED_CWD), + * 2. the legacy podcast-relative base `/.llm-wiki/wiki/podcast/` + * (older episode items wrote Key Files paths relative to the podcast dir + * rather than the wiki root), + * 3. `process.cwd()` as a last resort (plain CWD-relative key paths). + * + * Fail-open: returns null when no candidate exists on disk, so the detail + * view falls back to the raw description. + */ +export function resolveKeyFilePath(filePath: string): string | null { + const bases: string[] = []; + const wlDir = getWorklogDir(); + if (wlDir) { + const wlRoot = dirname(wlDir); + bases.push(wlRoot); + bases.push(join(wlRoot, '.llm-wiki', 'wiki', 'podcast')); + } + bases.push(process.cwd()); + for (const base of bases) { + try { + const candidate = resolvePath(base, filePath); + if (existsSync(candidate)) { + return candidate; + } + } catch { + // Ignore unreadable base and try the next candidate. + } + } + return null; +} + /** * Format the detail view for a single work item, with scrolling support. * @@ -1057,17 +1243,28 @@ export function formatDetailView( scrollOffset = 0, viewportHeight = 20, readFile?: (filePath: string) => string | null, + noIcons = false, + detailToCIndex = 0, + detailToCFocus = true, + detailRenderedIndex = 0, ): string { - const allLines = formatDetailContent(item, maxCols, readFile); + // The Related Docs ToC is pinned at the top of the detail view; only the + // body below it scrolls (WL-0MSHWHULZ001FL8I). + const tocLines = formatDetailToC(item, maxCols, detailToCIndex, detailToCFocus, noIcons); + const allLines = formatDetailContent(item, maxCols, readFile, noIcons, detailToCIndex, detailToCFocus, detailRenderedIndex); if (allLines.length === 0) return ''; - const totalLines = allLines.length; + const bodyLines = allLines.slice(tocLines.length); + const totalLines = bodyLines.length; const maxScroll = Math.max(0, totalLines - viewportHeight); const safeOffset = Math.min(scrollOffset, maxScroll); - const visible = allLines.slice(safeOffset, safeOffset + viewportHeight); + const visible = [ + ...tocLines, + ...bodyLines.slice(safeOffset, safeOffset + viewportHeight), + ]; - // Add scroll indicator if content is long + // Add scroll indicator if the body is long if (totalLines > viewportHeight && safeOffset <= maxScroll) { const percent = totalLines > 0 ? Math.round(((safeOffset + viewportHeight) / totalLines) * 100) @@ -1085,18 +1282,6 @@ export function formatDetailView( return visible.join('\n'); } -/** - * Format the filter status bar. - */ -export function formatFilterBar(filter: string | null, maxCols: number): string { - if (filter) { - const color = STAGE_COLORS[filter] || 241; - const bar = ` ${ANSI.bg(color)}${ANSI.fg(16)} Filter: ${filter} ${ANSI.reset}`; - return bar.padEnd(maxCols, '─'); - } - return ` ${ANSI.dim}No filter — press [f] then [i/n/p/r] to filter by stage${ANSI.reset}`.padEnd(maxCols, ' '); -} - /** * Format the filter selection prompt. */ @@ -1133,8 +1318,8 @@ export function createChordState(): ChordState { /** * Check if a key matches any chord leader in the registry. */ -export function isChordLeader(key: string, registry: ShortcutRegistry, codeFreezeActive?: boolean): boolean { - const chords = registry.getChordEntries(codeFreezeActive); +export function isChordLeader(key: string, registry: ShortcutRegistry, codeFreezeActive?: boolean, issueType?: string): boolean { + const chords = registry.getChordEntries(codeFreezeActive, issueType); return chords.some(c => { const chord = c.chord; return chord !== undefined && chord.length >= 1 && chord[0] === key; @@ -1155,11 +1340,12 @@ export function processChordInput( view: string, stage?: string, codeFreezeActive?: boolean, + issueType?: string, ): 'chord-complete' | 'chord-cancel' | null { const pending = [...chordState.pendingKeys, key]; // Check if this completes a chord - const entry = registry.lookupChordEntry(pending, view, stage, codeFreezeActive); + const entry = registry.lookupChordEntry(pending, view, stage, codeFreezeActive, issueType); if (entry) { chordState.pendingKeys = []; chordState.hints = ''; @@ -1169,7 +1355,7 @@ export function processChordInput( } // Check if this is a valid prefix for more chords - const nextChords = registry.getChordByPrefix(pending, view, stage, codeFreezeActive); + const nextChords = registry.getChordByPrefix(pending, view, stage, codeFreezeActive, issueType); if (nextChords.length > 0) { chordState.pendingKeys = pending; // Update hints @@ -1252,9 +1438,9 @@ export function formatChordHintsForHelp( * Get chord hints for showing in the help bar when in list mode. * Shows leader keys and abbreviated labels for all chords. */ -export function getChordHelpHints(registry: ShortcutRegistry | undefined, codeFreezeActive?: boolean): string { +export function getChordHelpHints(registry: ShortcutRegistry | undefined, codeFreezeActive?: boolean, issueType?: string): string { if (!registry) return ''; - const chords = registry.getChordEntries(codeFreezeActive); + const chords = registry.getChordEntries(codeFreezeActive, issueType); // Group by leader key const byLeader = new Map(); for (const c of chords) { @@ -1354,13 +1540,61 @@ export function handleKeypress( state.back(); return 'back'; } - // Detail scrolling + + // Related Docs ToC navigation (WL-0MSHWHULZ001FL8I). When the item has + // ≥1 .md Key File and keyboard focus is on the ToC (detailToCFocus), + // j/k/arrows move the ToC selection; Enter renders the selected doc. + // Navigating past the last entry transfers focus to document scrolling. + const mdCount = state.detailItem + ? extractFilePaths(state.detailItem.description ?? '').filter(p => p.endsWith('.md')).length + : 0; + const hasToC = mdCount > 0; + + if (hasToC && state.detailToCFocus) { + if (key === 'j' || key === '\x1b[B') { + if (state.detailToCIndex < mdCount - 1) { + state.detailToCIndex += 1; + } else { + // Past the last entry → document scrolling focus + state.detailToCFocus = false; + } + return null; + } + if (key === 'k' || key === '\x1b[A') { + if (state.detailToCIndex > 0) { + state.detailToCIndex -= 1; + } + return null; + } + if (key === '\r' || key === '\n') { + // Enter renders the selected document in the md viewer + state.detailRenderedIndex = state.detailToCIndex; + return null; + } + if (key === 'g') { + state.detailToCIndex = 0; + return null; + } + if (key === 'G') { + state.detailToCIndex = mdCount - 1; + return null; + } + return null; + } + + // Detail scrolling — when the item has a ToC this branch runs while + // focus is on the document; k at the top of the document returns focus + // to the ToC (WL-0MSHWHULZ001FL8I). if (key === 'j' || key === '\x1b[B') { state.detailScrollDown(1); return null; } if (key === 'k' || key === '\x1b[A') { - state.detailScrollUp(1); + if (hasToC && state.detailScrollOffset <= 0) { + state.detailToCFocus = true; + } else { + state.detailScrollUp(1); + } return null; } if (key === '\x1b[6~') { @@ -1509,7 +1743,37 @@ export function handleKeypress( * current state. The caller should write this to stdout and handle * terminal setup/teardown. */ -export function createListRenderer(): ( +/** + * Render the inline downtime-worker status fragment appended to the list + * header (AC3, WL-0MSF49FMW009M06K): `[⏳ downtime idle m:ss]`, + * `[downtime busy]`, `[⏳ downtime dispatching]`, `[downtime disabled]`, or + * `[downtime paused]` (no-candidate cooldown, WL-0MSI7DQL10016QYX). + * Inline-only — it never adds a row, so the pane-height budget is intact. + */ +export function renderDowntimeStatus(worker: DowntimeWorker | undefined): string { + if (!worker) return ''; + if (worker.dispatching) { + return ` ${ANSI.fg(208)}[⏳ downtime dispatching]${ANSI.reset}`; + } + if (!worker.enabled) { + return ` ${ANSI.dim}[downtime disabled]${ANSI.reset}`; + } + if (worker.paused) { + // No-candidate cooldown: the worker is not polling, so `idleSince` is + // stale/empty — render the honest paused state instead of a stale idle + // duration (AC6, WL-0MSI7DQL10016QYX). + return ` ${ANSI.dim}[downtime paused]${ANSI.reset}`; + } + if (worker.idleSince !== null) { + const elapsedSecs = Math.max(0, Math.floor((Date.now() - worker.idleSince) / 1000)); + const minutes = Math.floor(elapsedSecs / 60); + const seconds = elapsedSecs % 60; + return ` ${ANSI.fg(34)}[⏳ downtime idle ${minutes}:${String(seconds).padStart(2, '0')}]${ANSI.reset}`; + } + return ` ${ANSI.dim}[downtime busy]${ANSI.reset}`; +} + +export function createListRenderer(getShowIcons?: () => boolean): ( items: WorkItem[], selectedIndex: number, scrollOffset: number, @@ -1529,7 +1793,14 @@ export function createListRenderer(): ( metaScrollOffset?: number, metaLastCommand?: string, readFile?: (filePath: string) => string | null, + downtimeStatus?: string, + detailToCIndex?: number, + detailToCFocus?: boolean, + detailRenderedIndex?: number, ) => string { + // Default to icons enabled when no getter is supplied (backwards + // compatible — callers/tests that render without options keep icons). + const showIconsGetter = getShowIcons ?? (() => true); return ( items: WorkItem[], selectedIndex: number, @@ -1550,19 +1821,37 @@ export function createListRenderer(): ( metaScrollOffset?: number, metaLastCommand?: string, readFile?: (filePath: string) => string | null, + downtimeStatus?: string, + detailToCIndex?: number, + detailToCFocus?: boolean, + detailRenderedIndex?: number, ): string => { const { rows, cols } = termSize; + // Icons are gated by the getter for the whole frame (list lines, detail + // view, and metadata panel alike) so showIcons=false omits every item + // icon, including audit/review icons (AC1, WL-0MSBV4RYO008JL70). + const noIcons = !showIconsGetter(); const output: string[] = []; // The metadata panel reserves 20–40% of the pane height below the list; // the list area is the remaining height minus the notification row. const panelHeight = computeMetadataPanelHeight(rows); const listArea = Math.max(1, rows - 1 - panelHeight); - const listHeight = Math.max(3, rows - 6 - panelHeight); + const listHeight = Math.max(3, rows - 4 - panelHeight); if (mode === 'detail' && detailItem) { const viewportHeight = Math.max(10, rows - 1); const offset = detailScrollOffset ?? 0; - return formatDetailView(detailItem, cols, offset, viewportHeight, readFile); + return formatDetailView( + detailItem, + cols, + offset, + viewportHeight, + readFile, + noIcons, + detailToCIndex ?? 0, + detailToCFocus ?? true, + detailRenderedIndex ?? 0, + ); } if (mode === 'filter') { @@ -1592,6 +1881,9 @@ export function createListRenderer(): ( if (panePaused) { header += ` ${ANSI.fg(220)}[paused — hidden]${ANSI.reset}`; } + if (downtimeStatus) { + header += downtimeStatus; + } output.push(header); // Code Freeze banner — a prominent warning that implementation is @@ -1603,23 +1895,20 @@ export function createListRenderer(): ( const bannerLine = `${ANSI.bg(196)}${ANSI.fg(231)} ${bannerText} ${ANSI.reset}`; output.push(truncateLine(bannerLine, cols)); } - output.push(''); - - // Filter bar - output.push(formatFilterBar(activeFilter, cols)); // Items are already flattened by the caller (render callback in runWorklistTui // calls state.getFlattenedItems() before passing items here). Do NOT re-flatten. const flatItems = items; // Items with group separators. Each `── ──` separator consumes a - // row, so the visible window must be sized so header + blank + filter bar - // + items + separators + fill + footer fit in `rows - 1` lines (the last - // row is reserved for the notification line appended by render()). Without - // this accounting the output overflows the pane and the terminal scrolls - // the header/top items off the top (WL-0MSAAON63003N6LO). + // row, so the visible window must be sized so header + items + separators + // + fill + footer fit in `rows - 1` lines (the last row is reserved for + // the notification line appended by render()). Without this accounting the + // output overflows the pane and the terminal scrolls the header/top items + // off the top (WL-0MSAAON63003N6LO). The active stage filter is indicated + // in the header only (filterLabel) — no standalone filter bar is rendered. const bannerActive = codeFreezeActive === true; - const chromeLines = bannerActive ? 5 : 4; // header + banner + blank + filter bar + footer + const chromeLines = bannerActive ? 3 : 2; // header + banner + footer const budgetForItemsAndSeps = Math.max(0, listArea - chromeLines); // Count the group separators a window would render (same logic as the // render loop below) so the window can be trimmed when separators would @@ -1665,7 +1954,6 @@ export function createListRenderer(): ( const expandedItem = { ...item, _expanded: hasChildCount && isExpanded }; const isSelected = actualIndex === selectedIndex; - const noIcons = !iconsEnabled(); const line = formatItemLine(expandedItem, cols, isSelected, noIcons); if (isSelected) { output.push(`${ANSI.reverse}${line}${ANSI.reset}`); @@ -1674,7 +1962,7 @@ export function createListRenderer(): ( } } - // Fill remaining rows (header + blank + filter bar + items + separators) + // Fill remaining rows (header + items + separators) const used = chromeLines + visible.length + numSeparators; for (let i = used; i < listArea; i++) { output.push(''); @@ -1712,6 +2000,7 @@ export function createListRenderer(): ( panelHeight, metaScrollOffset ?? 0, metaLastCommand, + noIcons, ); for (const line of panelLines) { output.push(line); @@ -1731,11 +2020,6 @@ export function createListRenderer(): ( // ── Main TUI loop ───────────────────────────────────────────────────── -/** - * Default renderer instance. - */ -const defaultRenderer = createListRenderer(); - /** * Work-item ID format: a prefix (e.g. `WL`, `SA`) followed by a hash, * e.g. `WL-0MS9NPHQU005Y3VE`. @@ -1892,6 +2176,39 @@ export function formatCodeFreezeDialog(maxCols: number, maxRows: number, reason? return lines.join('\n'); } +/** + * Fetch the items for the current view. + * + * When a stage filter is active, the worklist shows EVERY open root item in + * that stage (`wl list --status open --stage --root-only`) — not + * just the `browseItemCount`-capped `wl next` subset — so stage-filtered + * views give a complete picture of the stage (WL-0MSDT8X1V003206G). Items + * with status `blocked`, `in-progress`, or `completed` are excluded; child + * items stay hidden and remain reachable via expand exactly as in the + * unfiltered view. Results follow the standard list order (sortIndex). + * + * Without an active filter the default fetcher is used unchanged (the + * default `/wl` view keeps its existing smart-selection behaviour). If the + * stage fetch fails, the default fetcher is used as a fallback so a `wl` + * error can never blank the list. + * + * @param activeFilter - The active stage filter (null = unfiltered view) + * @param defaultFetcher - The default fetcher for the unfiltered view + */ +export function fetchItemsForView( + activeFilter: string | null, + defaultFetcher: () => Promise, +): Promise { + if (activeFilter) { + // Fail-open: a wl error must never blank the list — fall back to the + // default fetcher (which itself fails open in index.ts). + return fetchItemsByStage(activeFilter).catch(() => defaultFetcher()); + } + // Unfiltered: delegate straight to the default fetcher so the refresh + // cadence is byte-for-byte unchanged (single-flight timing preserved). + return defaultFetcher(); +} + /** * Dispatch a chord command by mapping it to the appropriate TUI action * or routing it through the stdout command output mechanism. @@ -1918,20 +2235,22 @@ export function dispatchChordCommand( const wlStageMatch = command.match(/^\/wl\s+(\S+)$/); if (wlStageMatch) { const wlStage = wlStageMatch[1]; - // Map wl stage names to internal stage names - const stageMap: Record = { - idea: 'idea', - intake: 'intake_complete', - plan: 'plan_complete', - review: 'in_review', - }; - const internalStage = stageMap[wlStage]; + const internalStage = STAGE_MAP[wlStage]; if (internalStage) { state.applyFilter(internalStage); return true; } } + // ── /wl (no arguments): return to the default unfiltered view ──── + // Equivalent to the Pi TUI's `/wl` with no args (WL-0MSGSE15000746F7): + // clears the active stage filter so the next refresh shows the standard + // smart-selection list. No-op when no filter is active. + if (/^\/wl\s*$/.test(command)) { + state.clearFilter(); + return true; + } + // ── Agent skill invocations ───────────────────────────── if (command.startsWith('/skill:implement')) { return resolveAndRouteCommand(command, state, onCommand, model); @@ -1962,6 +2281,111 @@ export function dispatchChordCommand( export type ExecuteResult = 'dispatched' | 'callback' | 'noop' | 'blocked'; +/** + * Result of resolving a podcast-progression command marker for the selected + * work item. Either a fully-resolved command (no markers remain) or an + * error message that must be surfaced WITHOUT dispatching (OSL-0MSHFQ51L009IUOS + * belt-and-braces guard). + */ +export interface PodcastTargetResolution { + command?: string; + error?: string; +} + +/** + * Resolve podcast-progression command markers (``, + * ``) for the selected work item at dispatch time + * (OSL-0MSKFXM380098LFL, folding in OSL-0MSHFQ51L009IUOS). + * + * The `w` write-script chord command + * (`/skill:wiki-podcast-script `) derives its mode from the + * selected item's lifecycle context: + * - stage `intake_complete` (sourced): author a new script from the source + * synthesis → `--doc --force-single`; + * - otherwise, when open editor-note children exist: rewrite the existing + * script → `--rewrite `; + * - otherwise: belt-and-braces guard — returns an error and does NOT + * dispatch (never authors a duplicate). + * + * The `t` TTS chord command + * (`/skill:wiki-tts-generate --podcast-file `) resolves + * `` to the first `.podcast.md` Key File, normalized to the + * wiki-dir-relative `podcast/...` form the TTS skill expects (a bare + * `/<title>.podcast.md` Key File path is podcast-dir-relative). + * + * Markers are resolved BEFORE the generic modal-form check so they never + * fall through to the input form. Returns the input command unchanged when + * it carries no podcast markers. + * + * @param command - Resolved shortcut command (may contain markers). + * @param item - Selected work item (or null when nothing is selected). + * @param fetchChildren - Child fetcher (injectable for tests; defaults to + * {@link fetchChildrenForItem}). + */ +export async function resolvePodcastTarget( + command: string, + item: WorkItem | null, + fetchChildren: (parentId: string) => Promise<WorkItem[]> = fetchChildrenForItem, +): Promise<PodcastTargetResolution> { + const hasTarget = command.includes('<podcast-target>'); + const hasScript = command.includes('<podcast-script>'); + if (!hasTarget && !hasScript) { + return { command }; + } + if (!item) { + return { error: 'No work item selected' }; + } + + const mdPaths = extractFilePaths(item.description ?? '').filter(p => p.endsWith('.md')); + const synthesis = mdPaths.find(p => !p.endsWith('.podcast.md')) ?? mdPaths[0]; + const script = mdPaths.find(p => p.endsWith('.podcast.md')); + let resolved = command; + + if (hasTarget) { + // `w` write-script chord: mode derives from stage + open notes. + if (item.stage === 'intake_complete') { + // Sourced episode — author a new script from the synthesis. + if (!synthesis) { + return { error: 'No source synthesis found in Key Files: — run wiki-ingest-batch/research first' }; + } + resolved = resolved.replace(/<podcast-target>/g, `--doc ${synthesis} --force-single`); + } else { + // Drafted/written episode — rewrite only when open note children exist. + let children: WorkItem[] = []; + try { + children = await fetchChildren(item.id); + } catch { + children = []; + } + const openNotes = children.filter(c => { + const status = c.status ?? ''; + return status !== 'completed' && status !== 'closed' && status !== 'deleted'; + }); + if (openNotes.length === 0) { + return { error: 'podcast script already present, review and edit that rather than author a new one' }; + } + if (!script) { + return { error: 'No podcast script found in Key Files:' }; + } + resolved = resolved.replace(/<podcast-target>/g, `--rewrite ${script}`); + } + } + + if (hasScript) { + if (!script) { + return { error: 'No podcast script found in Key Files: — author the script first (w)' }; + } + // The TTS skill resolves --podcast-file relative to the wiki dir; + // episode Key Files store the script podcast-dir-relative. + const podcastFile = script.startsWith('podcast/') || script.startsWith('wiki/') + ? script + : `podcast/${script}`; + resolved = resolved.replace(/<podcast-script>/g, podcastFile); + } + + return { command: resolved }; +} + /** * Execute a resolved chord command. * @@ -2052,7 +2476,7 @@ export async function runWorklistTui( fetcher: () => Promise<WorkItem[]>, initialItems?: WorkItem[], shortcutRegistry?: { lookupChord: Function; getChordByLeader: Function; getChordByPrefix: Function; getChordEntries: Function } | ShortcutRegistry | undefined, - options?: { autoRefresh?: boolean; refreshIntervalMs?: number; autoSync?: boolean; syncIntervalMs?: number; browseItemCount?: number; showHelpText?: boolean; getShowHelpText?: () => boolean; onCommand?: (command: string, model?: string) => void }, + options?: { autoRefresh?: boolean; refreshIntervalMs?: number; autoSync?: boolean; syncIntervalMs?: number; browseItemCount?: number; showHelpText?: boolean; getShowHelpText?: () => boolean; showIcons?: boolean; getShowIcons?: () => boolean; onCommand?: (command: string, model?: string) => void; downtimeWorker?: DowntimeWorker; downtimePollIntervalMs?: number; mergeAgentStates?: (items: WorkItem[]) => Promise<void> }, ): Promise<WorkItem | undefined> { const opts = { autoRefresh: options?.autoRefresh ?? true, @@ -2062,7 +2486,12 @@ export async function runWorklistTui( browseItemCount: options?.browseItemCount ?? 10, showHelpText: options?.showHelpText ?? true, getShowHelpText: options?.getShowHelpText ?? (() => options?.showHelpText ?? true), + showIcons: options?.showIcons ?? true, + getShowIcons: options?.getShowIcons ?? (() => options?.showIcons ?? true), onCommand: options?.onCommand, + downtimeWorker: options?.downtimeWorker, + downtimePollIntervalMs: options?.downtimePollIntervalMs ?? DEFAULT_DOWNTIME_POLL_INTERVAL_MS, + mergeAgentStates: options?.mergeAgentStates, }; let termSize = getTermSize(); @@ -2084,7 +2513,10 @@ export async function runWorklistTui( }); const state = new WorkItemListState(items, termSize); - const renderer = defaultRenderer; + // Icons are gated by the getShowIcons getter (re-read on every render so a + // showIcons setting change applies without a plugin restart — same pattern + // as getShowHelpText). The renderer is created per-TUI-run with the getter. + const renderer = createListRenderer(opts.getShowIcons); const chordState: ChordState = createChordState(); let formState: FormState | null = null; /** Saved mode before entering form overlay (to restore on cancel) */ @@ -2177,27 +2609,56 @@ export async function runWorklistTui( refreshInFlight = true; try { try { - const newItems = await fetcher(); - const oldLen = state.items.length; - state.refreshItems(newItems); - // Re-read the Code Freeze marker so a freeze that started (or ended) - // since the last refresh is reflected in the banner promptly. - refreshFreezeState(); - // Re-fetch children for expanded parents so the hierarchy view stays - // fresh after an auto/manual refresh while inside a child context. + // Fetch the new top-level items and the children of currently + // expanded parents in PARALLEL, then apply both in one synchronous + // refreshItems step. The fetcher returns fresh top-level objects + // without `children`, so a swap followed by an async children + // re-fetch would render a collapsed intermediate state — the + // "momentary collapse" flicker (WL-0MSBVBNGH002RDP5). Fetching + // children up front removes that window entirely; refreshItems also + // carries over previously fetched children as a safety net when a + // children re-fetch fails. const expanded = [...state.expandedItems]; - if (expanded.length > 0) { + const freshChildren = new Map<string, WorkItem[]>(); + const fetchExpandedChildren = async (parentId: string): Promise<WorkItem[]> => { + try { + const children = await fetchChildrenForItem(parentId); + freshChildren.set(parentId, children); + return children; + } catch { + // Ignore: refreshItems carries over the previously fetched + // children when the re-fetch fails. + return []; + } + }; + const [newItems] = await Promise.all([ + fetchItemsForView(state.activeFilter, fetcher), + ...expanded.map(fetchExpandedChildren), + ]); + const oldLen = state.items.length; + + // Attach freshly fetched children to the new parent objects BEFORE + // the swap so the flattened view is complete at render time. + if (freshChildren.size > 0) { const byId = new Map(newItems.map((it) => [it.id, it])); - await Promise.all(expanded.map(async (parentId) => { + for (const [parentId, children] of freshChildren) { const parent = byId.get(parentId); - if (!parent) return; // parent no longer exists - try { - const children = await fetchChildrenForItem(parentId); + if (parent) { parent.children = children; - } catch { - // ignore: keep previously fetched children on refresh failure } - })); + } + } + state.refreshItems(newItems); + // Re-read the Code Freeze marker so a freeze that started (or ended) + // since the last refresh is reflected in the banner promptly. + refreshFreezeState(); + // Merge agent-status state into the refreshed items (top-level + + // expanded children) so the agent icons reflect the latest tracker + // state (WL-0MSBQUJQX005RAT9). Fail-open: no herdr CLI → no icons. + try { + await opts.mergeAgentStates?.(newItems); + } catch { + // Fail-open: a merge failure must never break the refresh cycle. } if (showNotification && newItems.length !== oldLen) { const diff = newItems.length - oldLen; @@ -2223,17 +2684,40 @@ export async function runWorklistTui( } }; + /** + * True when the resolved command is a `/wl` view command — a stage filter + * (`/wl <stage>`, shorthand alias or canonical name) or the clear-filter + * `/wl` with no arguments. Used after dispatch to trigger a view refetch: + * filtered views show every open root item in the stage + * (WL-0MSDT8X1V003206G); clearing the filter restores the default view + * (WL-0MSGSE15000746F7). + */ + const isWlViewCommand = (cmd: string): boolean => { + if (/^\/wl\s*$/.test(cmd)) return true; + const m = cmd.match(/^\/wl\s+(\S+)$/); + return m !== null && STAGE_MAP[m[1]] !== undefined; + }; + // Run `wl sync` and surface the outcome as a toast so sync status is // visible (success and graceful failure). Targets the resolved worklog // directory so sync operates on the tab project. // // With ifIdle=true (auto-sync timer path) runSync applies its single-flight // guard and passes --if-idle to the CLI, so overlapping syncs are skipped - // instead of piling up (lock-storm prevention). Manual 'S' syncs pass - // ifIdle=false and wait for the lock like a regular wl sync. - const doSync = async (ifIdle = false): Promise<void> => { - const outcome = await runSync(getWorklogDir(), { ifIdle }); + // instead of piling up (lock-storm prevention). With a heartbeatTtlMs the + // auto-sync path ALSO skips without spawning when another pane synced + // recently (cross-instance coordination, F3 — WL-0MSGAEJQA005QG3W); such + // skips are silent because the data is already fresh. Manual 'S' syncs pass + // ifIdle=false and no heartbeat, so they always wait for the lock like a + // regular wl sync. + const doSync = async (ifIdle = false, heartbeatTtlMs?: number): Promise<void> => { + const outcome = await runSync(getWorklogDir(), { + ifIdle, + ...(heartbeatTtlMs !== undefined ? { heartbeat: true, heartbeatTtlMs } : {}), + }); if (outcome.skipped) { + // Heartbeat skip: another pane synced within the window — silent. + if (outcome.reason === 'heartbeat') return; // Another sync is already in-flight / holding the lock — do not pile on. showToast('Sync in progress'); return; @@ -2298,15 +2782,29 @@ export async function runWorklistTui( state.mode === 'detail' ? 'detail' : 'list', state.activeFilter ?? undefined, codeFreezeActive, + state.getSelectedItem()?.issueType, ); if (chordResult === 'chord-complete') { // Chord resolved — execute the command - const command = chordState.resolvedCommand; + let command = chordState.resolvedCommand; const model = chordState.resolvedModel; chordState.resolvedCommand = null; chordState.resolvedModel = null; if (command) { + // Podcast-progression markers (<podcast-target>/<podcast-script>) + // are resolved from the selected item's context BEFORE the generic + // modal-form check so they never fall through to the input form + // (OSL-0MSKFXM380098LFL, folding in OSL-0MSHFQ51L009IUOS). + if (command.includes('<podcast-target>') || command.includes('<podcast-script>')) { + const podcast = await resolvePodcastTarget(command, state.getSelectedItem()); + if (podcast.error) { + showToast('Error', { body: podcast.error }); + render(); + return; + } + command = podcast.command ?? command; + } // Check for unknown identifiers that need form input if (hasUnknownIdentifiers(command)) { // Look up description from shortcut entry @@ -2380,6 +2878,12 @@ export async function runWorklistTui( // Surface a brief toast, then continue showToast('Sent', { body: command.length > 60 ? command.substring(0, 57) + '...' : command }); } + // Stage-filter dispatch (/wl <stage> or f-chord shortcut): refetch + // so the filtered view shows EVERY open root item in the stage, + // not just the already-loaded subset (WL-0MSDT8X1V003206G). + if (result === 'dispatched' && isWlViewCommand(command)) { + await doRefresh(true); + } } catch (e) { showToast('Error', { body: (e as Error).message }); process.stderr.write(`[herdr] Command error: ${(e as Error).message}\n`); @@ -2409,17 +2913,31 @@ export async function runWorklistTui( // If key wasn't handled as navigation and chord registry exists, // check if it's a shortcut or part of a chord sequence - if (shortcutRegistry && (action === null || isChordLeader(key, shortcutRegistry as ShortcutRegistry, codeFreezeActive))) { + if (shortcutRegistry && (action === null || isChordLeader(key, shortcutRegistry as ShortcutRegistry, codeFreezeActive, state.getSelectedItem()?.issueType))) { // First: check if this key is a complete single-key shortcut const singleEntry = (shortcutRegistry as ShortcutRegistry).lookupChordEntry( [key], state.mode === 'detail' ? 'detail' : 'list', state.activeFilter ?? undefined, codeFreezeActive, + state.getSelectedItem()?.issueType, ); if (singleEntry) { - const singleCmd = singleEntry.command; + let singleCmd = singleEntry.command; const singleModel = singleEntry.model ?? undefined; + // Podcast-progression markers (<podcast-target>/<podcast-script>) + // are resolved from the selected item's context BEFORE the generic + // modal-form check so they never fall through to the input form + // (OSL-0MSKFXM380098LFL, folding in OSL-0MSHFQ51L009IUOS). + if (singleCmd.includes('<podcast-target>') || singleCmd.includes('<podcast-script>')) { + const podcast = await resolvePodcastTarget(singleCmd, state.getSelectedItem()); + if (podcast.error) { + showToast('Error', { body: podcast.error }); + render(); + return; + } + singleCmd = podcast.command ?? singleCmd; + } // Single-key shortcut — check for unknown identifiers first if (hasUnknownIdentifiers(singleCmd)) { let description = ''; @@ -2479,6 +2997,12 @@ export async function runWorklistTui( // Surface a brief toast, then continue showToast('Sent', { body: singleCmd.length > 60 ? singleCmd.substring(0, 57) + '...' : singleCmd }); } + // Stage-filter dispatch (/wl <stage> or f-chord shortcut): refetch + // so the filtered view shows EVERY open root item in the stage, + // not just the already-loaded subset (WL-0MSDT8X1V003206G). + if (result === 'dispatched' && isWlViewCommand(singleCmd)) { + await doRefresh(true); + } render(); } catch (e) { showToast('Error', { body: (e as Error).message }); @@ -2489,11 +3013,12 @@ export async function runWorklistTui( } // Second: check if this key starts a multi-key chord sequence - if (isChordLeader(key, shortcutRegistry as ShortcutRegistry, codeFreezeActive)) { + if (isChordLeader(key, shortcutRegistry as ShortcutRegistry, codeFreezeActive, state.getSelectedItem()?.issueType)) { const nextChords = (shortcutRegistry as ShortcutRegistry).getChordByPrefix([key], state.mode === 'detail' ? 'detail' : 'list', state.activeFilter ?? undefined, - codeFreezeActive); + codeFreezeActive, + state.getSelectedItem()?.issueType); if (nextChords.length > 0) { chordState.pendingKeys = [key]; chordState.hints = formatChordHintsForHelp(nextChords, [key]); @@ -2530,6 +3055,13 @@ export async function runWorklistTui( render(); // immediate render while fetch is pending const children = await fetchChildrenForItem(selected.id); selected.children = children; + // Merge agent-status state into the freshly loaded children so + // their rows show agent icons too (WL-0MSBQUJQX005RAT9). + try { + await opts.mergeAgentStates?.([selected]); + } catch { + // Fail-open: a merge failure must never break expansion. + } state.toggleExpand(selected.id); render(); return; @@ -2548,14 +3080,17 @@ export async function runWorklistTui( /** * Read a Key Files: markdown document (e.g. an episode .podcast.md) for - * the generic md viewer in the detail pane. Paths are relative to the - * worklog root (the process CWD when the plugin runs in a pane). - * Fail-open: unreadable or missing files yield null and the detail view - * falls back to the raw description. + * the generic md viewer in the detail pane. Paths are resolved against + * the worklog root (via resolveKeyFilePath) — the plugin pane's process + * CWD is the plugin source dir, not the worklog root + * (WL-0MSGEA9AY0080V4Q). Fail-open: unreadable or missing files yield + * null and the detail view falls back to the raw description. */ const readKeyFile = (filePath: string): string | null => { + const resolved = resolveKeyFilePath(filePath); + if (!resolved) return null; try { - return readFileSync(resolvePath(filePath, process.cwd()), 'utf-8'); + return readFileSync(resolved, 'utf-8'); } catch { return null; } @@ -2594,12 +3129,14 @@ export async function runWorklistTui( if (shortcutRegistry && chordState.pendingKeys.length === 0) { const reg = shortcutRegistry as ShortcutRegistry; const selIdx = state.selectedIndex; - const selStage = displayItems.length > 0 && selIdx < displayItems.length - ? displayItems[selIdx]?.stage + const selItem = displayItems.length > 0 && selIdx < displayItems.length + ? displayItems[selIdx] : undefined; + const selStage = selItem?.stage; + const selIssueType = selItem?.issueType; const isEmpty = displayItems.length === 0; - const relevantEntries = reg.getEntriesForStage(selStage, codeFreezeActive) + const relevantEntries = reg.getEntriesForStage(selStage, codeFreezeActive, selIssueType) .filter(e => e.view === 'list' || e.view === 'both') .filter(e => { if (isEmpty && e.command.includes('<id>')) return false; @@ -2670,6 +3207,10 @@ export async function runWorklistTui( state.metaScrollOffset, metaLastCommand, readKeyFile, + renderDowntimeStatus(opts.downtimeWorker), + state.detailToCIndex, + state.detailToCFocus, + state.detailRenderedIndex, ); // Notifications are surfaced via Herdr toasts (showToast), never as a @@ -2698,66 +3239,66 @@ export async function runWorklistTui( // Read keypresses process.stdin.on('data', onData); - // Resume poll — while the pane is hidden, visibility is polled on a short - // interval so the hidden → visible transition triggers an immediate - // doRefresh instead of waiting for the next refreshIntervalMs tick. The - // poll uses only herdr pane get (via the PollGate memoizer, TTL aligned - // with the poll interval) — never wl — preserving the zero-wl-when-hidden - // guarantee. - const RESUME_POLL_INTERVAL_MS = DEFAULT_POLL_GATE_TTL_MS; - let resumePollTimer: ReturnType<typeof setInterval> | undefined; + // ── Consolidated scheduler loop (WL-0MSG4NMF0000YBRP) ────────────── + // All periodic plugin work — auto-refresh, auto-sync, visibility + // resume-poll, and future downtime-worker ticks — runs through ONE + // TaskScheduler interval. Tasks are dispatched by due-time deadline at a + // 1s base tick; each task applies its own visibility gate (refresh/sync + // skip when hidden, resume-poll only runs while hidden). Tick cadence, + // pause-when-hidden gating, and shutdown cleanup live in one place. + const scheduler = new TaskScheduler(DEFAULT_SCHEDULER_TICK_MS); const stopResumePoll = (): void => { - if (resumePollTimer !== undefined) { - clearInterval(resumePollTimer); - resumePollTimer = undefined; - } + scheduler.setDisabled('resume-poll', true); }; /** Start the resume poll (no-op when already running). */ const startResumePoll = (): void => { - if (resumePollTimer !== undefined) return; - resumePollTimer = setInterval(async () => { - if (await paneGate.visible()) { - // Hidden → visible transition: refresh immediately (with the - // "refreshed" notification) and let the normal cadence resume. - stopResumePoll(); - panePaused = false; - doRefresh(true); - } - }, RESUME_POLL_INTERVAL_MS); + scheduler.setDisabled('resume-poll', false); }; - // Auto-refresh timer — fetches fresh items on an interval. NOTE: this timer - // does NOT run `wl sync`; the dedicated SyncTimer below is the single sync - // source. Running sync from both timers caused a double-spawn per pane that + // Auto-refresh task — fetches fresh items on an interval. NOTE: this task + // does NOT run `wl sync`; the dedicated sync task below is the single sync + // source. Running sync from both tasks caused a double-spawn per pane that // amplified the wl sync lock storm (WL-0MSAB7ZUC004SK7E). // Visibility-gated: when the pane is hidden (not focused), ticks are // skipped so hidden panes spawn zero wl processes (pause-when-hidden). - let refreshTimer: ReturnType<typeof setInterval> | undefined; if (opts.autoRefresh) { - refreshTimer = setInterval(async () => { - if (!(await paneGate.visible())) { - panePaused = true; - startResumePoll(); - return; - } - stopResumePoll(); - panePaused = false; - doRefresh(false); - }, opts.refreshIntervalMs); - } - - // Auto-sync timer (background wl sync) — the only auto-sync source. Uses the - // single-flight + lock-aware (--if-idle) guard so concurrent panes/TUI - // instances skip instead of piling up under lock contention. - // Visibility-gated like the refresh timer: hidden panes skip the sync - // (and its follow-up refresh) entirely. - let syncTimer: ReturnType<typeof createSyncTimer> | undefined; + scheduler.addTask({ + id: 'refresh', + intervalMs: opts.refreshIntervalMs, + singleFlight: true, + run: async () => { + if (!(await paneGate.visible())) { + panePaused = true; + startResumePoll(); + return; + } + stopResumePoll(); + panePaused = false; + doRefresh(false); + }, + }); + } + + // Auto-sync task (background wl sync) — the only auto-sync source. Uses + // the single-flight + lock-aware (--if-idle) guard so concurrent + // panes/TUI instances skip instead of piling up under lock contention, + // and the cross-instance heartbeat (F3) so only the first pane per window + // spawns `wl sync` at all. Visibility-gated like the refresh task: hidden + // panes skip the sync (and its follow-up refresh) entirely. Fires once + // immediately on start (as the previous SyncTimer did) so the first sync + // cycle is not delayed. if (opts.autoSync && opts.syncIntervalMs !== 0) { - syncTimer = createSyncTimer({ + // Heartbeat window tied to the configured interval (interval minus a 15s + // margin) so the sync cadence still lands ~once per interval while only + // the first pane per window spawns a process. + const heartbeatTtlMs = heartbeatTtlForInterval(opts.syncIntervalMs); + scheduler.addTask({ + id: 'sync', intervalMs: opts.syncIntervalMs, - onSync: async () => { + fireImmediately: true, + run: async () => { if (!(await paneGate.visible())) { panePaused = true; startResumePoll(); @@ -2765,22 +3306,62 @@ export async function runWorklistTui( } stopResumePoll(); panePaused = false; - doSync(true); // ifIdle: skip when another sync is in-flight / lock held + doSync(true, heartbeatTtlMs); // ifIdle + heartbeat: skip when another sync is in-flight / fresh doRefresh(false); }, }); - syncTimer.start(); } + // Visibility resume-poll — runs only while the pane is hidden. Polls + // pane visibility on a short interval so the hidden → visible transition + // triggers an immediate doRefresh instead of waiting for the next + // refreshIntervalMs tick. The poll uses only herdr pane get (via the + // PollGate memoizer, TTL aligned with the poll interval) — never wl — + // preserving the zero-wl-when-hidden guarantee. + scheduler.addTask({ + id: 'resume-poll', + intervalMs: DEFAULT_POLL_GATE_TTL_MS, + singleFlight: true, + disabled: true, + run: async () => { + if (await paneGate.visible()) { + // Hidden → visible transition: refresh immediately (with the + // "refreshed" notification) and let the normal cadence resume. + stopResumePoll(); + panePaused = false; + doRefresh(true); + } + }, + }); + + // Downtime-worker task — polls the llama-proxy for idle state and, after + // the configured idle threshold, dispatches a pi agent pane (parent + // WL-0MSF49FMW009M06K). Unlike refresh/sync it is NOT visibility-gated: + // the worker runs while the worklist pane is open (parent Assumptions). + // Single-flight: the poller and dispatch guards inside the worker prevent + // overlapping work; the scheduler task itself is also single-flight. + // Scheduler-level watchdog (WL-0MSJIPHD0001L1J9): a tick run that hangs + // (e.g. an unbounded wl invocation) is abandoned after + // DOWNTIME_RUN_TIMEOUT_MS and the single-flight flag resets so the next + // tick retries — a hung run can never permanently wedge the downtime + // task until a pane restart. + if (opts.downtimeWorker) { + scheduler.addTask({ + id: 'downtime', + intervalMs: opts.downtimePollIntervalMs, + singleFlight: true, + runTimeoutMs: DOWNTIME_RUN_TIMEOUT_MS, + run: async () => { + await opts.downtimeWorker?.tick(); + }, + }); + } + + scheduler.start(); + // Cleanup on promise resolution promise.finally(() => { - if (refreshTimer !== undefined) { - clearInterval(refreshTimer); - } - if (syncTimer !== undefined) { - syncTimer.stop(); - } - stopResumePoll(); + scheduler.stop(); cleanup(); process.stdout.removeListener('resize', onResize); process.stdin.removeListener('data', onData); diff --git a/packages/shared/src/database.ts b/packages/shared/src/database.ts index 2580206d9..a12a51db2 100644 --- a/packages/shared/src/database.ts +++ b/packages/shared/src/database.ts @@ -1488,11 +1488,14 @@ export class WorklogDatabase { // unless --include-in-progress is set. // Items in the in_review stage are preserved even if their status // is 'completed' since they need to appear in wl next for review. + // Items in the done stage are excluded (terminal, not actionable) + // (WL-0MSGRJWRX0068W3W). const criticalItems = allItems.filter( item => item.priority === 'critical' && item.status !== 'deleted' && (item.status !== 'completed' || item.stage === 'in_review') && + item.stage !== 'done' && (includeInProgress || item.status !== 'in-progress') ); this.debug(`${debugPrefix} critical items from full set=${criticalItems.length}`); @@ -1866,11 +1869,15 @@ export class WorklogDatabase { // explicitly filtering by stage and may want completed items in that stage). // Also preserve items in the in_review stage - they need to appear in // wl next for review even though their status is 'completed'. + // Also exclude items in the done stage by default (WL-0MSGRJWRX0068W3W) - + // done items are terminal and not actionable; explicit --stage done still works. if (!stage) { pool = pool.filter( - item => item.status !== 'completed' || item.stage === 'in_review' + item => + (item.status !== 'completed' || item.stage === 'in_review') && + item.stage !== 'done' ); - this.debug(`${debugPrefix} filter: after completed=${pool.length}`); + this.debug(`${debugPrefix} filter: after completed/done=${pool.length}`); } // 4. Remove in-progress items by default (wl next recommends what to work on next, diff --git a/packages/tui/extensions/README.md b/packages/tui/extensions/README.md index 7e46f56bb..177d5daa5 100644 --- a/packages/tui/extensions/README.md +++ b/packages/tui/extensions/README.md @@ -1,203 +1,25 @@ -# TUI Extensions - -Extension modules for the Worklog TUI and Pi agent integration. - -## Settings - -The extension has five user-configurable settings: - -| Setting | Default | Description | -|---------|---------|-------------| -| `browseItemCount` | `5` | Number of work items shown in the browse list (1–50). Critical and completed/in_review items are always shown regardless of this limit — see [Selection List Behaviour](#selection-list-behaviour) | -| `showIcons` | `true` | Whether to show emoji icons in the browse list and preview widget | -| `showActivityIndicator` | `true` | Whether to show the activity indicator (⏵) in the footer | -| `showHelpText` | `true` | Whether to show the shortcut help text line in the browse selection overlay | -| `autoInjectEnabled` | `true` | Whether to auto-inject relevant work items into the system prompt before each agent turn | - -Settings are stored in Pi's canonical settings files under the `context-hub` -namespace. Settings changed via `/wl settings` are persisted to the project's -`.pi/settings.json`. - -### Project `.pi` Discovery - -The project settings directory is discovered the same way as the `.worklog` -directory (see `resolveWorklogDir()`/`resolvePiDir()` in `src/worklog-paths.ts`): - -1. Walk up from the current working directory toward the git repo root; the - **nearest** directory with a `.pi/settings.json` wins — a local settings - file in the working directory (or a closer ancestor) overrides the repo - root. -2. If no `.pi/settings.json` exists between the working directory and the - repo root, the **git repo root's** `.pi/settings.json` is used. -3. Outside a git repository, the working directory is used as-is. - -This means a project-level `.pi/settings.json` at the repo root is the -canonical settings location: running pi from a subdirectory (e.g. -`packages/herdr/`) loads and persists settings to the repo root unless a -closer `.pi/settings.json` exists. Unlike `.worklog`, worktree isolation is -not applied — `.pi` holds developer configuration, so settings are shared -across the repository. - -### Resolution Order - -Settings are resolved from multiple locations, with later sources overriding -earlier ones: - -| Order | Source | File | -|-------|--------|------| -| 1 | Built-in defaults | `DEFAULT_SETTINGS` (code) | -| 2 | Global settings | `~/.pi/agent/settings.json` → `{ "context-hub": { ... } }` | -| 3 | Project settings | `<project>/.pi/settings.json` → `{ "context-hub": { ... } }` | - -Project settings always win, allowing per-project overrides while individual -team members can set personal defaults globally. `project` is resolved -repo-root aware as described above (nearest `.pi/settings.json` wins, falling -back to the git repo root). - -### Auto-Refresh - -When the browse selection list overlay is open, the item list automatically -refreshes every 5 seconds. This ensures that newly created, updated, or -reassigned work items appear without requiring the user to close and re-open -the browse dialog. - -**Behaviour:** - -- The list re-fetches from the database every 5 seconds using the same - `wl next` command and stage filter as the initial load. -- The currently selected item remains selected after a refresh, matched by - work item ID. If the selected item no longer exists (e.g., was deleted or - filtered out), the selection falls back to the first item. -- The refresh is deferred while a chord shortcut key sequence is in progress - (e.g., after pressing a chord leader like `u`). Once the chord is resolved - or cancelled, normal refresh resumes. -- No visual flash, spinner, or notification is shown — the data updates - silently in-place. -- Auto-refresh is a hardcoded feature (5-second interval) with no - configuration UI. It only applies to the browse list overlay, not - to the detail view. - -**Idle gating (pause-when-idle):** The auto-refresh interval (and its -background `wl sync --if-idle` trigger) pauses when the selection list has -had **no keypresses for 30 seconds** (`IDLE_PAUSE_MS` in -`Worklog/lib/browse.ts`). This stops idle or hidden selection lists from -spawning `wl` subprocesses — each 5s tick previously spawned 4–5 `wl` -processes (~60/min per mounted widget), which with many concurrent pi -sessions caused severe memory and CPU pressure (WL-0MSB1N0HB0007N6N). - -- **Mount counts as interaction** — a freshly opened selection list starts - in the active state and refreshes normally. -- **Resume on keypress** — the first keypress after an idle pause triggers - an immediate refresh and resumes the normal 5s cadence. -- **No settings toggle** — the idle threshold is a named module constant - (30s), always on, fail-open (manual actions and navigation are never - gated). -- While the user actively browses, the cadence is unchanged (5s refresh). - -### Selection List Behaviour - -The default (unfiltered) selection list always shows **all** critical-priority -items and **all** completed/in_review items (the producer-review queue), -regardless of the `browseItemCount` setting: - -- Items with `priority=critical` are always included. -- Items with `status=completed` **and** `stage=in_review` are always included. -- The `browseItemCount` limit applies only to the remaining "other" items. - The number of "other" slots is `browseItemCount − (critical count) − - (completed/in_review count)`, floored at zero. -- When critical + completed/in_review items alone meet or exceed - `browseItemCount`, all of them are shown anyway (no hard cap on the - mandatory set) — the total may exceed the configured count. -- An item that is both critical and completed/in_review counts once - (deduplicated) toward the total. - -Example: with `browseItemCount=15`, 2 critical + 3 completed/in_review + -20 other items → the list shows 2 critical + 3 completed/in_review + the -first 10 others (15 total). If there were 20 completed/in_review items -instead of 3, all 22 mandatory items would be shown (22 > 15). - -The **stage-filtered** views (`/wl idea`, `/wl plan`, …) are unchanged: they -show only items matching the selected stage. - -The "top N of M" title reflects the **actual displayed count** (N), which -may exceed `browseItemCount` when the mandatory set is large. - -### Hierarchical Navigation (Drill into Children) - -The browse selection list supports navigating into child work items -when an item has children. This allows you to drill down through the -work-item hierarchy without leaving the browse dialog. - -**How it works:** - -- When an item in the browse list has children (`childCount > 0`), pressing - **Tab** on that item navigates into its children. All items with children - are visually marked with a child count indicator (e.g., `(3)`), - regardless of their issue type. -- **Enter** on any item (including parents with children) opens the detail - view, as before. -- Pressing **Escape** while viewing children navigates back one level in - the hierarchy. The footer shows a `[esc] back` hint (with `(N levels)` - when nested deeper than one level) while inside a child list. -- You can drill down **arbitrarily deep** through the hierarchy (children - of children of children, etc.) using the same Tab mechanism at each - level. -- When navigating back to a parent level (via Escape), the previously - selected item and list state are restored, so you return to the same - position you left. -- At the root level (no parent context), pressing Enter on an item without - children opens the detail view as before — behavior is unchanged for - non-parent items. - -**Example flow:** - -1. Browse the root list — items with children show `(N)` count indicators. -2. Press Tab on an epic or other item with children → the list updates to - show its child work items. -3. Press Tab on a child that also has children → navigate further down. -4. Press Escape to go back up one level. -5. At root level, pressing Enter on a leaf item opens the detail view. -6. Escape at root level closes the browse overlay. - -**Note:** When navigating within child items, the auto-refresh feature -calls `fetchChildren()` to re-fetch the child items of the current parent -in-place, rather than refreshing the root-level list. This ensures new -children appear, completed children disappear, and re-sorted items are -repositioned — all while staying at the same navigation level. At the -root level, the standard `wl next` refresh is used. - -### `/wl settings` Command - -Open the settings overlay by typing `/wl settings` in the Pi editor. This opens an interactive overlay where you can change settings using the arrow keys and Enter. - -- **Number of items**: Cycle through presets (3, 5, 10, 15, 20). Changes take effect immediately — the next `/wl` browse will use the new count. -- **Show icons**: Toggle between on/off. Changes are applied immediately — the preview widget and browse list reflect the change. -- **Activity indicator**: Toggle the activity indicator (⏵) in the footer on/off. When disabled, the footer line is hidden and no new indicators are shown. Existing indicators are cleared. -- **Help text**: Toggle the shortcut help text line in the browse selection overlay on/off. When disabled, the help line is hidden on the next browse overlay open. -- **Auto-inject items**: Toggle auto-injection of relevant work items before agent turns on/off. When enabled, the extension searches for related work items based on the prompt context and injects them into the system prompt automatically. - -Press `Escape` to close the settings overlay. - -### Settings File Format - -Settings in Pi's settings files are stored under the `context-hub` namespace. -Example `.pi/settings.json`: +# Worklog Pi Extension -```json -{ - "context-hub": { - "browseItemCount": 10, - "showIcons": false, - "showActivityIndicator": true, - "showHelpText": true, - "autoInjectEnabled": true - } -} -``` +Extension modules for the Worklog Pi agent integration. + +This extension auto-loads into every pi session via the global extension +install (`~/.pi/agent/extensions/worklog` → `packages/tui/extensions`). It +registers the agent-side plugin modules that complement the Herdr plugin +(which provides work item browsing and management): -When all settings files are missing or contain no `context-hub` section, -built-in defaults are used (5 items, icons enabled, activity indicator -enabled, help text enabled, auto-inject enabled). +- [Activity Indicator](#activity-indicator) +- [Session Health Footer](#session-health-footer) +- [Model/Provider Display](#modelprovider-display-line-3) +- [Guardrails](#guardrails) +- [Skill-path Tool](#skill-path-tool) +- [Error Recovery Module](#error-recovery-module) (`/retry`) +- [Lease Release](#lease-release) + +> The Pi-based TUI browse UI (chat pane, action palette, `/wl` command, +> `ctrl+shift+b` shortcut, settings overlay, auto-injection, and periodic +> request scheduler) has been removed from the repository — the Herdr plugin +> covers browsing, filtering, detail views, shortcuts, and code-freeze +> awareness. See `packages/herdr/` for that plugin. ## Activity Indicator @@ -210,7 +32,6 @@ the directory path and Git branch info. | Input Type | Example | Indicator Behavior | |------------|---------|-------------------| -| Extension commands (via `/wl` or `Ctrl+Shift+B` shortcut) | `/wl`, `/wl progress` | Shows `⏵ /wl` | | Skills | `/skill:audit WL-123` | Shows `⏵ skill:audit` | | Built-in Pi commands | `/model`, `/settings`, `/new` | Clears the indicator | | Free-form text | `Fix the login bug` | Clears the indicator | @@ -237,8 +58,6 @@ when used outside the Pi TUI. or status usage. - The indicator text is truncated to fit the terminal width with an ellipsis (`…`) for overflow. -- Extension commands registered by the Worklog extension itself (`/wl`, - `Ctrl+Shift+B`) set the indicator directly in their command handlers. - Skills (`/skill:name`) are captured via Pi's `input` event, which fires before skill expansion. - Built-in Pi commands and free-form text clear the indicator via the same @@ -282,7 +101,7 @@ a **three-section layout**. The third line shows the model/provider info and (optionally) an initial prompt preview: ``` -⏵ /wl ← Extension statuses +⏵ /skill:audit WL-123 ← Extension statuses ● Streaming 45s #5 Total: 5m 42s ↑1.2k ↓4.5k 39.1%/128k ← Session health code → openai/gpt-4 │ Fix the bug ← Model + prompt │ │ │ │ │ │ │ │ │ │ @@ -364,6 +183,26 @@ produce errors when used outside the Pi TUI. - Context usage is obtained from `ctx.getContextUsage()` which returns `{ tokens, contextWindow, percent }`. +## Guardrails + +The extension installs **guardrails** that protect worklog data by blocking +direct file writes/edits to protected worklog paths (`.worklog/` database and +JSONL files) and blocking dangerous shell commands that could damage worklog +data. Agents are directed to use `wl` commands instead. See +`docs/guardrails.md` for details. + +Implemented in `Worklog/lib/guardrails.ts` and registered via +`INSTALL_GUARDRAILS(pi, { enabled: true })`. + +## Skill-path Tool + +The extension registers a `skill_path` tool via `pi.registerTool()` that +resolves a skill's installation directory given a skill name (searches +`~/.pi/agent/skills/<name>/` and the project's `.pi/skills/<name>/`). +Agents use it to locate skill files (e.g., `SKILL.md`) during tasks. + +Implemented in `Worklog/lib/skill-path.ts`. + ## Error Recovery Module The extension includes a built-in automatic error recovery module that replaces the @@ -385,27 +224,23 @@ configuration. ### Configuration -Recovery settings are stored under `context-hub.recovery.*` in Pi's settings files. -Each category can be configured individually: +Recovery behaviour is driven by `DEFAULT_RECOVERY_CONFIG` in +`Worklog/lib/recovery/error-patterns.ts`. Each category can be configured: ```json { - "context-hub": { - "recovery": { - "serverError": { - "enabled": true, - "baseDelayMs": 2000, - "maxDelayMs": 60000 - }, - "timeout": { - "enabled": true, - "baseDelayMs": 2000, - "maxDelayMs": 60000 - }, - "rateLimit": { - "enabled": false - } - } + "serverError": { + "enabled": true, + "baseDelayMs": 2000, + "maxDelayMs": 60000 + }, + "timeout": { + "enabled": true, + "baseDelayMs": 2000, + "maxDelayMs": 60000 + }, + "rateLimit": { + "enabled": false } } ``` @@ -434,351 +269,6 @@ The recovery module is implemented in `Worklog/lib/recovery/` and consists of: The module is auto-registered during extension initialization in `index.ts`. -## Auto-Injection - -The extension automatically injects relevant work items into the system -prompt before each agent turn, providing context without requiring manual -`wl next` or `wl list` calls. - -### How It Works - -When a new agent turn begins, the `before_agent_start` hook triggers the -auto-injection pipeline: - -1. **ID Detection**: The user's prompt text is scanned for work item ID - patterns (e.g., `WL-0MQL0T5TR0060AEH`). All unique IDs are collected. - -2. **ID-Based Scanning** (when IDs are detected in the prompt): - - The referenced work item is fetched via `wl show`. - - Its **description** is scanned for embedded work item IDs. - - Its **comments** are fetched via `wl comment list` and scanned for IDs. - - Its **children** are fetched via `wl list --parent` and included directly. - - Each discovered related work item is fetched via `wl show` and added to - the result set, deduplicated against already-known IDs. - - The originally referenced ID is excluded from the related-items list. - -3. **Keyword Fallback** (only when NO work item ID is detected in the prompt): - A `wl search` is performed using the prompt keywords to find related - items (up to 5 results). This preserves backward compatibility for - prompts like "working on implementation task" that don't reference a - specific work item. - -4. **Formatting**: Found items are formatted as markdown context: - - **Full-detail mode** (≤3 items): Shows ID, title, and inline tags - for priority, status, and stage. - - **Links-only mode** (>3 items): Compact ID + title list. -5. **Injection**: The formatted context is appended to the system prompt - under a `## Relevant Work Items` heading. -6. **Status Indicator**: A status bar notification (e.g., `📋 3 items - auto-injected`) is shown briefly in the footer. - -### What Gets Injected - -**Full-detail mode** (≤3 items): -```markdown -## Relevant Work Items - -- **WL-123**: Fix login bug `high` `open` `in_progress` -- **WL-456**: Add tests `medium` `in_review` -``` - -**Links-only mode** (>3 items): -```markdown -## Relevant Work Items - -- WL-123: Fix login bug -- WL-456: Add tests -``` - -### Configuration - -Auto-injection can be toggled via the `autoInjectEnabled` setting: -- **`/wl settings`** — Toggle the "Auto-inject items" option on/off -- **`.pi/settings.json`** — Set `{ "context-hub": { "autoInjectEnabled": false } }` - -Changes take effect immediately. When disabled, the `before_agent_start` -handler returns without performing any search or injection. - -### Graceful Degradation - -- Missing or invalid work item IDs are silently skipped (no errors surfaced). -- Comment listing or child fetching failures are silently caught — the - handler degrades gracefully to still return the explicitly referenced IDs - and any successfully scanned sources. -- `wl search` keyword fallback failures are silently caught — the handler - returns only the explicitly referenced and discovered related IDs. -- When the prompt contains only IDs (no searchable text), ID-based scanning - still runs — description, comments, and children are scanned for related - work items. -- When no related items are found, the system prompt is left unmodified. -- In non-TUI modes (print, JSON, RPC), the status bar indicator is a no-op - with no errors. - -### Technical Notes - -- Implemented in `Worklog/lib/auto-inject.ts` and registered in `Worklog/index.ts`. -- Uses Pi's `before_agent_start` hook — available in the pi ExtensionAPI. -- The `AUTO_INJECT_STATUS_KEY` (`worklog-auto-inject`) is used for the - status bar indicator to avoid conflicts with other status entries. - -## `/wl` Slash Command — Stage Filtering - -The `/wl` slash command browses work items recommended by the `wl next` algorithm. The number of items shown is controlled by the `browseItemCount` setting (default: 5). It also supports an optional stage filter argument. - -### Usage - -``` -/wl # Show unfiltered work items (count from settings) -/wl settings # Open the settings overlay -/wl schedule # Manage periodic request schedules (see Periodic Request Scheduler) -/wl idea # Show items in idea stage -/wl intake # Show items in intake_complete stage -/wl plan # Show items in plan_complete stage -/wl progress # Show items in in_progress stage -/wl review # Show items in in_review stage -/wl in_progress # Canonical stage names also work -/wl in_review # Canonical stage names also work -``` - -### Stage Shorthand Aliases - -| Shorthand | Canonical Stage | -|-----------|----------------| -| `intake` | `intake_complete` | -| `plan` | `plan_complete` | -| `progress`| `in_progress` | -| `review` | `in_review` | - -All canonical stage names (`idea`, `in_progress`, `in_review`, `intake_complete`, `plan_complete`) are also recognised directly. - -### Invalid Values - -Typing an unrecognised stage value produces an error notification and falls back to the default unfiltered list without crashing. - -### Autocomplete - -The `/wl` command registers `getArgumentCompletions`, so Pi's editor shows autocomplete suggestions for valid stage values (both shorthand and canonical) and the `settings`/`schedule` commands when typing arguments. - -### Example - -- `/wl progress` — filters to items in `in_progress` stage -- `/wl in_review` — filters to items in `in_review` stage -- `/wl settings` — opens the settings overlay -- `/wl schedule list` — lists all configured periodic request schedules -- `/wl schedule add "0 1 * * *" "Daily audit"` — adds a daily audit schedule -- `/wl` — shows the default unfiltered items (count from settings) -- `/wl ` — whitespace-only arguments are treated as "no arguments" and show unfiltered items - -## Shortcuts - -Source config file: `packages/tui/extensions/Worklog/shortcuts.json` -(installed to `~/.pi/agent/extensions/worklog/Worklog/shortcuts.json`). - -The `shortcuts.json` config file defines a **config-driven shortcut system** that allows keyboard shortcuts in the Pi extension's worklog browse views (list and detail) to be expressed declaratively rather than hardcoded. - -### Schema - -Each shortcut entry is a JSON object. Entries use **either** `key` (single-character immediate dispatch) **or** `chord` (multi-key sequence) — they are mutually exclusive. - -Single-key entry: - -```json -{ - "key": "i", - "command": "implement <id>", - "view": "both", - "stages": ["intake_complete"], - "label": "implement", - "description": "Run the implement workflow on the selected work item" -} -``` - -Chord entry: - -```json -{ - "chord": ["u", "p"], - "command": "!!wl update --priority <id>", - "view": "both", - "label": "update priority", - "description": "Update the priority of the selected work item" -} -``` - -| Field | Type | Description | -|-------|------|-------------| -| `key` | string _(mutually exclusive with `chord`)_ | Single character key to trigger the shortcut immediately (e.g., `"i"`, `"p"`). Exactly one of `key` or `chord` must be set. | -| `chord` | string[] _(mutually exclusive with `key`)_ | Two-or-more character sequence that triggers the shortcut. The first key is the **leader** — pressing it enters a pending-chord state and the help line updates to show available completions. The second key (or remaining keys) completes the chord. Example: `["u", "p"]` means press `u` then `p`. | -| `command` | string | Template string to insert into the Pi editor. The placeholder `<id>` is replaced with the selected work item's ID. | -| `view` | string | Which view the shortcut applies to: `"list"` (browse selection only), `"detail"` (detail view only), or `"both"` (both views). | -| `label` | string _(optional)_ | Short display label shown in the browse help line (e.g., `"implement"`, `"plan"`). When provided, overrides the label derived from the command string. Chord entries are displayed as `leader:firstWord...` (e.g., `u:update...`) to keep the help line compact. | -| `description` | string _(optional)_ | One-sentence description of the command for use in help screens (e.g., `"Run the implement workflow on the selected work item"`). | -| `stages` | string[] _(optional)_ | Allow-list of work item stages for which the shortcut is available. When omitted or empty, the shortcut is unconditionally available (backward compatible). The stage comparison is case-sensitive, exact match. | - -### Stage-Based Visibility - -Shortcuts can be made conditional on the selected work item's stage using the optional `stages` field: - -- **With `stages` set**: The shortcut only appears and dispatches when the selected item's stage matches one of the listed values. -- **Without `stages`** (or `stages: []`): The shortcut is always available, preserving backward compatibility. - -This allows contextual shortcuts — for example, showing an **intake** shortcut only for items in the `idea` stage, and an **implement** shortcut only for items in the `intake_complete` stage. - -#### Visibility Rules - -| `stages` value | Behavior | -|----------------|----------| -| `undefined` (omitted) | Shortcut always available | -| `[]` (empty array) | Shortcut always available | -| `["idea"]` | Shortcut only available when item stage is `"idea"` | -| `["idea", "in_progress"]` | Shortcut available when item stage is `"idea"` or `"in_progress"` | - -### Chord Shortcuts - -Chord shortcuts let you dispatch commands with a two-key sequence. Press the **leader** key first — this does not dispatch anything. Instead, the help line updates to show available completions for that leader. Press the second key to complete the chord and dispatch the command. - -#### How Chords Work - -1. Press the leader key (e.g., `u`) — the shortcut does not fire. The help line updates to show available completions for that leader. -2. Press the completion key (e.g., `p`) — the full chord (`u-p`) is dispatched and the command is inserted into the editor. -3. Press `Escape` at any point during chord input to cancel. -4. Press an unrecognised completion key to cancel the pending chord. - -#### Examples - -| Chord | Command | Description | -|-------|---------|-------------| -| `u-p` | `!!wl update --priority <id>` | Update the priority of the selected work item | -| `u-t` | `!!wl update --title <id>` | Update the title of the selected work item | -| `f-i` | `/wl idea` | Filter browse list to items in the idea stage | -| `f-n` | `/wl intake` | Filter browse list to items in the intake_complete stage | -| `f-p` | `/wl plan` | Filter browse list to items in the plan_complete stage | -| `f-r` | `/wl review` | Filter browse list to items in the in_review stage | - -#### Chord Help Text - -When a chord leader key is pressed, the help line replaces the normal shortcut hints with the available chord completions for that leader. The pending state shows only the second key and the distinguishing part of the label (the first word is dropped since it's implied by the leader context). - -For example, pressing `u` while the help line is visible would show: -``` -🔗 p:priority t:title -``` - -#### Chord Stage Filtering - -Chord entries respect the same `stages` field as key-based shortcuts. If a chord entry has `stages` set, it only appears in the help line completions and only dispatches when the selected item's stage matches. Chords without a `stages` constraint (or with an empty array) are always available. - -#### Reserved Keys - -The same reserved navigation keys (`g`, `G`, ` `) that cannot be used as shortcut keys also cannot be chord leaders. Any chord entry with a reserved leader key is silently ignored. - -#### Key Differences from Single-Key Shortcuts - -| Aspect | Single-key shortcut | Chord shortcut | -|--------|-------------------|----------------| -| Trigger | Press key once | Press leader key, then completion key | -| Help text | Always visible | Shown after pressing the leader key | -| Cancel | N/A | Press `Escape` or unrecognised key | -| Entry format | `{"key": "i", ...}` | `{"chord": ["u", "p"], ...}` | - -### Current Shortcuts - -| Type | Key(s) | Command | View | Stages | Label | Description | -|------|--------|---------|------|--------|-------|-------------| -| key | `c` | `create <desc>` | both | `["idea"]` | create | Create a new work item with a description and priority template | -| key | `n` | `intake <id>` | both | `["idea"]` | intake | Create a new work item from the selected item via intake | -| key | `p` | `plan <id>` | both | `["intake_complete"]` | plan | Run the plan workflow on the selected work item | -| key | `i` | `implement <id>` | both | `["plan_complete"]` | implement | Run the implement workflow on the selected work item | -| key | `a` | `audit <id>` | both | (always available) | audit | Run an audit on the selected work item | -| chord | `u-p` | `!!wl update --priority <id>` | both | (always available) | update priority | Update the priority of the selected work item | -| chord | `u-t` | `!!wl update --title <id>` | both | (always available) | update title | Update the title of the selected work item | -| chord | `f-i` | `/wl idea` | both | (always available) | filter idea | Filter browse list to items in the idea stage | -| chord | `f-n` | `/wl intake` | both | (always available) | filter intake | Filter browse list to items in the intake_complete stage | -| chord | `f-p` | `/wl plan` | both | (always available) | filter plan | Filter browse list to items in the plan_complete stage | -| chord | `f-r` | `/wl review` | both | (always available) | filter in_review | Filter browse list to items in the in_review stage | - -### Help Text Filtering - -The help text shown in the browse list dynamically filters shortcuts based on the currently selected item's stage. As you navigate between items with different stages, the help text updates to show only applicable shortcuts. Both key-based and chord-based shortcuts are included in the help line. - -For example: -- Selecting an item in the `idea` stage shows `c:create`, `n:intake`, `u:update...`, and `a:audit`. -- Selecting an item in `intake_complete` shows `i:implement`, `p:plan`, `u:update...`, and `a:audit`. -- Selecting an item in `in_progress` shows `u:update...`, and `a:audit`. - -When a chord leader key (e.g. `u`) is pressed, the help line temporarily updates to show only the available chord completions for that leader, prefixed with `🔗`. Each completion is shown as the second key followed by the distinguishing part of the label: - -``` -🔗 p:priority t:title -``` - -### How It Works - -1. **Config loading**: `Worklog/shortcut-config.ts` loads `Worklog/shortcuts.json` at extension initialization and builds a `ShortcutRegistry` in memory. Key-based and chord-based entries are indexed separately for efficient lookup. -2. **Graceful degradation**: If the config file is missing or contains malformed JSON, the registry is empty (no shortcuts) and a warning is logged. Invalid entries (including entries with both `key` and `chord`, or missing required fields) are silently skipped. -3. **Dispatch**: Both the browse list dispatcher (`defaultChooseWorkItem`) and detail view dispatcher (`createScrollableWidget`) check a set of reserved navigation keys before attempting shortcut lookup. If the pressed key is reserved (see [Reserved Navigation Keys](#reserved-navigation-keys)), shortcut lookup is skipped and navigation takes precedence. - - **Single-key shortcuts**: For non-reserved single-character keys, `shortcutRegistry.lookup(key, view)` is called. If a match is found, the command template is substituted (`<id>` → selected item ID) and inserted into the editor. - - **Chord shortcuts**: If no single-key match is found, the registry checks if the key is a chord leader via `shortcutRegistry.getChordByLeader(key, view)`. If chords exist for that leader, the system enters a **pending-chord state** and updates the help line. Pressing a valid completion key triggers `shortcutRegistry.lookupChord([leader, completion], view)`, which dispatches the matching command. -4. **No trailing newline**: The inserted text has no trailing newline, allowing the user to review or edit the command before pressing Enter to submit. - -### Detail View Shortcut Hints - -The detail view overlay displays a shortcut hint line at the bottom of the rendered content (below the work item details). This hint line follows the same formatting and stage-filtering logic as the selection list hints: - -- Available shortcuts whose `view` includes `detail` or `both` are shown, filtered by the selected item's stage. -- When a chord leader key is pressed, the hint line temporarily updates to show available chord completions (same `🔗` prefix as the selection list). -- The hint line respects the `showHelpText` setting — hidden when disabled. -- The hint line is rendered as dim text, truncated to the terminal width, and is not part of the scrollable content. - -### Reserved Navigation Keys - -The following single-character keys are reserved for navigation and **cannot** be used as shortcut keys. Any shortcut entry in `shortcuts.json` with one of these keys will be silently ignored (navigation takes precedence): - -| Key | Navigation Action | View | -|-----|-------------------|------| -| `g` | Scroll to top | detail | -| `G` | Scroll to bottom | detail | -| ` ` (space) | Page down | detail | - -Multi-character navigation keys (e.g., escape sequences for arrow keys, key-id strings like `enter`, `escape`, `up`, `down`) are already excluded from shortcut lookup because the dispatcher only checks single-character keys. - -### Adding a New Shortcut - -#### Key-based Shortcut - -1. Add a new entry to `shortcuts.json` with the desired `key`, `command`, and `view`. -2. Ensure the `key` is not a reserved navigation key (see above). -3. The shortcut is immediately available — no code changes needed. - -Example: - -```json -{ - "key": "c", - "command": "close <id> --reason \"fixed\"", - "view": "detail" -} -``` - -#### Chord-based Shortcut - -1. Add a new entry to `shortcuts.json` with `chord` (an array of 2+ key strings), `command`, and `view`. -2. Ensure the first key in the chord is not a reserved navigation key. -3. Optionally add `label`, `description`, and `stages` fields. -4. The chord shortcut is immediately available — no code changes needed. - -Example: - -```json -{ - "chord": ["u", "p"], - "command": "!!wl update --priority <id>", - "view": "both", - "label": "update priority", - "description": "Update the priority of the selected work item" -} -``` - ## Lease Release The extension includes a **proactive lease release** module that automatically @@ -814,111 +304,3 @@ releases the previous session's model lease when a new Pi session is created - Results are cached per-extension-lifecycle to avoid repeated filesystem reads. - Registered in `Worklog/index.ts` via `registerLeaseRelease(pi)`. - Tests are in `Worklog/lease-release.test.ts`. - -## Periodic Request Scheduler - -The extension includes a **periodic request scheduler** that automatically submits pi requests on a recurring schedule using cron expressions. This is useful for automating recurring tasks such as hourly audits, daily intake checks, or nightly cleanup while the pi session is running. - -Requests are submitted through pi's normal message flow via `pi.sendUserMessage()`, making both the request and its response fully visible in the session history. - -### How It Works - -1. **Background ticker**: The scheduler runs a background interval (every 30 seconds by default) that checks all configured schedules. -2. **Cron matching**: For each enabled schedule, the current time is checked against the cron expression. If the expression matches the current minute, the request is a candidate for submission. -3. **Idle check**: Before submitting, the scheduler verifies that the pi agent is idle (no active streaming or tool execution) to avoid interrupting active work. If the agent is busy, the scheduled run is skipped for that minute. -4. **Duplicate prevention**: Once a schedule fires, it will not fire again within the same minute, preventing duplicate submissions. -5. **Visibility**: The request is submitted via `pi.sendUserMessage()`, so it appears as a user message in the session log alongside any pi responses. - -### The `/wl schedule` Command - -Manage configured schedules via the `/wl schedule` command. Autocomplete for `schedule` is available when typing `/wl ` in Pi's editor. - -#### Subcommands - -| Subcommand | Description | -|------------|-------------| -| `list` | List all configured schedules with their ID, cron expression, request text, label, and enabled status | -| `add <cron> <request>` | Add a new schedule with a cron expression and pi request text. Optionally add `--label <label>` for display | -| `remove <id>` | Remove a schedule by its unique ID | -| `toggle <id>` | Enable or disable a schedule by its ID without removing it | -| `help` | Show help text with usage and examples | - -#### Examples - -``` -/wl schedule add "0 1 * * *" "/skill:audit WL-123" --label "Daily audit" -/wl schedule add "*/15 * * * *" "Check for new work items" -/wl schedule list -/wl schedule toggle sched-abc123-1 -/wl schedule remove sched-abc123-1 -``` - -### Cron Expression Format - -The scheduler supports standard 5-field cron expressions: - -``` - ┌────────── minute (0-59) - │ ┌───────── hour (0-23) - │ │ ┌──────── day of month (1-31) - │ │ │ ┌────── month (1-12) - │ │ │ │ ┌──── day of week (0-7, 0 and 7 are Sunday) - │ │ │ │ │ - * * * * * -``` - -Supported syntax: - -| Syntax | Description | Example | -|--------|-------------|---------| -| `*` | Wildcard — matches every value | `*` in minute field = every minute | -| `N` | Exact value | `0` in hour field = midnight only | -| `N,M` | List of values | `1,15` in minute field = at minutes 1 and 15 | -| `N-M` | Range of values (inclusive) | `9-17` in hour field = every hour from 9am to 5pm | -| `*/N` | Step with wildcard | `*/15` in minute field = every 15 minutes | -| `N-M/N` | Step with range | `1-30/10` in minute field = at 1, 11, 21 minutes past the hour | - -#### Common Examples - -| Cron Expression | Description | -|----------------|-------------| -| `0 * * * *` | Every hour at minute 0 | -| `*/15 * * * *` | Every 15 minutes | -| `0 1 * * *` | Daily at 1:00 AM | -| `0 9,17 * * 1-5` | Weekdays at 9:00 AM and 5:00 PM | -| `0 0 * * 0` | Midnight every Sunday | - -### Configuration Persistence - -Schedule configuration is persisted to the project's `.pi/settings.json` under the `context-hub` namespace and survives pi restarts. When a new session starts (`session_start`), the scheduler reloads schedules from disk and resumes. - -Example `.pi/settings.json`: - -```json -{ - "context-hub": { - "schedules": [ - { - "id": "sched-abc123-1", - "cron": "0 1 * * *", - "request": "/skill:audit WL-123", - "label": "Daily audit", - "enabled": true - } - ] - } -} -``` - -Schedules can be added and removed via `/wl schedule add` and `/wl schedule remove`, or by editing the settings file directly. - -### Technical Notes - -- Implemented in `Worklog/lib/scheduler.ts`. -- Uses a minimal bundled cron expression parser with zero external dependencies. -- Agent idle state is tracked via pi lifecycle events (`turn_start`, `message_end`, `tool_execution_start`). -- The background ticker runs at 30-second intervals (not configurable via settings, but adjustable in code). -- The scheduler is registered in `Worklog/index.ts` via `registerScheduler(pi)`. -- Lifecycle: starts on `session_start`, stops on `session_shutdown`. -- Tests are in `Worklog/lib/scheduler.test.ts` (48 tests). -``` diff --git a/packages/tui/extensions/Worklog/actionPalette.ts b/packages/tui/extensions/Worklog/actionPalette.ts deleted file mode 100644 index 5c7bf899a..000000000 --- a/packages/tui/extensions/Worklog/actionPalette.ts +++ /dev/null @@ -1,477 +0,0 @@ -// Action Palette for Pi TUI -// Provides a keyboard-first action palette for invoking agent-driven flows -// that map to wl CLI commands. - -import { EventEmitter } from "events"; -import { runWl, wlEvents } from "../wl-integration.js"; -import { ChatPane } from "./chatPane.js"; - -/** - * An action that can be triggered from the palette. - */ -export interface Action { - /** Unique action ID */ - id: string; - /** Display label shown in the palette */ - label: string; - /** Short description shown below the label */ - description: string; - /** Keyboard shortcut hint (e.g. "Ctrl+L") */ - shortcut?: string; - /** - * Execute the action. May return a result string or Promise<string>. - * Return null/undefined to indicate no output. - */ - execute: () => Promise<string | void> | string | void; - /** Whether this action requires confirmation before execution */ - requiresConfirmation?: boolean; - /** Category for grouping/filtering (e.g. "Work Items", "Navigation", "System") */ - category?: string; -} - -/** - * ActionPalette provides a keyboard-first palette UI for invoking - * agent-driven flows that map to wl CLI commands. - */ -export class ActionPalette { - private actions: Map<string, Action> = new Map(); - private eventEmitter: EventEmitter; - private selectedIndex = -1; - private filteredActions: Action[] = []; - private filterText = ""; - private isOpen = false; - - constructor( - private chatPane: ChatPane, - options: { initialActions?: Action[] } = {} - ) { - this.eventEmitter = new EventEmitter(); - // Register default actions - this.registerDefaultActions(); - if (options.initialActions) { - for (const action of options.initialActions) { - this.registerAction(action); - } - } - } - - /** - * Register an action in the palette. - */ - registerAction(action: Action): void { - this.actions.set(action.id, action); - this.applyFilter(); - } - - /** - * Unregister an action from the palette. - */ - unregisterAction(id: string): void { - this.actions.delete(id); - this.applyFilter(); - } - - /** - * Get all registered action IDs. - */ - getActionIds(): string[] { - return Array.from(this.actions.keys()); - } - - /** - * Get an action by ID. - */ - getAction(id: string): Action | undefined { - return this.actions.get(id); - } - - /** - * Get all actions matching the current filter. - */ - getFilteredActions(): Action[] { - return this.filteredActions; - } - - /** - * Open the action palette and apply the current filter. - */ - open(): void { - this.isOpen = true; - this.selectedIndex = -1; - this.applyFilter(); - this.emit("palette-open", { actions: this.filteredActions }); - } - - /** - * Close the action palette. - */ - close(): void { - this.isOpen = false; - this.selectedIndex = -1; - this.emit("palette-close", {}); - } - - /** - * Check if the palette is open. - */ - isOpened(): boolean { - return this.isOpen; - } - - /** - * Set the filter text and re-apply the filter. - */ - setFilter(text: string): void { - this.filterText = text.toLowerCase().trim(); - this.selectedIndex = -1; - this.applyFilter(); - } - - /** - * Move selection up by one. - */ - selectPrev(): void { - if (this.filteredActions.length === 0) return; - this.selectedIndex = - this.selectedIndex <= 0 - ? this.filteredActions.length - 1 - : this.selectedIndex - 1; - this.emit("selection-change", { index: this.selectedIndex, action: this.filteredActions[this.selectedIndex] }); - } - - /** - * Move selection down by one. - */ - selectNext(): void { - if (this.filteredActions.length === 0) return; - this.selectedIndex = - this.selectedIndex >= this.filteredActions.length - 1 ? 0 : this.selectedIndex + 1; - this.emit("selection-change", { index: this.selectedIndex, action: this.filteredActions[this.selectedIndex] }); - } - - /** - * Execute the currently selected action. - */ - async executeSelected(): Promise<string | void> { - if (this.selectedIndex < 0 || this.selectedIndex >= this.filteredActions.length) { - return; - } - const action = this.filteredActions[this.selectedIndex]; - if (action.requiresConfirmation) { - this.emit("confirm-action", { action }); - return; - } - return this.executeAction(action); - } - - /** - * Execute a specific action by ID. - */ - async executeAction(action: Action): Promise<string | void> { - const result = await action.execute(); - this.emit("action-executed", { action, result }); - return result; - } - - /** - * Subscribe to palette events. - * Events: "palette-open", "palette-close", "selection-change", - * "confirm-action", "action-executed", "error" - */ - on(event: string, listener: (data: any) => void): void { - this.eventEmitter.on(event, listener); - } - - /** - * Remove a palette event listener. - */ - off(event: string, listener: (data: any) => void): void { - this.eventEmitter.off(event, listener); - } - - /** - * Emit an event to subscribers. - */ - private emit(event: string, data: unknown): void { - this.eventEmitter.emit(event, data); - } - - /** - * Apply the current filter text to the actions. - */ - private applyFilter(): void { - if (!this.filterText) { - this.filteredActions = Array.from(this.actions.values()); - return; - } - this.filteredActions = Array.from(this.actions.values()).filter( - (a) => - a.id.toLowerCase().includes(this.filterText) || - a.label.toLowerCase().includes(this.filterText) || - a.description.toLowerCase().includes(this.filterText) || - (a.category && a.category.toLowerCase().includes(this.filterText)) - ); - } - - /** - * Register all default actions that map to wl CLI commands. - */ - private registerDefaultActions(): void { - // --- Navigation --- - this.registerAction({ - id: "wl-next", - label: "Next Task", - description: "Show the recommended next task", - shortcut: "Ctrl+N", - category: "Navigation", - execute: async () => { - try { - const item = await runWl("next"); - if (item && typeof item === "object" && "id" in item) { - const id = (item as any).id; - const title = (item as any).title || "Untitled"; - const status = (item as any).status || "unknown"; - return `Suggested next: ${id}: ${title} [${status}]`; - } - return "No next task recommended."; - } catch (err) { - throw new Error(`wl next failed: ${err instanceof Error ? err.message : String(err)}`); - } - }, - }); - - this.registerAction({ - id: "wl-list", - label: "List Work Items", - description: "Show the first 10 work items", - shortcut: "Ctrl+L", - category: "Navigation", - execute: async () => { - try { - const items = await runWl("list", ["-n", "10"]); - if (Array.isArray(items) && items.length > 0) { - return items.map((i: any) => ` ${i.id}: ${i.title} [${i.status}]`).join("\n"); - } - return "No work items found."; - } catch (err) { - throw new Error(`wl list failed: ${err instanceof Error ? err.message : String(err)}`); - } - }, - }); - - // --- Work Item Actions --- - this.registerAction({ - id: "wl-create", - label: "Create Work Item", - description: "Create a new work item via chat", - shortcut: "Ctrl+C", - category: "Work Items", - execute: async () => { - const description = "New work item"; - try { - // Phase 3: direct DB write - const id = await createWorkItemDb(description); - if (id) { - return `Created: ${id}`; - } - return "Work item created."; - } catch (err) { - throw new Error(`wl create failed: ${err instanceof Error ? err.message : String(err)}`); - } - }, - requiresConfirmation: true, - }); - - this.registerAction({ - id: "wl-close", - label: "Close Work Item", - description: "Close a work item by ID", - shortcut: "Ctrl+Shift+C", - category: "Work Items", - execute: async () => { - const id = promptInput("Enter work item ID to close:"); - if (!id) return "Cancelled."; - try { - // Phase 3: direct DB write - const closed = await closeWorkItemDb(id); - if (closed) { - return `Closed: ${id}`; - } - return `Closed: ${id}`; - } catch (err) { - throw new Error(`wl close failed: ${err instanceof Error ? err.message : String(err)}`); - } - }, - requiresConfirmation: true, - }); - - this.registerAction({ - id: "wl-update", - label: "Update Work Item", - description: "Update a work item's description", - shortcut: "Ctrl+E", - category: "Work Items", - execute: async () => { - const id = promptInput("Enter work item ID to update:"); - if (!id) return "Cancelled."; - const desc = promptInput("Enter new description:"); - if (!desc) return "Cancelled."; - try { - await runWl("update", [id, "--description", desc]); - return `Updated: ${id}`; - } catch (err) { - throw new Error(`wl update failed: ${err instanceof Error ? err.message : String(err)}`); - } - }, - requiresConfirmation: true, - }); - - this.registerAction({ - id: "wl-show", - label: "Show Work Item", - description: "Show details of a specific work item", - shortcut: "Ctrl+S", - category: "Navigation", - execute: async () => { - const id = promptInput("Enter work item ID to show:"); - if (!id) return "Cancelled."; - try { - const item = await runWl("show", [id]); - if (item && typeof item === "object") { - return formatWorkItemDisplay(item as any); - } - return `Work item ${id} not found.`; - } catch (err) { - throw new Error(`wl show failed: ${err instanceof Error ? err.message : String(err)}`); - } - }, - }); - - this.registerAction({ - id: "wl-search", - label: "Search Work Items", - description: "Search for work items by keyword", - shortcut: "Ctrl+F", - category: "Navigation", - execute: async () => { - const query = promptInput("Enter search query:"); - if (!query) return "Cancelled."; - try { - const items = await runWl("search", [query]); - if (Array.isArray(items) && items.length > 0) { - return items.map((i: any) => ` ${i.id}: ${i.title} [${i.status}]`).join("\n"); - } - return `No results for "${query}".`; - } catch (err) { - throw new Error(`wl search failed: ${err instanceof Error ? err.message : String(err)}`); - } - }, - }); - - // --- Chat --- - this.registerAction({ - id: "chat", - label: "Start Agent Conversation", - description: "Open the chat pane for freeform agent interaction", - shortcut: "Ctrl+/", - category: "Agent", - execute: async () => { - return "Chat pane opened. Type your request in the chat input."; - }, - }); - - // --- Agent Flows --- - this.registerAction({ - id: "agent-claim", - label: "Claim Next Task", - description: "Automatically claim the next recommended task", - shortcut: "Ctrl+Shift+L", - category: "Agent", - execute: async () => { - try { - const next = await runWl("next"); - if (next && typeof next === "object" && "id" in next) { - const id = (next as any).id; - await updateWorkItemDb(id, { assignee: "OpenAI-Agent" }); - return `Claimed: ${id}`; - } - return "No tasks available to claim."; - } catch (err) { - throw new Error(`Claim failed: ${err instanceof Error ? err.message : String(err)}`); - } - }, - requiresConfirmation: true, - }); - - this.registerAction({ - id: "agent-comment", - label: "Add Comment", - description: "Add a comment to a work item", - shortcut: "Ctrl+M", - category: "Agent", - execute: async () => { - const id = promptInput("Enter work item ID:"); - if (!id) return "Cancelled."; - const content = promptInput("Enter comment text:"); - if (!content) return "Cancelled."; - try { - await addCommentDb(id, "TUI User", content); - return `Comment added to ${id}.`; - } catch (err) { - throw new Error(`Comment failed: ${err instanceof Error ? err.message : String(err)}`); - } - }, - requiresConfirmation: true, - }); - - this.registerAction({ - id: "agent-status", - label: "Change Status", - description: "Change the status of a work item", - shortcut: "Ctrl+T", - category: "Agent", - execute: async () => { - const id = promptInput("Enter work item ID:"); - if (!id) return "Cancelled."; - const status = promptInput("Enter new status (open/in-progress/closed):"); - if (!status) return "Cancelled."; - try { - await updateWorkItemDb(id, { status }); - return `Updated status to ${status} for ${id}.`; - } catch (err) { - throw new Error(`Status change failed: ${err instanceof Error ? err.message : String(err)}`); - } - }, - requiresConfirmation: true, - }); - } -} - -/** - * Prompt for input via the console. - * In a full TUI implementation, this would use the TUI input component. - */ -function promptInput(promptText: string): string { - // For headless/CI testing, return empty to cancel - // In a real TUI, this would show a modal input - return ""; -} - -/** - * Format a work item for display. - */ -function formatWorkItemDisplay(item: any): string { - const lines = [ - `${item.id}: ${item.title}`, - `Status: ${item.status || "unknown"}`, - `Priority: ${item.priority || "medium"}`, - `Type: ${item.issueType || "task"}`, - `Stage: ${item.stage || "unknown"}`, - `Assignee: ${item.assignee || "unassigned"}`, - ]; - if (item.description) { - const desc = item.description.substring(0, 200); - lines.push(`Description: ${desc}`); - } - return lines.join("\n"); -} diff --git a/packages/tui/extensions/Worklog/chatPane.ts b/packages/tui/extensions/Worklog/chatPane.ts deleted file mode 100644 index 0754a863e..000000000 --- a/packages/tui/extensions/Worklog/chatPane.ts +++ /dev/null @@ -1,530 +0,0 @@ -// Chat Pane for Pi TUI -// Provides an agent chat interface that interprets natural language and -// delegates to wl CLI commands via the integration layer. - -import { EventEmitter } from "events"; -import { realpathSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { createRequire } from "node:module"; -import { runWl, wlEvents, getWorklogDb } from "../wl-integration.js"; -import { createWorkItemDb, updateWorkItemDb, closeWorkItemDb, addCommentDb } from "./lib/tools.js"; - -// Use createRequire with realpath-resolved path for symlink-safe imports. -const _require = createRequire(realpathSync(fileURLToPath(import.meta.url))); -const { WlError } = _require("../../../../dist/wl-integration/spawn.js"); - -/** A single message in the chat history */ -export interface ChatMessage { - id: string; - role: "user" | "agent"; - content: string; - timestamp: number; - /** Optional parsed action that was executed */ - action?: ChatAction; -} - -/** An action that the agent can execute based on user input */ -export interface ChatAction { - type: "wl-create" | "wl-update" | "wl-close" | "wl-list" | "wl-show" | "wl-next" | "wl-search" | "wl-claim" | "wl-assign" | "wl-status" | "wl-stage" | "wl-comment" | "wl-re-sort" | "generic"; - description: string; - /** Raw wl command args */ - wlArgs?: string[]; -} - -/** State for the chat pane */ -export interface ChatPaneState { - messages: ChatMessage[]; - /** Whether the agent is currently processing a request */ - isProcessing: boolean; - /** Current conversation id (for session tracking) */ - conversationId?: string; -} - -const MAX_MESSAGES = 100; -let messageIdCounter = 0; - -/** - * ChatPane manages the agent chat interface. - * It interprets natural language from the user, delegates to wl CLI commands, - * and displays results as agent messages. - */ -export class ChatPane { - public state: ChatPaneState; - private eventEmitter: EventEmitter; - - constructor( - public readonly title: string = "Agent Chat", - options: { initialMessages?: ChatMessage[]; conversationId?: string } = {} - ) { - this.state = { - messages: options.initialMessages || [], - isProcessing: false, - conversationId: options.conversationId, - }; - this.eventEmitter = new EventEmitter(); - } - - /** Get a unique message ID */ - private nextId(): string { - return `msg-${++messageIdCounter}-${Date.now()}`; - } - - /** - * Emit a chat event to subscribers. - */ - private emit(event: string, data: unknown): void { - this.eventEmitter.emit(event, data); - if (event === "message-added") { - // Trim old messages - if (this.state.messages.length > MAX_MESSAGES) { - this.state.messages = this.state.messages.slice(-MAX_MESSAGES); - } - } - } - - /** - * Subscribe to chat events. - * Events: "message-added", "processing-start", "processing-end", "error" - */ - on(event: string, listener: (data: any) => void): void { - this.eventEmitter.on(event, listener); - } - - /** - * Remove a chat event listener. - */ - off(event: string, listener: (data: any) => void): void { - this.eventEmitter.off(event, listener); - } - - /** - * Send a message from the user. The agent interprets the message and - * may execute wl CLI commands on its behalf. - * - * @param message - The user's message text - * @returns The agent's response message - */ - async sendMessage(message: string): Promise<ChatMessage> { - if (this.state.isProcessing) { - return { - id: this.nextId(), - role: "agent", - content: "I am currently processing a request. Please wait.", - timestamp: Date.now(), - }; - } - - // Add user message - const userMsg: ChatMessage = { - id: this.nextId(), - role: "user", - content: message, - timestamp: Date.now(), - }; - this.state.messages.push(userMsg); - this.emit("message-added", userMsg); - - // Mark processing - this.state.isProcessing = true; - this.emit("processing-start", { message: userMsg }); - - try { - const response = await this.processMessage(message); - this.emit("message-added", response); - return response; - } catch (err) { - const errorMsg: ChatMessage = { - id: this.nextId(), - role: "agent", - content: `Error processing request: ${err instanceof Error ? err.message : String(err)}`, - timestamp: Date.now(), - }; - this.emit("message-added", errorMsg); - this.emit("error", { error: err, message: userMsg }); - return errorMsg; - } finally { - this.state.isProcessing = false; - this.emit("processing-end", { message: userMsg }); - } - } - - /** - * Process a user message and generate an agent response. - * Uses natural language parsing to determine which wl CLI commands to run. - */ - private async processMessage(message: string): Promise<ChatMessage> { - const lower = message.toLowerCase().trim(); - - // --- Keyword-based intent routing --- - // "list work items" or "show work items" - if (/\b(list|show|get)\b.*\b(work item|work item|item|task|issue)\b/.test(lower) || - lower === "wl list" || lower === "list" || lower === "show me my work") { - return await this.handleWlList(message); - } - - // "next task" or "what should I work on" - if (/\b(next|next\s+task|what.*work|what.*do|suggest|recommend)\b/.test(lower)) { - return await this.handleWlNext(message); - } - - // "show <id>" or "show me <id>" - const showMatch = message.match(/\bshow\b\s+(?:me\s+)?([A-Z]+-\d+)/i); - if (showMatch) { - return await this.handleWlShow(showMatch[1], message); - } - - // "create <description>" or "create a work item: <desc>" - if (/\b(create|add|make|new)\b/.test(lower)) { - return await this.handleWlCreate(message); - } - - // "update <id> with <details>" - const updateMatch = message.match(/\bupdate\b\s+(?:work\s+item\s+)?([A-Z]+-\d+)\b.*?(?:with|to|set)\s+(.+)/i); - if (updateMatch) { - return await this.handleWlUpdate(updateMatch[1], updateMatch[2], message); - } - - // "close <id>" or "close work item <id>" - const closeMatch = message.match(/\bclose\b.*?([A-Z]+-\d+)/i); - if (closeMatch) { - return await this.handleWlClose(closeMatch[1], message); - } - - // "search for <query>" - const searchMatch = message.match(/\b(search|find|look\s+for)\b\s+(?:for\s+)?(.+)/i); - if (searchMatch) { - return await this.handleWlSearch(searchMatch[2], message); - } - - // "claim" or "assign to me" - if (/\b(claim|assign\s+to\s+me|take|my)\b/.test(lower)) { - return await this.handleWlClaim(message); - } - - // "status" or "what is the status" - if (/\bstatus\b/.test(lower)) { - return await this.handleWlList(message); - } - - // "comment on <id>" or "add a comment to <id>" - const commentMatch = message.match(/\b(comment|add\s+a\s+comment|note)\b.*?([A-Z]+-\d+)\b.*?(?:to|on)\s+(.+)/i); - if (commentMatch) { - return await this.handleWlComment(commentMatch[2], commentMatch[3], message); - } - - // Fallback: treat as a freeform request to the agent - return await this.handleAgentFallback(message); - } - - /** - * Handle wl list command - */ - private async handleWlList(_message: string): Promise<ChatMessage> { - try { - const items = await runWl("list", ["-n", "5"]); - const count = Array.isArray(items) ? items.length : 0; - const response = count > 0 - ? `Found ${count} work item(s):\n${this.formatListItems(items as any[])}` - : "No work items found."; - return this.createAgentMessage(response); - } catch (err) { - return this.createAgentMessage( - `Failed to list work items: ${err instanceof Error ? err.message : String(err)}` - ); - } - } - - /** - * Handle wl next command - */ - private async handleWlNext(_message: string): Promise<ChatMessage> { - try { - const item = await runWl("next"); - if (item && typeof item === "object" && "id" in item) { - const id = (item as any).id; - const title = (item as any).title || "Untitled"; - const status = (item as any).status || "unknown"; - return this.createAgentMessage( - `Suggested next task:\n\n**${id}: ${title}**\nStatus: ${status}\n\nUse \`/worklog show ${id}\` to see details.` - ); - } - return this.createAgentMessage("No next task recommended at this time."); - } catch (err) { - return this.createAgentMessage( - `Failed to get next task: ${err instanceof Error ? err.message : String(err)}` - ); - } - } - - /** - * Handle wl show command - */ - private async handleWlShow(id: string, _message: string): Promise<ChatMessage> { - try { - const item = await runWl("show", [id]); - if (item && typeof item === "object") { - return this.createAgentMessage(this.formatWorkItem(item as any)); - } - return this.createAgentMessage(`Work item ${id} not found.`); - } catch (err) { - return this.createAgentMessage( - `Failed to show ${id}: ${err instanceof Error ? err.message : String(err)}` - ); - } - } - - /** - * Handle wl create command - */ - private async handleWlCreate(message: string): Promise<ChatMessage> { - // Extract description from the message after "create" keyword - let description = message.replace(/^(create|add|make|new)\s+(?:work\s+item\s+(?:called)?)?\s*/i, "").trim(); - if (!description) { - return this.createAgentMessage( - "I need a description to create a work item. For example: \"Create work item: Fix login bug\"" - ); - } - - // Remove "called" prefix - description = description.replace(/^called\s+/i, "").trim(); - - try { - const id = await createWorkItemDb(description); - if (id) { - return this.createAgentMessage( - `Created work item: **${id}**\n\nTitle: ${description}\n\nUse \`/worklog show ${id}\` to see details.`, - { - type: "wl-create", - description: `Created work item ${id}`, - wlArgs: ["create", "-t", description, "--description", description], - } - ); - } - return this.createAgentMessage("Work item created successfully."); - } catch (err) { - return this.createAgentMessage( - `Failed to create work item: ${err instanceof Error ? err.message : String(err)}` - ); - } - } - - /** - * Handle wl update command - */ - private async handleWlUpdate(id: string, details: string, _message: string): Promise<ChatMessage> { - try { - const updated = await updateWorkItemDb(id, { description: details }); - if (updated) { return `Updated ${id}: ${updated}`; } - const result = await runWl("update", [id, "--description", details]); - if (result && typeof result === "object") { - return this.createAgentMessage( - `Updated work item **${id}**.\n\nNew description: ${details}`, - { - type: "wl-update", - description: `Updated work item ${id}`, - wlArgs: ["update", id, "--description", details], - } - ); - } - return this.createAgentMessage(`Updated work item ${id}.`); - } catch (err) { - return this.createAgentMessage( - `Failed to update ${id}: ${err instanceof Error ? err.message : String(err)}` - ); - } - } - - /** - * Handle wl close command - */ - private async handleWlClose(id: string, _message: string): Promise<ChatMessage> { - try { - const closed = await closeWorkItemDb(id); - if (closed) { return `Closed: ${id}`; } - const result = await runWl("close", [id]); - if (result && typeof result === "object") { - return this.createAgentMessage( - `Closed work item **${id}**.`, - { - type: "wl-close", - description: `Closed work item ${id}`, - wlArgs: ["close", id], - } - ); - } - return this.createAgentMessage(`Closed work item ${id}.`); - } catch (err) { - return this.createAgentMessage( - `Failed to close ${id}: ${err instanceof Error ? err.message : String(err)}` - ); - } - } - - /** - * Handle wl search command - */ - private async handleWlSearch(query: string, _message: string): Promise<ChatMessage> { - try { - let items: any[] = []; - const db = getWorklogDb(); - if (db) { - try { items = db.search(query, 10); } catch { /* fall through */ } - } - if (!Array.isArray(items) || items.length === 0) { - items = await runWl("search", [query]); - } - const count = Array.isArray(items) ? items.length : 0; - if (count > 0) { - return this.createAgentMessage( - `Found ${count} result(s) for "${query}":\n${this.formatListItems(items as any[])}` - ); - } - return this.createAgentMessage(`No results found for "${query}".`); - } catch (err) { - return this.createAgentMessage( - `Failed to search: ${err instanceof Error ? err.message : String(err)}` - ); - } - } - - /** - * Handle wl claim command - */ - private async handleWlClaim(_message: string): Promise<ChatMessage> { - // Find the next unassigned item and claim it - try { - const next = await runWl("next"); - if (next && typeof next === "object" && "id" in next) { - const id = (next as any).id; - try { - const result = await runWl("update", [id, "--assignee", "OpenAI-Agent"]); - return this.createAgentMessage( - `Claimed next task: **${id}**\n\nTitle: ${(next as any).title}`, - { - type: "wl-claim", - description: `Claimed work item ${id}`, - wlArgs: ["update", id, "--assignee", "OpenAI-Agent"], - } - ); - } catch (err) { - return this.createAgentMessage( - `Found next task ${id} but failed to claim: ${err instanceof Error ? err.message : String(err)}` - ); - } - } - return this.createAgentMessage("No unassigned tasks available to claim."); - } catch (err) { - return this.createAgentMessage( - `Failed to find next task: ${err instanceof Error ? err.message : String(err)}` - ); - } - } - - /** - * Handle wl comment command - */ - private async handleWlComment(id: string, content: string, _message: string): Promise<ChatMessage> { - try { - const result = await addCommentDb(id, "TUI User", content) - if (result) { return `Comment added: ${id}`; } - const dbResult = await runWl("comment", ["add", id, "--comment", content]); - if (result && typeof result === "object") { - return this.createAgentMessage( - `Added comment to **${id}**: ${content}` - ); - } - return this.createAgentMessage(`Comment added to ${id}.`); - } catch (err) { - return this.createAgentMessage( - `Failed to add comment: ${err instanceof Error ? err.message : String(err)}` - ); - } - } - - /** - * Handle freeform agent conversation (no specific wl command detected). - */ - private async handleAgentFallback(message: string): Promise<ChatMessage> { - // For now, echo the message as a greeting/acknowledgment. - // In a full implementation, this would call the Pi agent API. - const response = `You said: "${message}"\n\nI can help you with work item management. Try commands like:\n- "list work items"\n- "create a work item: fix bug"\n- "show WL-123"\n- "close WL-456"\n- "search for auth"\n- "claim next task"`; - return this.createAgentMessage(response); - } - - /** - * Create an agent response message. - */ - private createAgentMessage(content: string, action?: ChatAction): ChatMessage { - const msg: ChatMessage = { - id: this.nextId(), - role: "agent", - content, - timestamp: Date.now(), - action, - }; - this.state.messages.push(msg); - this.emit("message-added", msg); - return msg; - } - - /** - * Format a list of work items for display. - */ - private formatListItems(items: any[]): string { - return items - .slice(0, 10) - .map( - (item: any) => - ` ${item.id}: ${item.title} [${item.status}]` - ) - .join("\n"); - } - - /** - * Format a single work item for display. - */ - private formatWorkItem(item: any): string { - const lines = [ - `**${item.id}: ${item.title}**`, - `Status: ${item.status || "unknown"}`, - `Priority: ${item.priority || "medium"}`, - `Type: ${item.issueType || "task"}`, - `Stage: ${item.stage || "unknown"}`, - `Assignee: ${item.assignee || "unassigned"}`, - ]; - if (item.description && item.description !== "null") { - // Strip markdown from description for cleaner display - const desc = item.description - .replace(/^Summary:\n/, "") - .replace(/^## Acceptance Criteria\n*/, "") - .replace(/^Minimal Implementation:\n*/, "") - .replace(/^Dependencies:\n*/, "") - .replace(/^Deliverables:\n*/, "") - .substring(0, 200); - lines.push(`\nDescription: ${desc}`); - } - return lines.join("\n"); - } - - /** - * Get the current message history. - */ - getMessages(): ChatMessage[] { - return [...this.state.messages]; - } - - /** - * Clear the conversation history. - */ - clear(): void { - this.state.messages = []; - this.state.isProcessing = false; - } - - /** - * Get the number of messages. - */ - getMessageCount(): number { - return this.state.messages.length; - } -} diff --git a/packages/tui/extensions/Worklog/config.ts b/packages/tui/extensions/Worklog/config.ts deleted file mode 100644 index 77b47f92a..000000000 --- a/packages/tui/extensions/Worklog/config.ts +++ /dev/null @@ -1,315 +0,0 @@ -/** - * config.ts — Hot-reloadable configuration manager for the Worklog extension. - * - * Provides a reactive configuration wrapper around the static settings - * loader (settings-config.ts). Supports lazy loading, runtime updates, - * change notification, and file watching for external config edits. - * - * Usage: - * const config = new WorklogConfig(); - * config.load(cwd); // load from disk (lazy: called by get()) - * const s = config.get(); // read-only snapshot - * config.update({ ... }); // merge, persist, notify - * const dispose = config.onChange(() => { ... }); // subscribe - * dispose(); // unsubscribe - * config.watchFile(path); // watch file for external changes - * config.dispose(); // release all watchers - */ - -import { watch } from 'node:fs'; -import { resolvePiDir } from '../../../../src/worklog-paths.js'; -import { loadSettings, persistSettings, validateNumber, validateBoolean, DEFAULT_SETTINGS, CONFIG_VERSION, type Settings } from './settings-config.js'; - -/** - * Reactive configuration manager with hot-reload support. - * - * - Lazy loading: config is loaded from disk on first access, not at - * construction time. - * - Runtime updates: update() merges partial settings, persists them, and - * notifies all onChange subscribers. - * - Change notification: subscribers are notified synchronously via onChange(). - * - File watching: watchFile() uses fs.watch to detect external edits. - */ -export class WorklogConfig { - private _config: Settings = { ...DEFAULT_SETTINGS }; - private _loaded = false; - private _watchers = new Set<() => void>(); - private _fsWatchers = new Set<ReturnType<typeof watch>>(); - private _projectDir = ''; - private _debounceTimer: ReturnType<typeof setTimeout> | null = null; - - /** - * Load configuration from disk for the given project directory. - * - * The project directory is resolved repo-root aware via resolvePiDir() - * (see src/worklog-paths.ts) and cached in `_projectDir` so loading, - * persistence and file watching all target the same settings file. - * Delegates to loadSettings() from settings-config.ts, which merges - * default, global, and project-level settings (project wins). - * Subsequent calls reload from disk and notify subscribers if values - * actually changed. - * - * @param cwd - Project working directory (defaults to process.cwd()) - */ - load(cwd?: string): void { - // Resolve the settings directory once: nearest .pi/settings.json wins, - // falling back to the git repo root (mirrors .worklog discovery). - this._projectDir = resolvePiDir(cwd ?? process.cwd()); - const newConfig = loadSettings(this._projectDir); - // Apply migrations to bring older config versions up to current - this._migrate(newConfig); - const changed = JSON.stringify(this._config) !== JSON.stringify(newConfig); - this._config = newConfig; - this._loaded = true; - if (changed) { - this._notifyWatchers(); - } - } - - /** - * Get the current configuration as a readonly snapshot. - * - * Loads from disk on first access (lazy loading) if load() has not been - * called explicitly. Returns a shallow copy so callers cannot mutate - * internal state. - */ - get(): Readonly<Settings> { - if (!this._loaded) { - this._config = loadSettings(this._projectDir || undefined); - this._loaded = true; - } - // Return a shallow copy to prevent mutation of internal state - return Object.freeze({ ...this._config }); - } - - /** - * Update configuration with partial values. - * - * Merges the provided partial settings into the current configuration, - * persists them to the project's .pi/settings.json under the context-hub - * namespace, and notifies all registered onChange subscribers. - * - * Each value is validated before being applied. Invalid values are - * replaced with defaults (graceful degradation, no throw). - * Unknown keys are silently ignored. - * - * @param partial - Partial settings to merge - */ - update(partial: Partial<Settings>): void { - if (!partial || typeof partial !== 'object') return; - if (!this._loaded) { - this._config = loadSettings(this._projectDir || undefined); - this._loaded = true; - } - - // Validate and apply each provided value - const validated: Partial<Settings> = {}; - - if (partial.browseItemCount !== undefined) { - validated.browseItemCount = validateNumber( - partial.browseItemCount, - DEFAULT_SETTINGS.browseItemCount, - 1, - 50, - ); - } - if (partial.showIcons !== undefined) { - validated.showIcons = validateBoolean(partial.showIcons, DEFAULT_SETTINGS.showIcons); - } - if (partial.showActivityIndicator !== undefined) { - validated.showActivityIndicator = validateBoolean( - partial.showActivityIndicator, - DEFAULT_SETTINGS.showActivityIndicator, - ); - } - if (partial.showHelpText !== undefined) { - validated.showHelpText = validateBoolean(partial.showHelpText, DEFAULT_SETTINGS.showHelpText); - } - if (partial.autoInjectEnabled !== undefined) { - validated.autoInjectEnabled = validateBoolean( - partial.autoInjectEnabled, - DEFAULT_SETTINGS.autoInjectEnabled, - ); - } - if (partial.guardrailsEnabled !== undefined) { - validated.guardrailsEnabled = validateBoolean( - partial.guardrailsEnabled, - DEFAULT_SETTINGS.guardrailsEnabled, - ); - } - if (partial.autoSyncIntervalSeconds !== undefined) { - validated.autoSyncIntervalSeconds = validateNumber( - partial.autoSyncIntervalSeconds, - DEFAULT_SETTINGS.autoSyncIntervalSeconds, - 0, - 300, - ); - } - if (partial.recovery !== undefined) { - // Recovery config is passed through as-is; individual category validation - // is handled by the recovery module at usage time. Invalid values in the - // recovery config degrade gracefully (fall back to defaults per category). - validated.recovery = partial.recovery; - } - if (partial.schedules !== undefined) { - // Schedules are passed through as-is; validation happens in the scheduler module - validated.schedules = partial.schedules; - } - - this._config = { ...this._config, ...validated }; - persistSettings(validated, this._projectDir || undefined); - this._notifyWatchers(); - } - - /** - * Register a callback that is invoked whenever the configuration changes. - * - * Callbacks are invoked synchronously after update() or when external - * file changes are detected via watchFile(). - * - * @param callback - Function to call on config change - * @returns A disposer function that unregisters the callback - */ - onChange(callback: () => void): () => void { - this._watchers.add(callback); - return () => { - this._watchers.delete(callback); - }; - } - - /** - * Watch a settings file for external changes using fs.watch. - * - * When the file changes, the config is reloaded from disk and onChange - * subscribers are notified. Changes are debounced by ~300ms to coalesce - * rapid writes (e.g., editor auto-save). - * - * @param path - Absolute path to the settings file to watch - */ - watchFile(path: string): void { - try { - const fsWatcher = watch(path, (eventType) => { - if (eventType === 'change' || eventType === 'rename') { - this._debouncedReload(); - } - }); - this._fsWatchers.add(fsWatcher); - } catch { - // File may not exist yet; silently ignore — watchFile can be called - // again when the file is known to exist. - } - } - - /** - * Release all resources: dispose all file watchers and clear subscribers. - * - * After calling dispose(), the config instance should not be reused. - */ - dispose(): void { - for (const w of this._fsWatchers) { - try { - w.close(); - } catch { - // Ignore close errors on stale watchers - } - } - this._fsWatchers.clear(); - this._watchers.clear(); - if (this._debounceTimer) { - clearTimeout(this._debounceTimer); - this._debounceTimer = null; - } - } - - /** - * Reload config from disk with debouncing. - */ - private _debouncedReload(): void { - if (this._debounceTimer) { - clearTimeout(this._debounceTimer); - } - this._debounceTimer = setTimeout(() => { - this._debounceTimer = null; - if (this._projectDir) { - const newConfig = loadSettings(this._projectDir); - this._migrate(newConfig); - const changed = JSON.stringify(this._config) !== JSON.stringify(newConfig); - if (changed) { - this._config = newConfig; - this._notifyWatchers(); - } - } - }, 300); - } - - // ── Migration support ───────────────────────────────────────────── - - /** - * Run config migration: transforms config from older versions to the - * current format. Mutates the config object in-place. - * - * Migrations are registered in the MIGRATORS map keyed by version. - * The config is stepped through each intermediate version until it - * reaches CONFIG_VERSION. - */ - private _migrate(config: Settings): void { - const version = config.version ?? 0; - if (version >= CONFIG_VERSION) return; // already current - - let currentVersion = version; - while (currentVersion < CONFIG_VERSION) { - const migrator = MIGRATORS[currentVersion]; - if (migrator) { - migrator(config); - console.log(`[WorklogConfig] Migrated config from v${currentVersion} to v${currentVersion + 1}`); - } - currentVersion++; - } - config.version = CONFIG_VERSION; - } - - /** - * Notify all registered onChange subscribers. - */ - private _notifyWatchers(): void { - for (const cb of this._watchers) { - try { - cb(); - } catch (err) { - console.error('[WorklogConfig] onChange callback error:', err); - } - } - } -} - -/** - * A migrator function that transforms a Settings object from one version - * to the next. Mutates the config in-place. - */ -type Migrator = (config: Settings) => void; - -/** - * Registry of migrators keyed by the source version. - * MIGRATORS[v] transforms config from version v → v+1. - * - * v0 → v1: Initial version, no-op (defaults are sufficient). - * For future schema changes, add new entries here. - */ -const MIGRATORS: Record<number, Migrator> = { - // v0 → v1: Placeholder for initial migration. No changes needed - // because the current schema matches the implicit v0 defaults. - 0: (_config: Settings) => { - // No-op: all fields have sensible defaults. - }, -}; - -/** - * Shared singleton instance of WorklogConfig used by the extension runtime. - * - * Extension components should subscribe to changes via: - * worklogConfig.onChange(() => { ... }); - * - * The /wl settings command handler should delegate to: - * worklogConfig.update({ ... }); - */ -export const worklogConfig = new WorklogConfig(); diff --git a/packages/tui/extensions/Worklog/index.ts b/packages/tui/extensions/Worklog/index.ts index 947814b69..18151c4af 100644 --- a/packages/tui/extensions/Worklog/index.ts +++ b/packages/tui/extensions/Worklog/index.ts @@ -1,194 +1,47 @@ /** - * Worklog browser extension — thin orchestration layer. + * Worklog Pi extension — thin orchestration layer. * - * Registers the /wl command, ctrl+shift+b shortcut, and session lifecycle - * hooks. All substantive logic is in lib/ modules. + * Registers the agent-side Pi plugin modules: activity indicator, model + * display, session health, guardrails, skill-path tool, error recovery + * (`/retry`), and lease release. The Pi-based TUI browse UI (chat pane, + * action palette, `/wl` command, `ctrl+shift+b` shortcut, scheduler, + * auto-injection, settings overlay) has been removed — the Herdr plugin + * covers work item browsing and management. * - * Moved from extensions/index.ts to extensions/Worklog/index.ts so that Pi - * derives the display label "Worklog" from the entry-point path. + * This extension auto-loads into every pi session via the global extension + * install (`~/.pi/agent/extensions/worklog` → `packages/tui/extensions`). */ -import { createRequire } from 'node:module'; -import { realpathSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'; -import type { ShortcutRegistry } from './shortcut-config.js'; -import { loadShortcutConfig } from './shortcut-config.js'; -import { registerActivityIndicator, showActivity, clearActivity } from './activity-indicator.js'; -import { worklogConfig } from './config.js'; -import { reloadSettings, currentSettings, STAGE_MAP, VALID_STAGES, updateSettings, openSettingsOverlay } from './lib/settings.js'; -import { runWl, defaultListWorkItems, defaultListWorkItemsWithStage, createDefaultListWorkItems, createListWorkItemsWithStage, createDefaultListWorkItemsDb, createListWorkItemsWithStageDb, fetchTotalActionableCountDb } from './lib/tools.js'; -import { registerAutoInject } from './lib/auto-inject.js'; +import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; +import { registerActivityIndicator } from './activity-indicator.js'; import { registerModelDisplay } from './model-display.js'; import { registerSessionHealth } from './session-health.js'; import { INSTALL_GUARDRAILS } from './lib/guardrails.js'; import { registerSkillPathTool } from './lib/skill-path.js'; import { registerRecoveryModule } from './lib/recovery/register-recovery.js'; import { registerLeaseRelease } from './lease-release.js'; -import { registerScheduler, scheduleCliCommand, setSchedulePersister } from './lib/scheduler.js'; -import { - type WorklogBrowseItem, - type WorklogBrowseDependencies, - type BrowseContext, - type ShortcutResult, - type SelectionChangeHandler, - type BrowseFlowOptions, - runBrowseFlow, - buildSelectionWidget, - formatBrowseOption, - createScrollableWidget, - getIconPrefix, -} from './lib/browse.js'; -// ── Backward-compatible re-exports ──────────────────────────────────── -export type { WorklogBrowseItem, SelectionChangeHandler }; - -export { - defaultChooseWorkItem, -} from './lib/browse.js'; - -export { - buildSelectionWidget, - getIconPrefix, - formatBrowseOption, - createScrollableWidget, - - updateSettings, - STAGE_MAP, -}; - -// Re-export list work item factories for tests and external consumers -export { - createDefaultListWorkItems, - createListWorkItemsWithStage, -} from './lib/tools.js'; - -// Icons — resolved via symlink-safe createRequire -const _require = createRequire(realpathSync(fileURLToPath(import.meta.url))); -const { priorityIcon, statusIcon, stageIcon, auditIcon, epicIcon, iconsEnabled, riskIcon, effortIcon } = _require('../../../../dist/icons.js'); - -export function createWorklogBrowseExtension(deps: WorklogBrowseDependencies = {}) { - const runWlImpl = deps.runWl ?? runWl; - // Use CLI-backed list operations (wl next) so grouping, sorting, and - // other CLI-side logic is always applied. - const listWorkItems = deps.listWorkItems ?? createDefaultListWorkItems(); - const listWorkItemsWithStage = deps.listWorkItemsWithStage ?? createListWorkItemsWithStage(); - const shortcutRegistry = deps.shortcutRegistry ?? loadShortcutConfig(); - const chooseWorkItem = deps.chooseWorkItem - ? (deps.chooseWorkItem as (items: WorklogBrowseItem[], ctx: BrowseContext, onSelectionChange: SelectionChangeHandler) => Promise<WorklogBrowseItem | ShortcutResult | undefined>) - : undefined; - - const browseOptions: BrowseFlowOptions = { - listWorkItems, - listWorkItemsWithStage, - runWlImpl, - shortcutRegistry, - chooseWorkItem, - // Phase 2: Pre-fetched actionable count from direct DB access. - // When undefined (DB unavailable), browse falls back to CLI-based count. - totalActionableCount: undefined, - }; - - return function registerWorklogBrowseExtension(pi: ExtensionAPI): void { - registerActivityIndicator(pi, () => currentSettings.showActivityIndicator); - registerAutoInject(pi); +export function createWorklogPiExtension() { + return function registerWorklogPiExtension(pi: ExtensionAPI): void { + // ── Footer chrome: activity indicator, model display, session health ── + registerActivityIndicator(pi, () => true); registerModelDisplay(pi); registerSessionHealth(pi); - INSTALL_GUARDRAILS(pi, { enabled: currentSettings.guardrailsEnabled }); - // ── Skill path discovery tool ───────────────────────────────── + // ── Guardrails: protect worklog database files ──────────────────── + INSTALL_GUARDRAILS(pi, { enabled: true }); + + // ── Skill path discovery tool ───────────────────────────────────── if (typeof pi.registerTool === 'function') { pi.registerTool(registerSkillPathTool()); } - // ── Recovery module (automatic error recovery) ──────────────── + // ── Recovery module (automatic error recovery, `/retry`) ────────── registerRecoveryModule(pi); - // ── Lease release (proactive proxy model lease release on /new) ── + // ── Lease release (proactive proxy model lease release on /new) ──── registerLeaseRelease(pi); - - // ── Periodic request scheduler ────────────────────────────────── - // Wire the schedule persister to the config system so changes are persisted - setSchedulePersister((schedules) => { - worklogConfig.update({ schedules }); - }); - // Register lifecycle hooks (start/stop on session_start/session_shutdown) - // The Scheduler tracks agent idle state internally via pi event listeners. - registerScheduler(pi); - - // Subscribe to config changes for hot-reload notifications - // When settings change via /wl settings or file edit, all onChange - // subscribers are notified immediately without requiring /reload. - worklogConfig.onChange(() => { - // currentSettings is already updated; components that read it - // dynamically (e.g., activity indicator getter) pick up changes. - // Future: re-install guardrails when guardrailsEnabled changes. - // Future: re-register recovery module when recovery settings change. - }); - - pi.registerCommand('wl', { - description: `Browse next ${currentSettings.browseItemCount} work items, optionally filtered by stage and settings`, - handler: async (_args: string, ctx: ExtensionCommandContext) => { - showActivity(ctx as any, '/wl', currentSettings.showActivityIndicator); - const trimmed = _args?.trim() ?? ''; - if (trimmed.length === 0) { - await runBrowseFlow(ctx as unknown as BrowseContext, browseOptions); - return; - } - if (trimmed === 'settings') { - await openSettingsOverlay(ctx as unknown as BrowseContext); - return; - } - if (trimmed.startsWith('schedule')) { - const subcommandArgs = trimmed.slice(8).trim(); // Remove 'schedule' prefix - const result = scheduleCliCommand(subcommandArgs); - ctx.ui.notify(result, 'info'); - return; - } - const canonical = STAGE_MAP[trimmed]; - if (canonical) { - await runBrowseFlow(ctx as unknown as BrowseContext, browseOptions, canonical); - return; - } - ctx.ui.notify(`Unknown stage value: '${trimmed}'`, 'error'); - await runBrowseFlow(ctx as unknown as BrowseContext, browseOptions); - }, - getArgumentCompletions: (prefix: string) => { - // Add 'schedule' to command completions when prefix matches - const allCompletions = ['settings', 'schedule', ...Object.keys(STAGE_MAP)].sort(); - const filtered = allCompletions.filter(s => s.startsWith(prefix)); - return filtered.length > 0 - ? filtered.map(s => ({ value: s, label: s })) - : null; - }, - }); - - pi.registerShortcut('ctrl+shift+b', { - description: `Browse next ${currentSettings.browseItemCount} recommended work items and preview selected title`, - handler: async (ctx: ExtensionCommandContext) => { - showActivity(ctx as any, '/wl', currentSettings.showActivityIndicator); - await runBrowseFlow(ctx as unknown as BrowseContext, browseOptions); - }, - }); - - // ── Session persistence ──────────────────────────────────────── - pi.on('session_start', async () => { - reloadSettings(); - }); - - pi.on('session_tree', async () => { - reloadSettings(); - }); - - // Auto-trigger browse flow on session_start when launched via `wl piman` - if (typeof process !== 'undefined' && process.env?.WL_PIMAN === '1') { - pi.on('session_start', (_event, ctx) => { - setTimeout(() => { - void runBrowseFlow(ctx as unknown as BrowseContext, browseOptions); - }, 500); - }); - } }; } -export default createWorklogBrowseExtension(); +export default createWorklogPiExtension(); diff --git a/packages/tui/extensions/Worklog/lib/auto-inject.test.ts b/packages/tui/extensions/Worklog/lib/auto-inject.test.ts deleted file mode 100644 index 800f75bb4..000000000 --- a/packages/tui/extensions/Worklog/lib/auto-inject.test.ts +++ /dev/null @@ -1,679 +0,0 @@ -/** - * Unit tests for lib/auto-inject.ts — Auto-injection of relevant work items - * before agent turns. - * - * Tests the extraction, search, formatting, and registration logic used to - * automatically inject related work-item context into the system prompt. - * - * Run: npx vitest run packages/tui/extensions/lib/auto-inject.test.ts - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// ── Mock @earendil-works/pi-coding-agent ────────────────────────────── - -vi.mock('@earendil-works/pi-coding-agent', () => ({ - getAgentDir: () => '/home/test-user/.pi/agent', -})); - -// ── Mock the wl-integration runWl ───────────────────────────────────── - -const mockRunWl = vi.fn(); -vi.mock('../../wl-integration.js', () => ({ - runWl: mockRunWl, -})); - -// ── Mock settings ───────────────────────────────────────────────────── - -vi.mock('./settings.js', () => ({ - currentSettings: { - autoInjectEnabled: true, - }, -})); - -// ── Helpers ──────────────────────────────────────────────────────────── - -/** - * Default mock fixture for a work item returned by `wl show`. - * Includes description so ID-scanning code has text to scan. - */ -function defaultShowResult(id = 'WL-0MQL0T5TR0060AEH', title = 'Test Work Item', description = ''): object { - return { id, title, status: 'open', priority: 'high', stage: 'in_progress', description }; -} - -/** - * Default mock fixture for empty `wl comment list` output. - */ -function emptyCommentResult(): object { - return { success: true, count: 0, workItemId: '', comments: [] }; -} - -/** - * Default mock fixture for empty `wl list --parent` output. - */ -function emptyChildrenResult(): object { - return { success: true, count: 0, workItems: [] }; -} - -/** - * Set up mockRunWl to handle the common ID-based mode calls. - * The mock intercepts: - * - 'show' → returns the primary work item - * - 'comment' with args ['list', ...] → returns comments - * - 'list' with args ['--parent', ...] → returns children - * - 'search' → returns search results (for fallback tests) - * - any other call → throws (test should fail if unexpected calls occur) - * - * @param showResult - What to return for `wl show <id>` - * @param commentResult - What to return for `wl comment list <id>` - * @param childrenResult - What to return for `wl list --parent <id>` - * @param searchResult - What to return for `wl search ...` - */ -function mockWlCalls( - showResult: any = defaultShowResult(), - commentResult: any = emptyCommentResult(), - childrenResult: any = emptyChildrenResult(), - searchResult: any = undefined, -): void { - mockRunWl.mockImplementation((command: string, args: string[]) => { - if (command === 'show') { - return Promise.resolve(showResult); - } - if (command === 'comment' && args[0] === 'list') { - return Promise.resolve(commentResult); - } - if (command === 'list' && args[0] === '--parent') { - return Promise.resolve(childrenResult); - } - if (command === 'search') { - if (searchResult !== undefined) { - return Promise.resolve(searchResult); - } - } - // Default: reject with error to catch unexpected calls - return Promise.reject(new Error(`Unexpected wl command: ${command} ${args?.join(' ')}`)); - }); -} - -// ── Extraction ──────────────────────────────────────────────────────── - -describe('extractWorkItemIds', () => { - it('should extract a single work item ID from text', async () => { - const { extractWorkItemIds } = await import('./auto-inject.js'); - const result = extractWorkItemIds('Implement WL-0MQL0T5TR0060AEH'); - expect(result).toEqual(['WL-0MQL0T5TR0060AEH']); - }); - - it('should extract multiple work item IDs from text', async () => { - const { extractWorkItemIds } = await import('./auto-inject.js'); - const result = extractWorkItemIds( - 'Fix WL-0MQL0T5TR0060AEH and refactor WL-0MP15X5HW001WXZR' - ); - expect(result).toEqual(['WL-0MQL0T5TR0060AEH', 'WL-0MP15X5HW001WXZR']); - }); - - it('should deduplicate repeated work item IDs', async () => { - const { extractWorkItemIds } = await import('./auto-inject.js'); - const result = extractWorkItemIds( - 'WL-0MQL0T5TR0060AEH is related to WL-0MQL0T5TR0060AEH' - ); - expect(result).toEqual(['WL-0MQL0T5TR0060AEH']); - }); - - it('should return an empty array when no IDs are found', async () => { - const { extractWorkItemIds } = await import('./auto-inject.js'); - const result = extractWorkItemIds('No work item IDs in this text'); - expect(result).toEqual([]); - }); - - it('should handle empty string input', async () => { - const { extractWorkItemIds } = await import('./auto-inject.js'); - const result = extractWorkItemIds(''); - expect(result).toEqual([]); - }); - - it('should ignore short alphanumeric codes that are not work item IDs', async () => { - const { extractWorkItemIds } = await import('./auto-inject.js'); - const result = extractWorkItemIds('Use ABC-123 for reference'); - expect(result).toEqual([]); - }); - - it('should match IDs with different prefixes', async () => { - const { extractWorkItemIds } = await import('./auto-inject.js'); - const result = extractWorkItemIds('SA-0MPYMFZXO0004ZU4 and TASK-0ABCDEF12345678'); - // TASK- has 4 letters, prefix must be 2-3 uppercase letters - expect(result).toEqual(['SA-0MPYMFZXO0004ZU4']); - }); -}); - -// ── ID-based mode (new behavior) ────────────────────────────────────── - -describe('searchRelatedWorkItems — ID-based mode', () => { - beforeEach(() => { - mockRunWl.mockReset(); - }); - - it('should fetch explicitly referenced IDs via wl show', async () => { - const { searchRelatedWorkItems } = await import('./auto-inject.js'); - - mockWlCalls(defaultShowResult('WL-0MQL0T5TR0060AEH', 'Test Work Item')); - - const results = await searchRelatedWorkItems( - 'Work on WL-0MQL0T5TR0060AEH', - ['WL-0MQL0T5TR0060AEH'], - ); - expect(results).toHaveLength(1); - expect(results[0].id).toBe('WL-0MQL0T5TR0060AEH'); - expect(results[0].title).toBe('Test Work Item'); - }); - - it('should scan description for embedded related work item IDs', async () => { - const { searchRelatedWorkItems } = await import('./auto-inject.js'); - - // The primary work item description mentions a related item - const desc = 'See WL-0MP15X5HW001WXZR for more details'; - mockRunWl.mockImplementation((command: string, args: string[]) => { - if (command === 'show') { - if (args[0] === 'WL-0MP15X5HW001WXZR') { - return Promise.resolve(defaultShowResult('WL-0MP15X5HW001WXZR', 'Related Item')); - } - return Promise.resolve(defaultShowResult('WL-0MQL0T5TR0060AEH', 'Test Work Item', desc)); - } - if (command === 'comment' && args[0] === 'list') { - return Promise.resolve(emptyCommentResult()); - } - if (command === 'list' && args[0] === '--parent') { - return Promise.resolve(emptyChildrenResult()); - } - return Promise.reject(new Error(`Unexpected: ${command} ${args?.join(' ')}`)); - }); - - const results = await searchRelatedWorkItems( - 'Work on WL-0MQL0T5TR0060AEH', - ['WL-0MQL0T5TR0060AEH'], - ); - // Should have: primary ID + related ID from description - expect(results).toHaveLength(2); - const ids = results.map(r => r.id).sort(); - // WL-0MP... (P=80) sorts before WL-0MQ... (Q=81) - expect(ids).toEqual(['WL-0MP15X5HW001WXZR', 'WL-0MQL0T5TR0060AEH']); - }); - - it('should scan comments for embedded related work item IDs', async () => { - const { searchRelatedWorkItems } = await import('./auto-inject.js'); - - // Comments contain a reference to another work item - const commentPayload = { - success: true, - count: 1, - workItemId: 'WL-0MQL0T5TR0060AEH', - comments: [ - { - id: 'WL-C1', - workItemId: 'WL-0MQL0T5TR0060AEH', - author: 'test', - comment: 'See also WL-0MP15X5HW001WXZR for more context', - createdAt: '2026-01-01T00:00:00.000Z', - references: [], - }, - ], - }; - - mockRunWl.mockImplementation((command: string, args: string[]) => { - if (command === 'show') { - // When fetching the related ID found in comments, return it with proper ID - if (args[0] === 'WL-0MP15X5HW001WXZR') { - return Promise.resolve(defaultShowResult('WL-0MP15X5HW001WXZR', 'Related Item')); - } - return Promise.resolve(defaultShowResult('WL-0MQL0T5TR0060AEH', 'Test Work Item')); - } - if (command === 'comment' && args[0] === 'list') { - return Promise.resolve(commentPayload); - } - if (command === 'list' && args[0] === '--parent') { - return Promise.resolve(emptyChildrenResult()); - } - return Promise.reject(new Error(`Unexpected: ${command} ${args?.join(' ')}`)); - }); - - const results = await searchRelatedWorkItems( - 'Work on WL-0MQL0T5TR0060AEH', - ['WL-0MQL0T5TR0060AEH'], - ); - expect(results).toHaveLength(2); - const ids = results.map(r => r.id).sort(); - // WL-0MP... (P=80) sorts before WL-0MQ... (Q=81) - expect(ids).toEqual(['WL-0MP15X5HW001WXZR', 'WL-0MQL0T5TR0060AEH']); - }); - - it('should include child items as related items', async () => { - const { searchRelatedWorkItems } = await import('./auto-inject.js'); - - // Children returned by wl list --parent - const childrenPayload = { - success: true, - count: 2, - workItems: [ - { id: 'WL-CHILD1', title: 'Child One', status: 'open', priority: 'medium', stage: 'in_progress' }, - { id: 'WL-CHILD2', title: 'Child Two', status: 'in-progress', priority: 'low', stage: 'plan_complete' }, - ], - }; - - mockRunWl.mockImplementation((command: string, args: string[]) => { - if (command === 'show') { - return Promise.resolve(defaultShowResult()); - } - if (command === 'comment' && args[0] === 'list') { - return Promise.resolve(emptyCommentResult()); - } - if (command === 'list' && args[0] === '--parent') { - return Promise.resolve(childrenPayload); - } - return Promise.reject(new Error(`Unexpected: ${command} ${args?.join(' ')}`)); - }); - - const results = await searchRelatedWorkItems( - 'Work on WL-0MQL0T5TR0060AEH', - ['WL-0MQL0T5TR0060AEH'], - ); - // Should have: primary + two children - expect(results).toHaveLength(3); - const ids = results.map(r => r.id).sort(); - expect(ids).toEqual(['WL-0MQL0T5TR0060AEH', 'WL-CHILD1', 'WL-CHILD2']); - // Child items should have their titles preserved - const child1 = results.find(r => r.id === 'WL-CHILD1'); - expect(child1?.title).toBe('Child One'); - const child2 = results.find(r => r.id === 'WL-CHILD2'); - expect(child2?.title).toBe('Child Two'); - }); - - it('should deduplicate discovered IDs excluding the primary ID', async () => { - const { searchRelatedWorkItems } = await import('./auto-inject.js'); - - // Description mentions only the primary ID (no related IDs), comments contain a related - const desc = 'Main task WL-0MQL0T5TR0060AEH'; // only primary, no related - const commentPayload = { - success: true, - count: 1, - workItemId: 'WL-0MQL0T5TR0060AEH', - comments: [ - { - id: 'WL-C1', - workItemId: 'WL-0MQL0T5TR0060AEH', - author: 'test', - comment: 'Related WL-0MP15X5HW001WXZR', - createdAt: '2026-01-01T00:00:00.000Z', - references: [], - }, - ], - }; - - mockRunWl.mockImplementation((command: string, args: string[]) => { - if (command === 'show') { - if (args[0] === 'WL-0MP15X5HW001WXZR') { - return Promise.resolve(defaultShowResult('WL-0MP15X5HW001WXZR', 'Related Item')); - } - return Promise.resolve(defaultShowResult('WL-0MQL0T5TR0060AEH', 'Test Work Item', desc)); - } - if (command === 'comment' && args[0] === 'list') { - return Promise.resolve(commentPayload); - } - if (command === 'list' && args[0] === '--parent') { - return Promise.resolve(emptyChildrenResult()); - } - return Promise.reject(new Error(`Unexpected: ${command} ${args?.join(' ')}`)); - }); - - const results = await searchRelatedWorkItems( - 'WL-0MQL0T5TR0060AEH', - ['WL-0MQL0T5TR0060AEH'], - ); - // Should have: primary + related (not duplicated, primary not re-discovered) - expect(results).toHaveLength(2); - const ids = results.map(r => r.id).sort(); - // WL-0MP... (P=80) sorts before WL-0MQ... (Q=81) - expect(ids).toEqual(['WL-0MP15X5HW001WXZR', 'WL-0MQL0T5TR0060AEH']); - }); - - it('should skip wl search when work item IDs are present', async () => { - const { searchRelatedWorkItems } = await import('./auto-inject.js'); - - // Search call should NOT be made — mock rejects if called - mockRunWl.mockImplementation((command: string, args: string[]) => { - if (command === 'show') { - return Promise.resolve(defaultShowResult()); - } - if (command === 'comment' && args[0] === 'list') { - return Promise.resolve(emptyCommentResult()); - } - if (command === 'list' && args[0] === '--parent') { - return Promise.resolve(emptyChildrenResult()); - } - if (command === 'search') { - return Promise.reject(new Error('Search should not be called in ID-based mode')); - } - return Promise.reject(new Error(`Unexpected: ${command} ${args?.join(' ')}`)); - }); - - const results = await searchRelatedWorkItems( - 'Work on WL-0MQL0T5TR0060AEH', - ['WL-0MQL0T5TR0060AEH'], - ); - expect(results).toHaveLength(1); - expect(results[0].id).toBe('WL-0MQL0T5TR0060AEH'); - }); - - it('should handle failed ID lookups gracefully', async () => { - const { searchRelatedWorkItems } = await import('./auto-inject.js'); - - // Show throws (modeled by mock rejecting) - mockRunWl.mockImplementation((command: string) => { - if (command === 'show') { - return Promise.reject(new Error('Not found')); - } - if (command === 'comment') { - return Promise.resolve(emptyCommentResult()); - } - if (command === 'list') { - return Promise.resolve(emptyChildrenResult()); - } - return Promise.reject(new Error(`Unexpected: ${command}`)); - }); - - const results = await searchRelatedWorkItems('WL-0BADID0000000000', ['WL-0BADID0000000000']); - expect(results).toEqual([]); - }); - - it('should handle comment and child scanning errors gracefully', async () => { - const { searchRelatedWorkItems } = await import('./auto-inject.js'); - - mockRunWl.mockImplementation((command: string, args: string[]) => { - if (command === 'show') { - return Promise.resolve(defaultShowResult()); - } - if (command === 'comment') { - return Promise.reject(new Error('Comment listing failed')); - } - if (command === 'list') { - return Promise.reject(new Error('Child listing failed')); - } - return Promise.reject(new Error(`Unexpected: ${command} ${args?.join(' ')}`)); - }); - - // Even if comment/child scanning fails, the primary ID should still be returned - const results = await searchRelatedWorkItems( - 'Work on WL-0MQL0T5TR0060AEH', - ['WL-0MQL0T5TR0060AEH'], - ); - expect(results).toHaveLength(1); - expect(results[0].id).toBe('WL-0MQL0T5TR0060AEH'); - }); - - it('should skip search when prompt only contains IDs', async () => { - const { searchRelatedWorkItems } = await import('./auto-inject.js'); - - mockWlCalls(defaultShowResult()); - - const results = await searchRelatedWorkItems( - 'WL-0MQL0T5TR0060AEH', - ['WL-0MQL0T5TR0060AEH'], - ); - expect(results).toHaveLength(1); - expect(results[0].id).toBe('WL-0MQL0T5TR0060AEH'); - }); -}); - -// ── Search-based mode (fallback) ────────────────────────────────────── - -describe('searchRelatedWorkItems — Search-based mode (fallback)', () => { - beforeEach(() => { - mockRunWl.mockReset(); - }); - - it('should search by prompt context when no IDs are found', async () => { - const { searchRelatedWorkItems } = await import('./auto-inject.js'); - - mockRunWl.mockImplementation((command: string, args: string[]) => { - if (command === 'search') { - return Promise.resolve({ - results: [ - { - id: 'WL-0MP15X5HW001WXZR', - title: 'Found Item', - status: 'open', - priority: 'medium', - }, - ], - }); - } - return Promise.reject(new Error(`Unexpected: ${command} ${args?.join(' ')}`)); - }); - - const results = await searchRelatedWorkItems('implementation task', []); - expect(results).toHaveLength(1); - expect(results[0].id).toBe('WL-0MP15X5HW001WXZR'); - expect(results[0].title).toBe('Found Item'); - }); - - it('should handle search errors gracefully', async () => { - const { searchRelatedWorkItems } = await import('./auto-inject.js'); - - mockRunWl.mockImplementation((command: string) => { - if (command === 'search') { - return Promise.reject(new Error('Search failed')); - } - return Promise.resolve({ results: [] }); - }); - - const results = await searchRelatedWorkItems('some search text', []); - expect(results).toEqual([]); - }); - - it('should skip search when prompt is too short', async () => { - const { searchRelatedWorkItems } = await import('./auto-inject.js'); - - mockRunWl.mockImplementation(() => { - return Promise.reject(new Error('Should not be called')); - }); - - const results = await searchRelatedWorkItems('ab', []); - expect(results).toEqual([]); - }); -}); - -// ── Formatting ──────────────────────────────────────────────────────── - -describe('formatWorkItemContext', () => { - it('should format items in full-detail mode when under threshold', async () => { - const { formatWorkItemContext } = await import('./auto-inject.js'); - const items = [ - { id: 'WL-1', title: 'First Item', status: 'open', priority: 'high', stage: 'in_progress' }, - { id: 'WL-2', title: 'Second Item', status: 'in-progress', priority: 'medium' }, - ]; - const result = formatWorkItemContext(items); - expect(result).toContain('## Relevant Work Items'); - expect(result).toContain('WL-1'); - expect(result).toContain('First Item'); - expect(result).toContain('WL-2'); - expect(result).toContain('Second Item'); - expect(result).toContain('`high`'); - expect(result).toContain('`open`'); - }); - - it('should include status tags in full-detail mode', async () => { - const { formatWorkItemContext } = await import('./auto-inject.js'); - const items = [ - { id: 'WL-1', title: 'Test', status: 'open', priority: 'high', stage: 'in_progress' }, - ]; - const result = formatWorkItemContext(items); - expect(result).toContain('`high`'); - expect(result).toContain('`open`'); - expect(result).toContain('`in_progress`'); - }); - - it('should handle items without priority or stage gracefully', async () => { - const { formatWorkItemContext } = await import('./auto-inject.js'); - const items = [ - { id: 'WL-1', title: 'Minimal', status: 'open' }, - ]; - const result = formatWorkItemContext(items); - expect(result).toContain('WL-1'); - expect(result).toContain('Minimal'); - expect(result).toContain('`open`'); - }); - - it('should return empty string for empty items array', async () => { - const { formatWorkItemContext } = await import('./auto-inject.js'); - const result = formatWorkItemContext([]); - expect(result).toBe(''); - }); -}); - -// ── Registration ────────────────────────────────────────────────────── - -describe('registerAutoInject', () => { - beforeEach(() => { - mockRunWl.mockReset(); - }); - - it('should register a before_agent_start handler', async () => { - const { registerAutoInject } = await import('./auto-inject.js'); - const onMock = vi.fn(); - - registerAutoInject({ on: onMock } as any); - - expect(onMock).toHaveBeenCalledWith('before_agent_start', expect.any(Function)); - }); - - it('should skip injection when auto-inject is disabled', async () => { - // Temporarily switch auto-inject to disabled - const { currentSettings } = await import('./settings.js'); - const original = currentSettings.autoInjectEnabled; - currentSettings.autoInjectEnabled = false; - - const { registerAutoInject } = await import('./auto-inject.js'); - const handler = vi.fn(); - const onMock = vi.fn((_event: string, fn: any) => { handler.mockImplementation(fn); }); - - registerAutoInject({ on: onMock } as any); - - // Call the registered handler - const event = { prompt: 'test', systemPrompt: 'system prompt' }; - const ctx = { ui: { setStatus: vi.fn() } }; - await handler(event, ctx); - - // Should not have called runWl (no search performed) - expect(mockRunWl).not.toHaveBeenCalled(); - - // Restore - currentSettings.autoInjectEnabled = original; - }); - - it('should inject context when related items are found via search', async () => { - const { registerAutoInject } = await import('./auto-inject.js'); - - mockRunWl.mockImplementation((command: string, args: string[]) => { - if (command === 'search') { - return Promise.resolve({ - results: [ - { id: 'WL-RELATED1', title: 'Related Task', status: 'open', priority: 'high' }, - ], - }); - } - return Promise.resolve({}); - }); - - const onMock = vi.fn(); - const setStatusMock = vi.fn(); - let registeredHandler: Function = async () => {}; - - registerAutoInject({ - on: (_event: string, fn: any) => { registeredHandler = fn; }, - } as any); - - const event = { - prompt: 'working on implementation task', - systemPrompt: 'You are an AI assistant.', - }; - const ctx = { ui: { setStatus: setStatusMock } }; - - const result = await registeredHandler(event, ctx); - - expect(result).toBeDefined(); - expect(result!.systemPrompt).toContain('## Relevant Work Items'); - expect(result!.systemPrompt).toContain('WL-RELATED1'); - expect(result!.systemPrompt).toContain('Related Task'); - expect(result!.systemPrompt).toContain(event.systemPrompt); // Original system prompt preserved - expect(setStatusMock).toHaveBeenCalled(); - }); - - it('should inject context when related items are found via ID scanning', async () => { - const { registerAutoInject } = await import('./auto-inject.js'); - - // Prompt has a work item ID, so ID-based mode is used - // The description references a related item (must be 15+ chars to match regex) - const desc = 'See WL-0MP15X5HW001WXZR for more details'; - mockRunWl.mockImplementation((command: string, args: string[]) => { - if (command === 'show') { - if (args[0] === 'WL-0MP15X5HW001WXZR') { - return Promise.resolve({ id: 'WL-0MP15X5HW001WXZR', title: 'Related Task', status: 'open', priority: 'high' }); - } - return Promise.resolve(defaultShowResult('WL-0MQL0T5TR0060AEH', 'Primary Task', desc)); - } - if (command === 'comment' && args[0] === 'list') { - return Promise.resolve(emptyCommentResult()); - } - if (command === 'list' && args[0] === '--parent') { - return Promise.resolve(emptyChildrenResult()); - } - return Promise.resolve({ results: [] }); - }); - - const onMock = vi.fn(); - const setStatusMock = vi.fn(); - let registeredHandler: Function = async () => {}; - - registerAutoInject({ - on: (_event: string, fn: any) => { registeredHandler = fn; }, - } as any); - - const event = { - prompt: 'working on WL-0MQL0T5TR0060AEH', - systemPrompt: 'You are an AI assistant.', - }; - const ctx = { ui: { setStatus: setStatusMock } }; - - const result = await registeredHandler(event, ctx); - - expect(result).toBeDefined(); - expect(result!.systemPrompt).toContain('## Relevant Work Items'); - expect(result!.systemPrompt).toContain('WL-0MQL0T5TR0060AEH'); - expect(result!.systemPrompt).toContain('WL-0MP15X5HW001WXZR'); - expect(result!.systemPrompt).toContain('Primary Task'); - expect(result!.systemPrompt).toContain(event.systemPrompt); - expect(setStatusMock).toHaveBeenCalled(); - }); - - it('should not inject context when no items are found', async () => { - const { registerAutoInject } = await import('./auto-inject.js'); - - mockRunWl.mockImplementation(() => Promise.resolve({ results: [] })); - - const onMock = vi.fn(); - let registeredHandler: Function = async () => {}; - - registerAutoInject({ - on: (_event: string, fn: any) => { registeredHandler = fn; }, - } as any); - - const event = { - prompt: 'random text with no matches', - systemPrompt: 'You are an AI assistant.', - }; - const ctx = { ui: { setStatus: vi.fn() } }; - - const result = await registeredHandler(event, ctx); - - expect(result).toBeUndefined(); - }); -}); diff --git a/packages/tui/extensions/Worklog/lib/auto-inject.ts b/packages/tui/extensions/Worklog/lib/auto-inject.ts deleted file mode 100644 index f45683239..000000000 --- a/packages/tui/extensions/Worklog/lib/auto-inject.ts +++ /dev/null @@ -1,373 +0,0 @@ -/** - * lib/auto-inject.ts — Auto-injection of relevant work items before agent turns. - * - * Registers a `before_agent_start` handler that: - * 1. Extracts work item IDs from the user's prompt - * 2. Scans the referenced work item's description, comments, and children for - * related work item IDs (when IDs are detected), OR searches by prompt - * context keywords via `wl search` (fallback when no IDs are detected) - * 3. Formats matching items as context - * 4. Injects the formatted context into the system prompt - * 5. Sets a status bar indicator when items are injected - * - * Configuration: - * - `autoInjectEnabled` (boolean): Master enable/disable toggle (default: true) - * Set via the `context-hub` settings namespace in `.pi/settings.json`. - * - * Features: - * - **ID Detection**: Auto-detect work item IDs in prompts (e.g., WL-0MQL0T5TR0060AEH) - * - **Related-Item Scanning**: Scan the referenced work item's description, - * comments, and children for embedded work item IDs (when an ID is present) - * - **Keyword Search Fallback**: Find related items by prompt keywords when no - * work item ID is detected - * - **Smart Injection**: Only inject when relevant items are found - * - **Full-Detail Mode**: Shows ID, title, status, priority, and stage (up to `MAX_FULL_DETAIL` items) - * - **Links-Only Mode**: Compact ID + title list for larger result sets - * - **Configurable**: Enable/disable via settings - * - **Status Indicator**: Brief status bar notification showing injection count - */ - -import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; -import { runWl } from '../../wl-integration.js'; -import { currentSettings } from './settings.js'; - -// ── Constants ───────────────────────────────────────────────────────── - -/** - * Max items in full-detail mode. Above this, links-only mode is used. - */ -const MAX_FULL_DETAIL = 3; - -/** - * Max results returned by the `wl search` call. - */ -const MAX_SEARCH_RESULTS = 5; - -/** - * Regex to detect work item ID patterns in user input. - * - * Matches patterns like `WL-0MQL0T5TR0060AEH` (prefix + dash + 15+ alphanumeric chars). - * The prefix must be 2-3 uppercase letters followed by a dash and at least 15 - * alphanumeric characters. This is intentionally conservative to avoid false - * positives on ordinary text while matching all known work item ID formats. - */ -const WORK_ITEM_ID_REGEX = /\b[A-Z]{2,3}-[A-Z0-9]{15,}/g; - -/** - * Status key used for the auto-injection indicator in the footer. - * Passed to `ctx.ui.setStatus()` to set and clear the indicator. - */ -const AUTO_INJECT_STATUS_KEY = 'worklog-auto-inject'; - -// ── Extraction ──────────────────────────────────────────────────────── - -/** - * Extract all unique work item IDs from the given text. - * - * Scans the text for patterns matching work item IDs (e.g., WL-0MQL0T5TR0060AEH) - * and returns all unique matches in order of first appearance. - * - * @param text - The text to scan for work item IDs - * @returns An array of unique work item IDs, or an empty array if none found - * - * @example - * extractWorkItemIds('Implement WL-0MQL0T5TR0060AEH') // => ['WL-0MQL0T5TR0060AEH'] - * extractWorkItemIds('Fix WL-A and WL-B') // => ['WL-A', 'WL-B'] - * extractWorkItemIds('No IDs here') // => [] - */ -export function extractWorkItemIds(text: string): string[] { - const matches = text.match(WORK_ITEM_ID_REGEX); - if (!matches || matches.length === 0) return []; - // Deduplicate while preserving order of first appearance - return [...new Set(matches)]; -} - -// ── Types ──────────────────────────────────────────────────────────── - -/** - * A simplified work item shape used for context injection. - */ -export interface WorkItemSummary { - id: string; - title: string; - status: string; - priority?: string; - stage?: string; -} - -/** - * Normalize a raw wl CLI result (from `wl show` or `wl search`) into a - * WorkItemSummary. - */ -function normalizeWorkItem(raw: unknown): WorkItemSummary | null { - if (!raw || typeof raw !== 'object') return null; - const obj = raw as Record<string, unknown>; - const id = obj.id ? String(obj.id) : ''; - if (!id) return null; - return { - id, - title: obj.title ? String(obj.title) : 'Untitled', - status: obj.status ? String(obj.status) : 'unknown', - priority: obj.priority ? String(obj.priority) : undefined, - stage: obj.stage ? String(obj.stage) : undefined, - }; -} - -// ── Search ──────────────────────────────────────────────────────────── - -/** - * Interface for the wl runner function, allowing injection for testability. - */ -export interface WlRunner { - (command: string, args?: string[]): Promise<unknown>; -} - -/** - * Search for related work items based on the prompt context. - * - * When work item IDs are detected in the prompt (ID-based mode): - * 1. Fetches explicitly referenced IDs via `wl show` - * 2. Scans the primary work item's description, comments, and children for - * embedded work item IDs - * 3. Fetches discovered related IDs via `wl show` - * 4. Deduplicates and returns all results - * - * When no work item IDs are detected (search-based mode / fallback): - * 1. Strips any ID-like patterns from the prompt - * 2. Searches by remaining text via `wl search --limit <n>` - * - * @param prompt - The user's prompt text - * @param existingIds - Work item IDs already extracted from the prompt - * @param runWlFn - The wl runner function (injected for testability; defaults to - * the real runWl from wl-integration) - * @returns A deduplicated array of matching work items - */ -export async function searchRelatedWorkItems( - prompt: string, - existingIds: string[], - runWlFn: WlRunner = runWl as unknown as WlRunner, -): Promise<WorkItemSummary[]> { - const results: Map<string, WorkItemSummary> = new Map(); - - if (existingIds.length > 0) { - // ── ID-based mode ────────────────────────────────────────────── - // Fetch the referenced work item(s) and scan the primary work item's - // description, comments, and children for embedded related item IDs. - - const primaryId = existingIds[0]; - let primaryPayload: unknown = null; - - // 1. Fetch explicitly referenced IDs via wl show - for (const id of existingIds) { - try { - const payload = await runWlFn('show', [id]); - if (id === primaryId) primaryPayload = payload; - if (payload && typeof payload === 'object') { - const item = normalizeWorkItem(payload); - if (item) { - results.set(item.id, item); - } - } - } catch { - // Silently skip invalid/missing IDs — the ID may be stale or from - // a different session. No error is surfaced to the user. - } - } - - // 2. Scan the primary work item for embedded related IDs - const discoveredIds: string[] = []; - - // a. Scan description for embedded work item IDs - if (primaryPayload && typeof primaryPayload === 'object') { - const desc = (primaryPayload as Record<string, unknown>).description; - if (typeof desc === 'string') { - discoveredIds.push(...extractWorkItemIds(desc)); - } - } - - // b. Scan comments for embedded work item IDs - try { - const commentPayload = await runWlFn('comment', ['list', primaryId]); - if (commentPayload && typeof commentPayload === 'object') { - const payloadObj = commentPayload as Record<string, unknown>; - const comments = Array.isArray(payloadObj.comments) ? payloadObj.comments : []; - for (const comment of comments) { - if (comment && typeof comment === 'object') { - const text = (comment as Record<string, unknown>).comment; - if (typeof text === 'string') { - discoveredIds.push(...extractWorkItemIds(text)); - } - } - } - } - } catch { - // Silently skip comment scanning errors — the wl CLI may not support - // comment listing or the item may have no comments. - } - - // c. Scan children — fetch via wl list --parent - try { - const childrenPayload = await runWlFn('list', ['--parent', primaryId]); - if (childrenPayload && typeof childrenPayload === 'object') { - const payloadObj = childrenPayload as Record<string, unknown>; - const workItems = Array.isArray(payloadObj.workItems) ? payloadObj.workItems : []; - for (const child of workItems) { - if (child && typeof child === 'object') { - const childId = (child as Record<string, unknown>).id; - if (typeof childId === 'string') { - discoveredIds.push(childId); - // Add child directly from the list response (saves a wl show call) - const item = normalizeWorkItem(child); - if (item && !results.has(item.id)) { - results.set(item.id, item); - } - } - } - } - } - } catch { - // Silently skip child-item fetching errors. - } - - // 3. Fetch discovered related IDs (deduplicated, excluding the primary ID) - const uniqueDiscovered = [...new Set(discoveredIds)] - .filter(id => id !== primaryId); - for (const relatedId of uniqueDiscovered) { - if (results.has(relatedId)) continue; // Already fetched (e.g., child items) - try { - const payload = await runWlFn('show', [relatedId]); - if (payload && typeof payload === 'object') { - const item = normalizeWorkItem(payload); - if (item) { - results.set(item.id, item); - } - } - } catch { - // Silently skip invalid/missing IDs - } - } - } else { - // ── Search-based mode (fallback) ────────────────────────────── - // Strip out any work item IDs so we search by the actual semantic content. - const cleanedPrompt = prompt.replace(/\b[A-Z]{2,3}-[A-Z0-9]{15,}/g, '').trim(); - if (cleanedPrompt.length >= 3) { - try { - const payload = await runWlFn('search', [cleanedPrompt, '--limit', String(MAX_SEARCH_RESULTS)]); - if (payload && typeof payload === 'object') { - const payloadObj = payload as Record<string, unknown>; - const searchResults = Array.isArray(payloadObj.results) ? payloadObj.results : []; - for (const entry of searchResults) { - const item = normalizeWorkItem(entry); - if (item && !results.has(item.id)) { - results.set(item.id, item); - } - } - } - } catch { - // Silently skip search errors — the wl CLI may not be available or - // the search index may not be built. Graceful degradation. - } - } - } - - return [...results.values()]; -} - -// ── Formatting ──────────────────────────────────────────────────────── - -/** - * Format a list of work items as a markdown context block for system prompt - * injection. - * - * In **full-detail mode** (up to `MAX_FULL_DETAIL` items), shows each item - * with ID, title, status, priority, and stage as inline tags: - * - * ```markdown - * ## Relevant Work Items - * - * - **WL-123**: Fix login bug `high` `open` `in_progress` - * - **WL-456**: Add tests `medium` `in_review` - * ``` - * - * For larger result sets, a compact **links-only** list is used: - * - * ```markdown - * ## Relevant Work Items - * - * - WL-123: Fix login bug - * - WL-456: Add tests - * ``` - * - * @param items - The work items to format (may be empty) - * @returns A formatted markdown string, or an empty string if no items - */ -export function formatWorkItemContext(items: WorkItemSummary[]): string { - if (items.length === 0) return ''; - - const header = '## Relevant Work Items\n'; - - if (items.length <= MAX_FULL_DETAIL) { - // Full-detail mode: show ID, title, and inline tags - const details = items - .map((item) => { - const tags = [item.priority, item.status, item.stage] - .filter((t): t is string => Boolean(t)) - .map((t) => `\`${t}\``) - .join(' '); - return `- **${item.id}**: ${item.title} ${tags}`; - }) - .join('\n'); - return `${header}\n${details}\n`; - } - - // Links-only mode: compact ID + title list - const links = items.map((item) => `- ${item.id}: ${item.title}`).join('\n'); - return `${header}\n${links}\n`; -} - -// ── Registration ────────────────────────────────────────────────────── - -/** - * Register the auto-injection handler with a Pi extension instance. - * - * Sets up a `before_agent_start` handler that: - * 1. Checks if auto-injection is enabled in settings - * 2. Extracts work item IDs from the prompt text - * 3. Searches for related work items (by ID-based scanning or keyword fallback) - * 4. Formats matching items as markdown context - * 5. Appends the context to the system prompt - * 6. Sets a status bar indicator showing how many items were injected - * - * When auto-injection is disabled via settings, the handler is a no-op. - * When no related items are found, the handler returns without modifying - * the system prompt. - * - * @param pi - The ExtensionAPI instance - */ -export function registerAutoInject(pi: ExtensionAPI): void { - pi.on('before_agent_start', async (event, ctx) => { - // Check if auto-injection is enabled in settings - if (!currentSettings.autoInjectEnabled) return; - - const prompt = event.prompt || ''; - - // Extract work item IDs from the prompt text - const workItemIds = extractWorkItemIds(prompt); - - // Search for related work items by ID lookup and context search - const relatedItems = await searchRelatedWorkItems(prompt, workItemIds); - - // Only inject if we found relevant items - if (relatedItems.length > 0) { - const context = formatWorkItemContext(relatedItems); - const updatedSystemPrompt = event.systemPrompt + '\n\n' + context; - - // Set a status bar indicator showing injection count - const count = relatedItems.length; - const noun = count === 1 ? 'item' : 'items'; - ctx.ui.setStatus(AUTO_INJECT_STATUS_KEY, `📋 ${count} ${noun} auto-injected`); - - return { systemPrompt: updatedSystemPrompt }; - } - }); -} diff --git a/packages/tui/extensions/Worklog/lib/browse-idle.test.ts b/packages/tui/extensions/Worklog/lib/browse-idle.test.ts deleted file mode 100644 index ccdfdbbc8..000000000 --- a/packages/tui/extensions/Worklog/lib/browse-idle.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -/** - * Unit tests for lib/browse.ts — idle-gated auto-refresh/auto-sync in the - * browse selection widget (WL-0MSB445BP0057ZQT). - * - * Verifies that the 5s auto-refresh interval (and its `wl sync --if-idle` - * trigger) pauses when the widget has had no keypresses for IDLE_PAUSE_MS, - * resumes immediately on the next keypress, and that mount counts as - * interaction (freshly opened widget starts active). Manual actions and the - * detail-view widget are unaffected by idle state. - * - * Run: npx vitest run packages/tui/extensions/Worklog/lib/browse-idle.test.ts - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { Mock } from 'vitest'; - -vi.mock('@earendil-works/pi-coding-agent', () => ({ - getAgentDir: () => '/home/test-user/.pi/agent', - getSettingsListTheme: () => ({}), -})); - -// Keep the real tools.js exports (types, helpers) but stub the CLI path so -// no real `wl` subprocess is spawned and sync/refresh calls are observable. -vi.mock('./tools.js', async (importOriginal) => { - const actual = await importOriginal<typeof import('./tools.js')>(); - return { - ...actual, - runWl: vi.fn().mockResolvedValue('{}'), - fetchTotalActionableCount: vi.fn().mockResolvedValue(undefined), - }; -}); - -import { defaultChooseWorkItem, IDLE_PAUSE_MS } from './browse.js'; -import { runWl } from './tools.js'; - -const mockRunWl = runWl as Mock; - -// ── Widget harness ──────────────────────────────────────────────────── - -interface WidgetHarness { - handleInput: (data: string) => void; - requestRender: ReturnType<typeof vi.fn>; - done: (value: unknown) => void; - promise: Promise<unknown>; -} - -/** - * Build a mock browse context whose `custom` widget factory captures the - * widget instance (render/invalidate/handleInput) and a done() resolver. - */ -function createMockCtx(): { ctx: any; harness: WidgetHarness } { - const harness: WidgetHarness = { - handleInput: () => undefined, - requestRender: vi.fn(), - done: () => undefined, - promise: Promise.resolve(undefined), - }; - - const ctx: any = { - ui: { - custom: vi.fn().mockImplementation((factory: Function) => { - harness.promise = new Promise((resolve) => { - harness.done = resolve; - const widget = factory( - { requestRender: harness.requestRender }, - { fg: vi.fn((c: string, t: string) => t), bold: vi.fn((t: string) => t) }, - {}, - (value: unknown) => resolve(value), - ); - harness.handleInput = widget.handleInput ?? (() => undefined); - }); - return harness.promise; - }), - notify: vi.fn(), - setEditorText: vi.fn(), - setWidget: vi.fn(), - onTerminalInput: vi.fn(), - }, - }; - return { ctx, harness }; -} - -function createMockItems() { - return [ - { id: 'WL-TEST001', title: 'Test Item 1', status: 'open', stage: 'in_review' as const, priority: 'high' }, - { id: 'WL-TEST002', title: 'Test Item 2', status: 'open', stage: 'in_progress' as const, priority: 'medium' }, - ]; -} - -/** Quit the widget by dispatching escape (resolves the custom promise). */ -function quitWidget(harness: WidgetHarness): void { - harness.handleInput('\x1b'); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('browse widget idle-gated auto-refresh', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('exposes IDLE_PAUSE_MS as a named module constant (default 30s)', () => { - expect(IDLE_PAUSE_MS).toBe(30_000); - }); - - it('widget mount counts as interaction — ticks fetch while within the idle window', async () => { - const { ctx, harness } = createMockCtx(); - const items = createMockItems(); - const reFetchItems = vi.fn().mockResolvedValue(createMockItems()); - - const promise = defaultChooseWorkItem(items, ctx, vi.fn(), undefined, reFetchItems); - await vi.advanceTimersByTimeAsync(0); - - // Freshly mounted widget: no keypresses yet, but mount counts as - // interaction so the first few ticks (within IDLE_PAUSE_MS) must fetch. - await vi.advanceTimersByTimeAsync(5_000); - expect(reFetchItems).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(5_000); // t=10s, still < 30s - expect(reFetchItems).toHaveBeenCalledTimes(2); - - quitWidget(harness); - await promise; - }); - - it('tick within the idle window (keypress recently) → fetch invoked (cadence preserved)', async () => { - const { ctx, harness } = createMockCtx(); - const items = createMockItems(); - const reFetchItems = vi.fn().mockResolvedValue(createMockItems()); - - const promise = defaultChooseWorkItem(items, ctx, vi.fn(), undefined, reFetchItems); - await vi.advanceTimersByTimeAsync(0); - - // Interact at t=0 (mount) then keep interacting every 10s: each tick at - // 5/10/15/20/25s is within 30s of the last interaction → always fetches. - // Interval fires every 5s: t=5 (1), t=10 (2), t=15 (3), t=20 (4), t=25 (5). - await vi.advanceTimersByTimeAsync(5_000); - harness.handleInput('j'); // keypress at t=5s - await vi.advanceTimersByTimeAsync(10_000); // ticks at t=10, t=15 - harness.handleInput('k'); // keypress at t=15s - await vi.advanceTimersByTimeAsync(10_000); // ticks at t=20, t=25 - - expect(reFetchItems).toHaveBeenCalledTimes(5); - - quitWidget(harness); - await promise; - }); - - it('tick after the idle threshold (no keypresses for > IDLE_PAUSE_MS) → fetch NOT invoked', async () => { - const { ctx, harness } = createMockCtx(); - const items = createMockItems(); - const reFetchItems = vi.fn().mockResolvedValue(createMockItems()); - - const promise = defaultChooseWorkItem(items, ctx, vi.fn(), undefined, reFetchItems); - await vi.advanceTimersByTimeAsync(0); - - // Advance through the active window: ticks at 5/10/15/20/25/30s fetch. - await vi.advanceTimersByTimeAsync(30_000); - expect(reFetchItems).toHaveBeenCalledTimes(6); - - // Advance past the idle threshold: ticks at 35/40/45s must NOT fetch. - const callsAfterActive = reFetchItems.mock.calls.length; - await vi.advanceTimersByTimeAsync(15_000); // t=45s, idle since t=30s - expect(reFetchItems.mock.calls.length).toBe(callsAfterActive); // zero new fetches - - quitWidget(harness); - await promise; - }); - - it('auto-sync trigger (wl sync --if-idle) is also skipped when idle', async () => { - const { ctx, harness } = createMockCtx(); - const items = createMockItems(); - const reFetchItems = vi.fn().mockResolvedValue(createMockItems()); - - const promise = defaultChooseWorkItem(items, ctx, vi.fn(), undefined, reFetchItems); - await vi.advanceTimersByTimeAsync(0); - - const countSyncCalls = () => - mockRunWl.mock.calls.filter((c) => (c[0] as string[]).includes('sync')).length; - - // Active window: sync may be triggered alongside refresh (ticks at - // 5..30s are all within the 30s idle threshold — t=30 is still active - // because the check is strictly greater-than). - await vi.advanceTimersByTimeAsync(31_000); - const syncsWhileActive = countSyncCalls(); - - // Idle window (past 30s without interaction): no sync, no refresh. - const syncsBeforeIdle = countSyncCalls(); - const fetchesBeforeIdle = reFetchItems.mock.calls.length; - await vi.advanceTimersByTimeAsync(30_000); // t=61s — idle since t=35s - expect(countSyncCalls()).toBe(syncsBeforeIdle); // no new sync while idle - expect(reFetchItems.mock.calls.length).toBe(fetchesBeforeIdle); // no new fetches - - expect(syncsWhileActive).toBeGreaterThanOrEqual(0); // sanity: counter works - - quitWidget(harness); - await promise; - }); - - it('a keypress after an idle pause triggers an immediate refresh and resumes the cadence', async () => { - const { ctx, harness } = createMockCtx(); - const items = createMockItems(); - const reFetchItems = vi.fn().mockResolvedValue(createMockItems()); - - const promise = defaultChooseWorkItem(items, ctx, vi.fn(), undefined, reFetchItems); - await vi.advanceTimersByTimeAsync(0); - - // Go idle: advance past 30s without interaction. - await vi.advanceTimersByTimeAsync(35_000); - const callsBeforeKeypress = reFetchItems.mock.calls.length; - - // First keypress after idle → immediate refresh + resume cadence. - harness.handleInput('j'); - await vi.advanceTimersByTimeAsync(0); - expect(reFetchItems.mock.calls.length).toBeGreaterThan(callsBeforeKeypress); - - // Cadence resumes: next tick (5s later) also fetches. - await vi.advanceTimersByTimeAsync(5_000); - expect(reFetchItems.mock.calls.length).toBeGreaterThan(callsBeforeKeypress + 1); - - quitWidget(harness); - await promise; - }); - - it('manual actions still work regardless of idle state (shortcut dispatch)', async () => { - const { ctx, harness } = createMockCtx(); - const items = createMockItems(); - const reFetchItems = vi.fn().mockResolvedValue(createMockItems()); - const registry: any = { - lookup: vi.fn((key: string) => (key === 'r' ? '!!wl reviewed <id> false' : undefined)), - lookupChord: vi.fn().mockReturnValue(undefined), - getChordByPrefix: vi.fn().mockReturnValue([]), - getEntriesForStage: vi.fn().mockReturnValue([]), - getEntries: vi.fn().mockReturnValue([]), - }; - - const promise = defaultChooseWorkItem(items, ctx, vi.fn(), registry, reFetchItems); - await vi.advanceTimersByTimeAsync(0); - - // Let the widget go idle (no keypresses for > 30s). - await vi.advanceTimersByTimeAsync(35_000); - - // Manual shortcut 'r' must still dispatch even while idle. - harness.handleInput('r'); - const result = await promise; - expect(result).toBeDefined(); - expect((result as any).type).toBe('shortcut'); - expect((result as any).command).toBe('!!wl reviewed WL-TEST001 false'); - }); -}); - -describe('browse widget auto-refresh without reFetchItems', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('detail/selection widget with no interval is unaffected by idle state', async () => { - const { ctx, harness } = createMockCtx(); - const items = createMockItems(); - - // No reFetchItems → no auto-refresh interval is created at all; the - // widget behaves exactly as before regardless of idle gating. - const promise = defaultChooseWorkItem(items, ctx, vi.fn()); - await vi.advanceTimersByTimeAsync(0); - - await vi.advanceTimersByTimeAsync(60_000); // long idle — nothing to pause - harness.handleInput('j'); // navigation still works - harness.handleInput('\x1b'); - - // Escape with no selection resolves undefined (no crash). - await expect(promise).resolves.toBeUndefined(); - }); -}); diff --git a/packages/tui/extensions/Worklog/lib/browse.test.ts b/packages/tui/extensions/Worklog/lib/browse.test.ts deleted file mode 100644 index b2d3e3834..000000000 --- a/packages/tui/extensions/Worklog/lib/browse.test.ts +++ /dev/null @@ -1,501 +0,0 @@ -/** - * Unit tests for lib/browse.ts — browse UI logic (formatting, widgets, - * keyboard navigation, selection overlay). - * - * Run: npx vitest run packages/tui/extensions/lib/browse.test.ts - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@earendil-works/pi-coding-agent', () => ({ - getAgentDir: () => '/home/test-user/.pi/agent', - getSettingsListTheme: () => ({}), -})); - -describe('lib/browse exports', () => { - it('should export the expected types and functions', async () => { - const mod = await import('./browse.js'); - // Types are imported from ./tools.js and re-exported; not runtime-accessible - // Check that runtime exports are present - - // Constants - expect(mod.RESERVED_NAVIGATION_KEYS).toBeDefined(); - expect(mod.RESERVED_NAVIGATION_KEYS instanceof Set).toBe(true); - - // Functions - expect(typeof mod.truncateToWidth).toBe('function'); - expect(typeof mod.getIconPrefix).toBe('function'); - expect(typeof mod.formatBrowseOption).toBe('function'); - expect(typeof mod.buildSelectionWidget).toBe('function'); - expect(typeof mod.defaultChooseWorkItem).toBe('function'); - expect(typeof mod.createScrollableWidget).toBe('function'); - - // Keyboard helpers - expect(typeof mod.isUpKey).toBe('function'); - expect(typeof mod.isDownKey).toBe('function'); - expect(typeof mod.isPageUpKey).toBe('function'); - expect(typeof mod.isPageDownKey).toBe('function'); - expect(typeof mod.isEnterKey).toBe('function'); - expect(typeof mod.isCtrlEnterKey).toBe('function'); - expect(typeof mod.isEscapeKey).toBe('function'); - }); -}); - -describe('truncateToWidth', () => { - it('should truncate text with ellipsis', async () => { - const { truncateToWidth } = await import('./browse.js'); - const result = truncateToWidth('Hello World', 5); - expect(result).toBe('Hell…'); - }); - - it('should return full text if within width', async () => { - const { truncateToWidth } = await import('./browse.js'); - const result = truncateToWidth('Hi', 10); - expect(result).toBe('Hi'); - }); - - it('should use custom ellipsis', async () => { - const { truncateToWidth } = await import('./browse.js'); - const result = truncateToWidth('Hello World', 5, '...'); - expect(result).toBe('He...'); - }); -}); - -describe('RESERVED_NAVIGATION_KEYS', () => { - it('should contain g, G, and space', async () => { - const { RESERVED_NAVIGATION_KEYS } = await import('./browse.js'); - expect(RESERVED_NAVIGATION_KEYS.has('g')).toBe(true); - expect(RESERVED_NAVIGATION_KEYS.has('G')).toBe(true); - expect(RESERVED_NAVIGATION_KEYS.has(' ')).toBe(true); - expect(RESERVED_NAVIGATION_KEYS.has('i')).toBe(false); - }); -}); - -describe('createScrollableWidget', () => { - it('should return an object with render, invalidate, handleInput', async () => { - const { createScrollableWidget } = await import('./browse.js'); - const widget = createScrollableWidget(['line 1', 'line 2']); - expect(typeof widget).toBe('function'); - // Call the factory with mock tui and theme - const instance = widget({}, {}); - expect(typeof instance.render).toBe('function'); - expect(typeof instance.invalidate).toBe('function'); - expect(typeof instance.handleInput).toBe('function'); - }); - - it('should render provided lines', async () => { - const { createScrollableWidget } = await import('./browse.js'); - const widget = createScrollableWidget(['line 1', 'line 2']); - const instance = widget({}, {}); - const rendered = instance.render(100); - expect(rendered).toContain('line 1'); - expect(rendered).toContain('line 2'); - }); -}); - -describe('formatChordHints', () => { - it('should be exported from browse module', async () => { - const { formatChordHints } = await import('./browse.js'); - expect(typeof formatChordHints).toBe('function'); - }); - - it('should return empty string for empty chords array', async () => { - const { formatChordHints } = await import('./browse.js'); - expect(formatChordHints([], ['u'])).toBe(''); - }); - - it('should collapse multiple chords sharing the same nextKey at intermediate level', async () => { - const { formatChordHints } = await import('./browse.js'); - // Simulate four u-p-* chords at the 'u' layer (pendingChord = ['u']) - // All four share nextKey='p', should collapse to 'p:priority...' - const chords: any[] = [ - { key: '', command: '!!wl update <id> --priority low', view: 'both', chord: ['u', 'p', 'l'], label: 'update priority low' }, - { key: '', command: '!!wl update <id> --priority medium', view: 'both', chord: ['u', 'p', 'm'], label: 'update priority medium' }, - { key: '', command: '!!wl update <id> --priority high', view: 'both', chord: ['u', 'p', 'h'], label: 'update priority high' }, - { key: '', command: '!!wl update <id> --priority critical', view: 'both', chord: ['u', 'p', 'c'], label: 'update priority critical' }, - ]; - const result = formatChordHints(chords, ['u']); - expect(result).toBe('p:priority...'); - }); - - it('should strip correct number of words at deeper chord level (pendingChord.length >= 2)', async () => { - const { formatChordHints } = await import('./browse.js'); - // At the 'u-p' layer (pendingChord = ['u', 'p']), each u-p-* has unique nextKey - // Should strip 2 words from each label - const chords: any[] = [ - { key: '', command: '!!wl update <id> --priority low', view: 'both', chord: ['u', 'p', 'l'], label: 'update priority low' }, - { key: '', command: '!!wl update <id> --priority medium', view: 'both', chord: ['u', 'p', 'm'], label: 'update priority medium' }, - { key: '', command: '!!wl update <id> --priority high', view: 'both', chord: ['u', 'p', 'h'], label: 'update priority high' }, - { key: '', command: '!!wl update <id> --priority critical', view: 'both', chord: ['u', 'p', 'c'], label: 'update priority critical' }, - ]; - const result = formatChordHints(chords, ['u', 'p']); - expect(result).toBe('l:low m:medium h:high c:critical'); - }); - - it('should handle non-standard labels with fewer words gracefully', async () => { - const { formatChordHints } = await import('./browse.js'); - // x-c has label 'close done' (2 words), chord ['x', 'c'] - // x-d has label 'close deleted' (2 words), chord ['x', 'd'] - // At 'x' layer (pendingChord.length = 1), strip 1 word - const chords: any[] = [ - { key: '', command: '!!wl close <id>', view: 'both', chord: ['x', 'c'], label: 'close done' }, - { key: '', command: '!!wl delete <id>', view: 'both', chord: ['x', 'd'], label: 'close deleted' }, - ]; - const result = formatChordHints(chords, ['x']); - // Each has unique nextKey, so no collapsing — full rest shown - expect(result).toBe('c:done d:deleted'); - }); - - it('should filter out commands with <id> when isEmpty option is true', async () => { - const { formatChordHints } = await import('./browse.js'); - const chords: any[] = [ - { key: '', command: '!!wl close <id>', view: 'both', chord: ['x', 'c'], label: 'close done' }, - ]; - // With empty items, chords referencing <id> should be filtered out - const result = formatChordHints(chords, ['x'], { isEmpty: true }); - expect(result).toBe(''); - }); - - it('should still show chords when isEmpty is false even if they have <id>', async () => { - const { formatChordHints } = await import('./browse.js'); - const chords: any[] = [ - { key: '', command: '!!wl close <id>', view: 'both', chord: ['x', 'c'], label: 'close done' }, - ]; - const result = formatChordHints(chords, ['x'], { isEmpty: false }); - expect(result).toBe('c:done'); - }); - - it('should have safety check when label has fewer words than chord depth', async () => { - const { formatChordHints } = await import('./browse.js'); - // Label has only 1 word but chord depth is 3 - // Should fall back safely and show the full label - const chords: any[] = [ - { key: '', command: '!!wl update <id> --priority low', view: 'both', chord: ['u', 'p', 'l'], label: 'low' }, - ]; - const result = formatChordHints(chords, ['u', 'p']); - // Safety: should show the full label since stripping would produce empty - expect(result).toBe('l:low'); - }); - - it('should handle mixed groups with both single and multiple entries per nextKey', async () => { - const { formatChordHints } = await import('./browse.js'); - // u-p has 4 entries (all share p), u-s has 1 entry (unique s), u-t has 1 entry (unique t) - const chords: any[] = [ - { key: '', command: '!!wl update <id> --priority low', view: 'both', chord: ['u', 'p', 'l'], label: 'update priority low' }, - { key: '', command: '!!wl update <id> --priority medium', view: 'both', chord: ['u', 'p', 'm'], label: 'update priority medium' }, - { key: '', command: '!!wl update <id> --priority high', view: 'both', chord: ['u', 'p', 'h'], label: 'update priority high' }, - { key: '', command: '!!wl update <id> --priority critical', view: 'both', chord: ['u', 'p', 'c'], label: 'update priority critical' }, - { key: '', command: '!!wl update <id> --status', view: 'both', chord: ['u', 's'], label: 'update stage/status' }, - { key: '', command: '!!wl update <id> --title', view: 'both', chord: ['u', 't'], label: 'update title' }, - ]; - const result = formatChordHints(chords, ['u']); - // p:priority... (collapsed), s:stage/status (single), t:title (single) - expect(result).toContain('p:priority...'); - expect(result).toContain('s:stage/status'); - expect(result).toContain('t:title'); - }); - - it('should extract label from command when no explicit label is set', async () => { - const { formatChordHints } = await import('./browse.js'); - // No label property — should derive from command - const chords: any[] = [ - { key: '', command: '/intake\n<desc>\nPriority: medium', view: 'both', chord: ['f', 'i'], label: undefined }, - ]; - const result = formatChordHints(chords, ['f']); - expect(typeof result).toBe('string'); - expect(result.length).toBeGreaterThan(0); - }); - - it('should handle non-standard labels without crashing for deep chords', async () => { - const { formatChordHints } = await import('./browse.js'); - // Label with 2 words, chord depth 2, pendingChord.length = 1 - // Strip 1 word → 'done' for x-c, 'deleted' for x-d - const chords: any[] = [ - { key: '', command: '!!wl close <id>', view: 'both', chord: ['x', 'c'], label: 'close done' }, - { key: '', command: '!!wl delete <id>', view: 'both', chord: ['x', 'd'], label: 'close deleted' }, - ]; - // At 'x' layer — both have unique nextKey - const result = formatChordHints(chords, ['x']); - expect(result).toBe('c:done d:deleted'); - }); - - it('should work for a-* audit chords (a-y, a-r, a-a)', async () => { - const { formatChordHints } = await import('./browse.js'); - const chords: any[] = [ - { key: '', command: '!!wl audit-set <id> --ready-to-close yes', view: 'both', chord: ['a', 'y'], label: 'audit approve' }, - { key: '', command: '!!wl audit-set <id> --ready-to-close no', view: 'both', chord: ['a', 'r'], label: 'audit reject' }, - { key: '', command: '/skill:audit <id>', view: 'both', chord: ['a', 'a'], label: 'audit automatic' }, - ]; - // At 'a' layer — all three share nextKey? No: y, r, a are all different - // So no collapsing needed - const result = formatChordHints(chords, ['a']); - // Strip 1 word: 'approve', 'reject', 'automatic' - expect(result).toBe('y:approve r:reject a:automatic'); - }); - - describe('multi-<id> token replacement in list view shortcut dispatch', () => { - let capturedHandler: ((data: string) => void) | undefined; - let doneResolve: ((value: unknown) => void) | undefined; - - function createMockCtx(): any { - capturedHandler = undefined; - doneResolve = undefined; - - return { - ui: { - custom: vi.fn().mockImplementation((factory: Function) => { - return new Promise((resolve) => { - doneResolve = resolve; - const widget = factory( - { requestRender: vi.fn() }, - { fg: vi.fn((c: string, t: string) => t), bold: vi.fn((t: string) => t) }, - {}, - (value: unknown) => resolve(value), - ); - capturedHandler = widget.handleInput; - }); - }), - notify: vi.fn(), - setEditorText: vi.fn(), - setWidget: vi.fn(), - onTerminalInput: vi.fn(), - }, - }; - } - - function createMockItems() { - return [ - { - id: 'WL-TEST001', - title: 'Test Item 1', - status: 'open', - stage: 'in_review' as const, - priority: 'high', - issueType: 'bug', - }, - { - id: 'WL-TEST002', - title: 'Test Item 2', - status: 'open', - stage: 'in_progress' as const, - priority: 'medium', - issueType: 'feature', - }, - ]; - } - - function createMockRegistry(commands: Record<string, { command: string; isChord?: boolean }>) { - return { - lookup: vi.fn((key: string, _view: string, _stage?: string) => { - const entry = commands[key]; - if (entry && !entry.isChord) return entry.command; - return undefined; - }), - lookupChord: vi.fn((chordKeys: string[], _view: string, _stage?: string) => { - const key = chordKeys.join(' '); - const entry = commands[key]; - if (entry && entry.isChord) return entry.command; - return undefined; - }), - getChordByPrefix: vi.fn().mockReturnValue([]), - getEntriesForStage: vi.fn().mockReturnValue([]), - getEntries: vi.fn().mockReturnValue([]), - }; - } - - it('replaces all <id> tokens in single-key dispatch', async () => { - const { defaultChooseWorkItem } = await import('./browse.js'); - const items = createMockItems(); - const ctx: any = createMockCtx(); - const mockRegistry = createMockRegistry({ - r: { command: '!!wl reviewed <id> false && wl audit-set <id> --ready-to-close yes' }, - }); - - const promise = defaultChooseWorkItem(items, ctx, vi.fn(), mockRegistry as any); - - expect(capturedHandler).toBeDefined(); - capturedHandler!('r'); - - const result = await promise; - expect(result).toBeDefined(); - expect(result!.type).toBe('shortcut'); - expect((result! as any).command).toBe( - '!!wl reviewed WL-TEST001 false && wl audit-set WL-TEST001 --ready-to-close yes', - ); - }); - - it('replaces all <id> tokens in chord dispatch', async () => { - const { defaultChooseWorkItem } = await import('./browse.js'); - const items = createMockItems(); - const ctx: any = createMockCtx(); - - // For chord dispatch, the first keypress triggers chord prefix lookup - // which sets pendingChord. The second keypress resolves the chord. - const mockRegistry = createMockRegistry({ - a: { command: '' }, // triggers chord prefix - 'a a': { command: '!!wl reviewed <id> false && wl audit-set <id> --ready-to-close yes', isChord: true }, - }); - - // Override getChordByPrefix to return a match on first 'a' press - mockRegistry.getChordByPrefix = vi.fn((prefix: string[]) => { - if (prefix.length === 1 && prefix[0] === 'a') { - return [{ key: '', command: '!!wl reviewed <id> false && wl audit-set <id> --ready-to-close yes', view: 'both', chord: ['a', 'a'] }]; - } - return []; - }); - - const promise = defaultChooseWorkItem(items, ctx, vi.fn(), mockRegistry as any); - - expect(capturedHandler).toBeDefined(); - // First press sets up chord prefix - capturedHandler!('a'); - // Second press resolves chord - capturedHandler!('a'); - - const result = await promise; - expect(result).toBeDefined(); - expect(result!.type).toBe('shortcut'); - expect((result! as any).command).toBe( - '!!wl reviewed WL-TEST001 false && wl audit-set WL-TEST001 --ready-to-close yes', - ); - }); - - it('preserves backward compatibility with single <id> token', async () => { - const { defaultChooseWorkItem } = await import('./browse.js'); - const items = createMockItems(); - const ctx: any = createMockCtx(); - const mockRegistry = createMockRegistry({ - i: { command: 'implement <id>' }, - }); - - const promise = defaultChooseWorkItem(items, ctx, vi.fn(), mockRegistry as any); - - expect(capturedHandler).toBeDefined(); - capturedHandler!('i'); - - const result = await promise; - expect(result).toBeDefined(); - expect(result!.type).toBe('shortcut'); - expect((result! as any).command).toBe('implement WL-TEST001'); - }); - - it('replaces three <id> tokens in a single command', async () => { - const { defaultChooseWorkItem } = await import('./browse.js'); - const items = createMockItems(); - const ctx: any = createMockCtx(); - const mockRegistry = createMockRegistry({ - x: { command: '!!wl update <id> --status done && wl close <id> --reason "fixed" && wl comment add <id> --body "done"' }, - }); - - const promise = defaultChooseWorkItem(items, ctx, vi.fn(), mockRegistry as any); - - expect(capturedHandler).toBeDefined(); - capturedHandler!('x'); - - const result = await promise; - expect(result).toBeDefined(); - expect(result!.type).toBe('shortcut'); - expect((result! as any).command).toBe( - '!!wl update WL-TEST001 --status done && wl close WL-TEST001 --reason "fixed" && wl comment add WL-TEST001 --body "done"', - ); - }); - }); -}); - -describe('getIconPrefix — stale audit but passed', () => { - it('returns stale-passed icon for in_review with stale audit and readyToClose=true', async () => { - const { getIconPrefix } = await import('./browse.js'); - // Stale audit: auditedAt older than (updatedAt - 60000) - const result = getIconPrefix( - { - id: 'WL-TEST002', - title: 'Test Item', - status: 'open', - stage: 'in_review', - auditedAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-06-01T00:00:00Z', - auditResult: true, - }, - false, - ); - // Result format: status (🔓) + stale-passed (🟩) + producer-review (✅) = "🔓 🟩 ✅" - expect(result).toContain('\u{1F7E9}'); // 🟩 stale-passed - expect(result).not.toContain('\u{1F50D}'); // Not 🔍 stage icon (would be stale fallback) - // ✅ appears as producer review flag (column 3), not as fresh audit icon (column 2) - }); - - it('returns stage icon for in_review with stale audit and no audit result', async () => { - const { getIconPrefix } = await import('./browse.js'); - const result = getIconPrefix( - { - id: 'WL-TEST003', - title: 'Test Item', - status: 'open', - stage: 'in_review', - auditedAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-06-01T00:00:00Z', - auditResult: undefined, - }, - false, - ); - // Should have stage icon (🔍) not stale-passed icon - expect(result).toContain('\u{1F50D}'); // 🔍 stage icon - expect(result).not.toContain('\u{1F7E9}'); // 🟩 stale-passed - }); - - it('returns ✅ for in_review with fresh audit and readyToClose=true', async () => { - const { getIconPrefix } = await import('./browse.js'); - // Fresh audit: now - 1 second (within the 60-second buffer) - const now = new Date(); - const result = getIconPrefix( - { - id: 'WL-TEST004', - title: 'Test Item', - status: 'open', - stage: 'in_review', - auditedAt: new Date(now.getTime() - 1000).toISOString(), - updatedAt: now.toISOString(), - auditResult: true, - }, - false, - ); - expect(result).toContain('\u{2705}'); // ✅ fresh passed - }); - - it('returns ❌ for in_review with fresh audit and readyToClose=false', async () => { - const { getIconPrefix } = await import('./browse.js'); - const now = new Date(); - const result = getIconPrefix( - { - id: 'WL-TEST005', - title: 'Test Item', - status: 'open', - stage: 'in_review', - auditedAt: new Date(now.getTime() - 1000).toISOString(), - updatedAt: now.toISOString(), - auditResult: false, - }, - false, - ); - expect(result).toContain('\u{274C}'); // ❌ fresh failed - }); - - it('returns stale-passed icon with noIcons=true for in_review stale passed', async () => { - const { getIconPrefix } = await import('./browse.js'); - const result = getIconPrefix( - { - id: 'WL-TEST006', - title: 'Test Item', - status: 'open', - stage: 'in_review', - auditedAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-06-01T00:00:00Z', - auditResult: true, - }, - true, - ); - expect(result).toContain('[YES_STALE]'); - expect(result).not.toContain('[YES]'); // Not fresh passed - expect(result).not.toContain('[REVIEW]'); // Not stage fallback - }); -}); diff --git a/packages/tui/extensions/Worklog/lib/browse.ts b/packages/tui/extensions/Worklog/lib/browse.ts deleted file mode 100644 index 8be41439d..000000000 --- a/packages/tui/extensions/Worklog/lib/browse.ts +++ /dev/null @@ -1,1458 +0,0 @@ -/** - * lib/browse.ts — Browse UI logic for the Worklog extension - * - * Extracted from the monolithic index.ts. Provides work item formatting, - * selection widgets, scrollable detail views, and the browsing overlay. - */ - -import { createRequire } from 'node:module'; -import { realpathSync, readFileSync, existsSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { join, dirname } from 'node:path'; -import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; -import { applyStageColour, type PiTheme } from '../worklog-helpers.js'; -import { truncateToTerminalWidth, wrapToTerminalWidth, visibleWidth } from '../terminal-utils.js'; -import type { ShortcutRegistry, ShortcutEntry } from '../shortcut-config.js'; -import { currentSettings } from './settings.js'; -import { - RESERVED_NAVIGATION_KEYS, - isUpKey, - isDownKey, - isPageUpKey, - isPageDownKey, - isEnterKey, - isCtrlEnterKey, - isShiftEnterKey, - isEscapeKey, - isTabKey, -} from './shortcuts.js'; - -// Re-export keyboard helpers and navigation keys so existing imports from -// browse.js continue to work (and for test access). -export { - RESERVED_NAVIGATION_KEYS, - isUpKey, - isDownKey, - isPageUpKey, - isPageDownKey, - isEnterKey, - isCtrlEnterKey, - isShiftEnterKey, - isEscapeKey, - isTabKey, -}; -import { - type WorklogBrowseItem, - type RunWlFn, - runWl, - extractJsonObject, - normalizeListPayload, - fetchTotalActionableCount, -} from './tools.js'; - -// Use createRequire with realpath-resolved path so the icons module can be -// found even when this extension is loaded via a symlink. -const _require = createRequire(realpathSync(fileURLToPath(import.meta.url))); -const { priorityIcon, statusIcon, stageIcon, auditIcon, epicIcon, iconsEnabled, riskIcon, effortIcon, needsProducerReviewIcon, auditStaleIcon } = _require('../../../../../dist/icons.js'); - -// ── Auto-sync state ──────────────────────────────────────────────── - -/** - * Idle threshold (ms) for the browse widget's auto-refresh/auto-sync. - * When the widget has had no keypresses for this long, the 5s refresh - * interval pauses (zero `wl` subprocess spawns) until the next keypress. - * Named module constant (no settings toggle — always-on, fail-open). - */ -export const IDLE_PAUSE_MS = 30_000; - -/** - * In-flight guard flag for TUI background auto-sync. - * Prevents concurrent background syncs when multiple auto-refresh - * intervals fire before a previous sync completes. - */ -let _autoSyncInFlight = false; - -/** - * Find the .worklog directory by walking up from cwd. - */ -function _findWorklogDir(): string | null { - let dir = process.cwd(); - while (dir !== dirname(dir)) { - const candidate = join(dir, '.worklog'); - if (existsSync(candidate)) return candidate; - dir = dirname(dir); - } - const rootCandidate = join(dir, '.worklog'); - return existsSync(rootCandidate) ? rootCandidate : null; -} - -/** - * Read the last sync timestamp from .worklog/last-sync-time. - * Returns null when the file is missing or unreadable. - */ -function _readLastSyncTime(): string | null { - try { - const worklogDir = _findWorklogDir(); - if (!worklogDir) return null; - const lastSyncPath = join(worklogDir, 'last-sync-time'); - if (!existsSync(lastSyncPath)) return null; - return readFileSync(lastSyncPath, 'utf-8').trim(); - } catch { - return null; - } -} - -/** - * Trigger a background `wl sync` if auto-sync conditions are met. - * Checks the in-flight guard and the configured interval threshold. - * This is fire-and-forget: errors are silently ignored. - */ -function _triggerAutoSync(): void { - if (_autoSyncInFlight) return; - - const intervalSeconds = currentSettings.autoSyncIntervalSeconds; - if (intervalSeconds <= 0) return; - - const lastSyncStr = _readLastSyncTime(); - if (!lastSyncStr) { - // No sync ever performed - trigger one - } else { - const elapsed = Date.now() - new Date(lastSyncStr).getTime(); - if (elapsed < intervalSeconds * 1000) return; - } - - _autoSyncInFlight = true; - - // Fire-and-forget: invoke wl sync in background, then clear the guard. - // `--if-idle` makes the sync skip when the file lock is held by another - // process (cross-process mutex) so multiple TUI instances/panes cannot pile - // up into a lock storm (WL-0MSAB7ZUC004SK7E). - runWl(['sync', '--if-idle']).finally(() => { - _autoSyncInFlight = false; - }).catch(() => { - _autoSyncInFlight = false; - }); -} - -// ── Types ───────────────────────────────────────────────────────────── - -export interface ShortcutResult { - type: 'shortcut'; - command: string; -} - -export type SelectionChangeHandler = (item: WorklogBrowseItem) => void; - -export type ChooseWorkItemFn = ( - items: WorklogBrowseItem[], - ctx: BrowseContext, - onSelectionChange: SelectionChangeHandler, -) => Promise<WorklogBrowseItem | ShortcutResult | undefined>; - -export interface WorklogBrowseDependencies { - listWorkItems?: () => Promise<WorklogBrowseItem[]>; - listWorkItemsWithStage?: (stage: string) => Promise<WorklogBrowseItem[]>; - runWl?: RunWlFn; - chooseWorkItem?: ChooseWorkItemFn; - shortcutRegistry?: ShortcutRegistry; -} - -type TerminalInputHandler = (data: string) => { consume?: boolean; data?: string } | undefined; - -/** - * Browse UI interface - matches the subset of ExtensionUIContext we use. - */ -interface BrowseUi { - select?: (title: string, options: string[]) => Promise<string | undefined>; - custom?: <T>( - render: ( - tui: { requestRender: () => void }, - theme: { - fg: (color: string, text: string) => string; - bold: (text: string) => string; - }, - keybindings: unknown, - done: (value: T) => void, - ) => { - render: (width: number) => string[]; - invalidate: () => void; - handleInput?: (data: string) => void; - }, - ) => Promise<T>; - setWidget?: (id: string, content?: string[] | ((tui: unknown, theme: unknown) => { render: (width: number) => string[]; invalidate: () => void; handleInput?: (data: string) => void; dispose?: () => void; })) => void; - notify: (message: string, level?: 'info' | 'warning' | 'error') => void; - setEditorText?: (text: string) => void; - getEditorText?: () => string; - onTerminalInput?: (handler: TerminalInputHandler) => () => void; - getHeight?: () => number; - setStatus?: (key: string, text: string | undefined) => void; - readonly theme?: { - fg: (color: string, text: string) => string; - bg: (color: string, text: string) => string; - bold: (text: string) => string; - }; -} - -export type { WorklogBrowseItem } from './tools.js'; - -export type BrowseContext = { ui: BrowseUi }; -type PiLike = ExtensionAPI; - -// ── Formatting helpers ──────────────────────────────────────────────── - -/** - * Truncate a string to fit within maxWidth visible terminal columns. - */ -export function truncateToWidth(text: string, maxWidth: number, ellipsis = '…'): string { - return truncateToTerminalWidth(text, maxWidth, { ellipsis }); -} - -/** - * Format chord shortcut hints for the help line, collapsing multiple chords - * that share the same nextKey into a single entry, and stripping consumed - * words from labels based on pending chord depth. - * - * At intermediate levels (pendingChord.length >= 1), chords with the same - * nextKey are collapsed to `<nextKey>:<firstWord>...` matching the first-layer - * pattern. At deeper levels, the label strips all words consumed by the - * traversed chord keys (not just 1 word). - * - * @param chords - Chord entries to format (already filtered by prefix/view/stage) - * @param pendingChord - Current pending chord prefix - * @param options - Optional settings (isEmpty: filter out commands with `<id>`) - * @returns Space-joined hint string, or empty string if no hints remain - */ -export function formatChordHints( - chords: ShortcutEntry[], - pendingChord: string[], - options?: { isEmpty?: boolean }, -): string { - // Filter out chords referencing <id> when there are no items to operate on - const filtered = options?.isEmpty - ? chords.filter(c => !c.command.includes('<id>')) - : chords; - - if (filtered.length === 0) return ''; - - // Extract display label from an entry (same logic as formatEntryLabel/formatHint) - const extractLabel = (e: ShortcutEntry): string => { - return e.label ?? e.command - .replace(/<[^>]+>/g, '') - .split(/\r?\n/)[0] - .trim() - .replace(/^\/(skill:)?/, ''); - }; - - // Build hint entries with nextKey and computed rest - // For each entry, store the first word after stripping consumed words - // so that collapsed hints can use the correct word (e.g., 'priority' - // instead of 'update' for u-p-* chords at the 'u' layer). - type HintEntry = { nextKey: string; hint: string; firstRestWord: string }; - const hints: HintEntry[] = []; - - for (const e of filtered) { - const chord = (e as Record<string, unknown>).chord; - const label = extractLabel(e); - - if (Array.isArray(chord) && chord.length > pendingChord.length) { - // Pending chord has remaining keys — compute nextKey and stripped rest - const nextKey = (chord as string[])[pendingChord.length]; - const words = label.split(/\s+/); - // Safety check: don't strip more words than exist minus one - const stripCount = Math.min(pendingChord.length, Math.max(0, words.length - 1)); - const rest = words.slice(stripCount); - const firstRestWord = rest.length > 0 ? rest[0] : (words.length > 0 ? words[words.length - 1] : ''); - const hint = rest.length > 0 ? `${nextKey}:${rest.join(' ')}` : nextKey; - hints.push({ nextKey, hint, firstRestWord }); - } else { - // Fallback: entry is not a chord or chord is fully consumed - // Format like the first layer: leaderKey:firstWord... or key:label - if (Array.isArray(chord) && chord.length >= 2) { - const leaderKey = (chord as string[])[0]; - const firstWord = label.split(/\s+/)[0]; - hints.push({ nextKey: leaderKey, hint: `${leaderKey}:${firstWord}...`, firstRestWord: firstWord }); - } else if (e.key) { - hints.push({ nextKey: e.key, hint: `${e.key}:${label}`, firstRestWord: label.split(/\s+/)[0] }); - } - } - } - - // Group by nextKey - const byKey = new Map<string, HintEntry[]>(); - for (const h of hints) { - const group = byKey.get(h.nextKey) ?? []; - group.push(h); - byKey.set(h.nextKey, group); - } - - // Build result: collapse groups with multiple entries - const result: string[] = []; - for (const [, group] of byKey) { - if (group.length > 1) { - // Multiple entries for the same nextKey — collapse to nextKey:firstWord... - result.push(`${group[0].nextKey}:${group[0].firstRestWord}...`); - } else { - // Single entry — show full hint as computed - result.push(group[0].hint); - } - } - - return result.join(' '); -} - -/** - * Determine whether an audit result is fresh (not stale) based on the - * 60-second staleness buffer. - * - * An audit is considered fresh when: - * auditedAt > updatedAt - 60000 (milliseconds) - * - * If auditedAt or updatedAt is missing, the audit is considered stale. - */ -function isAuditFresh(auditedAt: string | null | undefined, updatedAt: string | undefined): boolean { - if (!auditedAt || !updatedAt) return false; - const auditTime = new Date(auditedAt).getTime(); - const updateTime = new Date(updatedAt).getTime(); - if (isNaN(auditTime) || isNaN(updateTime)) return false; - return auditTime > updateTime - 60000; -} - -/** - * Compute the icon prefix string for a work item (just icon characters, no trailing space). - * - * Column layout (left to right): - * 1. Status icon - * 2. Stage icon (for `in_review` items, shows audit-aware icon instead) - * 3. Producer review flag icon (replaces audit icon for all stages) - * 4. Optional epic icon + child count - * - * For `in_review` items, column 2 shows: - * - 🔍 (stage icon) if no audit exists or audit is stale - * - ✅ if a fresh audit says readyToClose=true - * - ❌ if a fresh audit says readyToClose=false - * - * For all other stages, column 2 shows the normal stage icon. - * - * Column 3 always shows the producer review flag: - * - ❌ when needsProducerReview === true - * - ✅ when needsProducerReview === false - */ -export function getIconPrefix(item: WorklogBrowseItem, noIcons: boolean): string { - const normalizedStatus = (item.status || '').replace(/_/g, '-'); - const sIcon = statusIcon(normalizedStatus, { noIcons }); - - // Column 2: stage or audit-aware icon for in_review - let secondIcon: string; - if (item.stage === 'in_review') { - const fresh = isAuditFresh(item.auditedAt, item.updatedAt); - if (fresh) { - // Fresh audit: show based on readyToClose - secondIcon = auditIcon(item.auditResult, { noIcons }); - } else { - // No audit or stale audit: show stale-passed icon if passed, else stage icon - if (item.auditResult === true) { - secondIcon = auditStaleIcon(item.auditResult, { noIcons }); - } else { - secondIcon = stageIcon(item.stage, { noIcons }); - } - } - } else { - secondIcon = stageIcon(item.stage, { noIcons }); - } - - // Column 3: producer review flag (replaces audit icon for all stages) - const prIcon = needsProducerReviewIcon(item.needsProducerReview, { noIcons }); - - const coreIcons = [sIcon, secondIcon, prIcon].filter(Boolean).join(' '); - - let childSuffix = ''; - if (item.childCount !== undefined && item.childCount > 0) { - const countStr = `(${item.childCount})`; - if (item.issueType === 'epic') { - const eIcon = epicIcon({ noIcons }); - childSuffix = `${eIcon}${countStr}`; - } else { - childSuffix = countStr; - } - } else if (item.issueType === 'epic') { - const eIcon = epicIcon({ noIcons }); - childSuffix = eIcon; - } - - return [coreIcons, childSuffix].filter(Boolean).join(' '); -} - -export function formatBrowseOption( - item: WorklogBrowseItem, - maxWidth?: number, - theme?: PiTheme, - settings?: typeof currentSettings, - prefixWidth?: number, -): string { - const titleText = item.title; - - const showIcons = settings?.showIcons ?? iconsEnabled(); - const noIcons = !showIcons; - - const iconPrefix = getIconPrefix(item, noIcons); - - let prefixStr: string; - if (iconPrefix.length > 0) { - if (prefixWidth !== undefined) { - const currentIconWidth = visibleWidth(iconPrefix); - const padding = Math.max(0, prefixWidth - currentIconWidth); - prefixStr = iconPrefix + ' '.repeat(padding + 1); - } else { - prefixStr = `${iconPrefix} `; - } - } else { - prefixStr = ''; - } - - const formatTitle = (title: string): string => { - if (theme) { - return applyStageColour(title, item.stage, item.status, theme); - } - return title; - }; - - const fullLine = `${prefixStr}${formatTitle(titleText)}`; - - if (!maxWidth || maxWidth <= 0) { - return fullLine; - } - - return truncateToWidth(fullLine, maxWidth); -} - -// ── Selection widget ────────────────────────────────────────────────── - -/** - * Create a selection widget factory that renders a compact single-line - * summary of work item metadata. - */ -export function buildSelectionWidget( - item: WorklogBrowseItem, - settings?: typeof currentSettings, -): (tui: any, _theme: PiTheme) => { - render: (width: number) => string[]; - invalidate: () => void; -} { - return (_tui, _theme) => { - let cachedWidth: number | undefined; - let cachedLines: string[] | undefined; - - const computeLine = (): string => { - const idPart = item.id; - - const tags = item.tags; - const tagStr = Array.isArray(tags) && tags.length > 0 - ? tags.join(', ') - : '—'; - const tagsPart = `tags: ${tagStr}`; - - const ghPart = (item.githubIssueNumber !== undefined && item.githubIssueNumber > 0) - ? `GH #${item.githubIssueNumber}` - : null; - - const showIcons = settings?.showIcons ?? iconsEnabled(); - const noIcons = !showIcons; - const effortStr = effortIcon(item.effort, { noIcons }); - const riskStr = riskIcon(item.risk, { noIcons }); - const effortRiskPart = [effortStr, riskStr].filter(Boolean).join(' '); - - const parts = [idPart, tagsPart, ghPart, effortRiskPart].filter(Boolean); - - return parts.join(' | '); - }; - - return { - render: (width: number) => { - if (cachedLines && cachedWidth === width) { - return cachedLines; - } - const line = computeLine(); - cachedWidth = width; - cachedLines = [truncateToWidth(line, width)]; - return cachedLines; - }, - invalidate: () => { - cachedWidth = undefined; - cachedLines = undefined; - }, - }; - }; -} - -// ── Browse overlay (default choose work item) ───────────────────────── - -/** - * State snapshot used to preserve the selection list's navigation context - * across loop iterations in runBrowseFlow. Captured at the moment an item - * is selected (Enter), and restored when the loop restarts after returning - * from the detail view via Escape. - */ -export interface BrowseSelectionState { - /** Snapshot of the current items array at time of selection */ - currentItems: WorklogBrowseItem[]; - /** Index of the selected item */ - selectedIndex: number; - /** Last announced item ID (for onSelectionChange dedup) */ - lastSelectionId: string | undefined; - /** Hierarchical navigation stack (drill-down parents) */ - navStack: Array<{ - items: WorklogBrowseItem[]; - selectedIndex: number; - lastSelectionId: string | undefined; - }>; -} - -/** - * Default work item chooser that renders a custom overlay with the browse list. - */ -export async function defaultChooseWorkItem( - items: WorklogBrowseItem[], - ctx: BrowseContext, - onSelectionChange: SelectionChangeHandler, - shortcutRegistry?: ShortcutRegistry, - reFetchItems?: () => Promise<WorklogBrowseItem[]>, - fetchChildren?: (parentId: string) => Promise<WorklogBrowseItem[]>, - totalCount?: number, - /** - * Optional mutable context for preserving navigation state across - * loop restarts. When provided with a non-empty snapshot, the - * selection list initializes from the restored state instead of - * starting fresh. The state is updated again when an item is - * selected (so the next iteration sees the correct hierarchy level). - */ - selectionState?: BrowseSelectionState, -): Promise<WorklogBrowseItem | ShortcutResult | undefined> { - if (!ctx.ui.custom) { - if (!ctx.ui.select) { - throw new Error('Selection UI is unavailable in this environment.'); - } - - const noIcons = !(currentSettings?.showIcons ?? iconsEnabled()); - const maxPrefixWidth = items.reduce( - (max, item) => Math.max(max, visibleWidth(getIconPrefix(item, noIcons))), - 0, - ); - - const options = items.map(item => formatBrowseOption(item, undefined, undefined, currentSettings, maxPrefixWidth)); - // "top N of M": N = actual displayed count (may exceed browseItemCount and - // even the actionable total when the mandatory critical/completed-in_review - // set is shown — those items are not counted by fetchTotalActionableCount), - // M = actionable total. - const displayedCount = items.length; - const titleSuffix = totalCount !== undefined ? ` (top ${displayedCount} of ${totalCount})` : ` (top ${displayedCount})`; - const selected = await ctx.ui.select(`Browse Worklog next items${titleSuffix}`, options); - if (!selected) return undefined; - - const selectedIndex = options.indexOf(selected); - if (selectedIndex < 0) { - ctx.ui.notify('Invalid selection.', 'warning'); - return undefined; - } - - const selectedItem = items[selectedIndex]; - onSelectionChange(selectedItem); - return selectedItem; - } - - // ── Chord state ────────────────────────────────────────────────── - let pendingChord: string[] = []; - - const result = await ctx.ui.custom<WorklogBrowseItem | ShortcutResult | null>((tui, theme, _keybindings, done) => { - let selectedIndex = 0; - let lastSelectionId = items[0]?.id; - let cachedWidth: number | undefined; - let cachedLines: string[] | undefined; - // Mutable so auto-refresh can update the total actionable count without - // rebuilding the widget (the render closure reads this each frame). - let currentTotalCount: number | undefined = totalCount; - - // Idle-gating: mount counts as interaction (freshly opened widget starts - // active), updated on every keypress. Auto-refresh/auto-sync pause after - // IDLE_PAUSE_MS without interaction (WL-0MSB1N0HB0007N6N). - let lastInteractionAt = Date.now(); - - const invalidateCache = () => { - cachedWidth = undefined; - cachedLines = undefined; - }; - - // True when the widget has been idle for longer than IDLE_PAUSE_MS. - const isIdle = (): boolean => Date.now() - lastInteractionAt > IDLE_PAUSE_MS; - - // ── Auto-refresh interval ────────────────────────────────────── - let refreshInterval: ReturnType<typeof setInterval> | undefined; - - if (reFetchItems) { - refreshInterval = setInterval(async () => { - if (pendingChord.length > 0) return; - - // Idle-gating (WL-0MSB1N0HB0007N6N): when the selection list has had - // no keypresses for IDLE_PAUSE_MS, skip the fetch AND the auto-sync - // trigger so idle/hidden sessions spawn zero wl subprocesses. A - // keypress resumes the cadence (see handleInput). - if (isIdle()) return; - - // Trigger background auto-sync if interval has elapsed - _triggerAutoSync(); - - try { - let newItems: WorklogBrowseItem[]; - - if (navStack.length > 0) { - const parentEntry = navStack[navStack.length - 1]; - const parentId = parentEntry.items[parentEntry.selectedIndex]?.id; - if (!parentId || !fetchChildren) return; - - const childResults = await fetchChildren(parentId); - newItems = [ - { id: '..', title: '..', status: 'open' }, - ...childResults, - ]; - } else { - newItems = await reFetchItems(); - } - - if (newItems.length === 0 && items.length === 0) return; - - const currentId = items[selectedIndex]?.id; - let newIndex = currentId - ? newItems.findIndex(item => item.id === currentId) - : -1; - if (newIndex < 0) newIndex = 0; - - items.length = 0; - items.push(...newItems); - selectedIndex = newIndex; - - const item = items[selectedIndex]; - if (item) { - lastSelectionId = item.id; - onSelectionChange(item); - } - - // Keep the "top N of M" count fresh: the total actionable count is - // re-fetched on every auto-refresh so M does not go stale in long - // sessions. Uses the module default runWl (this closure has no - // injected run function). - fetchTotalActionableCount().then((count) => { - currentTotalCount = count; - }).catch(() => { - // ignore; keep the previous count on fetch failure - }); - - invalidateCache(); - tui.requestRender(); - } catch { - // Silently ignore refresh errors - } - }, 5000); - } - - const _done = (value: WorklogBrowseItem | ShortcutResult | null) => { - if (refreshInterval !== undefined) { - clearInterval(refreshInterval); - refreshInterval = undefined; - } - // Save current navigation state before resolving - if (selectionState) { - selectionState.currentItems = [...items]; - selectionState.selectedIndex = selectedIndex; - selectionState.lastSelectionId = lastSelectionId; - selectionState.navStack = navStack.map(entry => ({ - items: [...entry.items], - selectedIndex: entry.selectedIndex, - lastSelectionId: entry.lastSelectionId, - })); - } - done(value); - }; - - const moveSelection = (nextIndex: number) => { - if (items.length === 0) return; - if (nextIndex < 0) { - nextIndex = items.length - 1; - } else if (nextIndex >= items.length) { - nextIndex = 0; - } - if (nextIndex === selectedIndex) return; - selectedIndex = nextIndex; - invalidateCache(); - const item = items[selectedIndex]; - if (item && item.id !== lastSelectionId) { - lastSelectionId = item.id; - onSelectionChange(item); - } - }; - - // ── Hierarchical navigation stack ────────────────────────────── - interface NavStackEntry { - items: WorklogBrowseItem[]; - selectedIndex: number; - lastSelectionId: string | undefined; - } - const navStack: NavStackEntry[] = []; - let isLoadingChildren = false; - - // ── Restore navigation state if available (from loop restart) ── - if (selectionState) { - if (selectionState.selectedIndex >= 0 && selectionState.selectedIndex < items.length) { - selectedIndex = selectionState.selectedIndex; - } - if (selectionState.lastSelectionId !== undefined) { - lastSelectionId = selectionState.lastSelectionId; - } - // Restore nav stack (deep copy of saved entries) - for (const entry of selectionState.navStack) { - navStack.push({ - items: [...entry.items], - selectedIndex: entry.selectedIndex, - lastSelectionId: entry.lastSelectionId, - }); - } - // Mark state as consumed - selectionState.currentItems = []; - } - - const formatEntryLabel = (e: ShortcutEntry): string => { - const label = e.label ?? e.command - .replace(/<[^>]+>/g, '') - .split(/\r?\n/)[0] - .trim() - .replace(/^\/(skill:)?/, ''); - const chord = (e as Record<string, unknown>).chord; - if (Array.isArray(chord) && chord.length >= 2) { - const leaderKey = (chord as string[])[0]; - const firstWord = label.split(/\s+/)[0]; - return `${leaderKey}:${firstWord}...`; - } - return `${e.key}:${label}`; - }; - - return { - render: (width: number) => { - if (cachedLines && cachedWidth === width) { - return cachedLines; - } - - // "top N of M": N = actual displayed count (may exceed browseItemCount - // when the mandatory critical/completed-in_review set is shown). - const displayedCount = items.length; - - const isEmpty = items.length === 0; - const title = isEmpty - ? truncateToWidth(theme.fg('accent', theme.bold('No work items to browse')), width) - : (() => { - const titleSuffix = currentTotalCount !== undefined - ? ` (top ${displayedCount} of ${currentTotalCount})` - : ` (top ${displayedCount})`; - return truncateToWidth(theme.fg('accent', theme.bold(`Browse Worklog next items${titleSuffix}`)), width); - })(); - - let helpText = ''; - if (shortcutRegistry) { - const selectedStage = items[selectedIndex]?.stage; - - if (pendingChord.length > 0) { - const chords = shortcutRegistry.getChordByPrefix(pendingChord, 'list', selectedStage); - if (chords.length > 0) { - const hints = formatChordHints(chords, pendingChord, { isEmpty }); - if (hints.length > 0) { - helpText = `\uD83D\uDD17 ${hints}`; - } - } - } else { - const relevantEntries = shortcutRegistry - .getEntriesForStage(selectedStage) - .filter(e => e.view === 'list' || e.view === 'both') - .filter(e => { - if (isEmpty && e.command.includes('<id>')) return false; - return true; - }); - if (relevantEntries.length > 0) { - const seenChordLeaders = new Set<string>(); - helpText = relevantEntries - .filter(e => { - const chord = (e as Record<string, unknown>).chord; - if (Array.isArray(chord) && chord.length >= 2) { - const leader = (chord as string[])[0]; - if (seenChordLeaders.has(leader)) return false; - seenChordLeaders.add(leader); - } - return true; - }) - .map(e => formatEntryLabel(e)) - .join(' '); - } - } - } - // Append Tab hint when the selected item has children - if (items[selectedIndex] && items[selectedIndex].childCount !== undefined && items[selectedIndex].childCount > 0) { - const childrenHint = 'Tab:children'; - helpText = helpText ? `${helpText} ${childrenHint}` : childrenHint; - } - const help = currentSettings.showHelpText - ? truncateToWidth(theme.fg('dim', helpText), width) - : ''; - - const noIcons = !(currentSettings?.showIcons ?? iconsEnabled()); - const maxPrefixWidth = items.reduce( - (max, item) => Math.max(max, visibleWidth(getIconPrefix(item, noIcons))), - 0, - ); - - // Build display rows, inserting group separator lines when the group changes - // between items. Group separators are non-selectable visual breaks. - const displayRows: string[] = []; - if (items.length > 0) { - let lastDisplayedGroup: number | undefined; - for (let index = 0; index < items.length; index++) { - const item = items[index]; - - // Insert group heading when the first item with a group is encountered, - // or when the group changes between consecutive items. - if (item.id !== '..' && item.group !== undefined) { - if (lastDisplayedGroup === undefined || item.group !== lastDisplayedGroup) { - const label = item.groupLabel ?? `Group ${item.group}`; - displayRows.push(theme.fg('dim', theme.bold(`── ${label} ──`))); - } - lastDisplayedGroup = item.group; - } - - const prefix = index === selectedIndex ? theme.fg('accent', '\u203A ') : ' '; - const contentWidth = Math.max(0, width - 2); - const optionLine = item.id === '..' - ? `${prefix}${item.title || '..'}` - : `${prefix}${formatBrowseOption(item, contentWidth, theme, currentSettings, maxPrefixWidth)}`; - displayRows.push(truncateToWidth(optionLine, width)); - } - } else { - displayRows.push(theme.fg('dim', ' No items to display')); - } - - const lines = [title, '', ...displayRows, '', help]; - cachedWidth = width; - cachedLines = lines; - return lines; - }, - invalidate: () => { - invalidateCache(); - }, - handleInput: (data: string) => { - const lookupKey = data.length === 1 ? data : undefined; - - // Idle-gating (WL-0MSB1N0HB0007N6N): every keypress counts as - // interaction. The FIRST keypress after an idle pause triggers an - // immediate refresh and resumes the normal cadence. - const wasIdle = isIdle(); - lastInteractionAt = Date.now(); - if (wasIdle && reFetchItems) { - // Resume: refresh now, then the interval resumes its normal cadence. - void (async () => { - try { - const newItems = await reFetchItems(); - if (newItems.length === 0 && items.length === 0) return; - items.length = 0; - items.push(...newItems); - selectedIndex = 0; - const item = items[selectedIndex]; - if (item) { - lastSelectionId = item.id; - onSelectionChange(item); - } - invalidateCache(); - tui.requestRender(); - } catch { - // Silently ignore refresh errors - } - })(); - } - - // ── Pending chord state ──────────────────────────────────── - if (pendingChord.length > 0 && lookupKey) { - if (isEscapeKey(data)) { - pendingChord = []; - invalidateCache(); - tui.requestRender(); - return; - } - const selectedStage = items[selectedIndex]?.stage; - const fullChord = [...pendingChord, lookupKey]; - const chordCommand = shortcutRegistry!.lookupChord( - fullChord, - 'list', - selectedStage, - ); - if (chordCommand) { - pendingChord = []; - if (chordCommand.includes('<id>')) { - const chordTarget = items[selectedIndex]; - if (!chordTarget) return; - _done({ - type: 'shortcut' as const, - command: chordCommand.replace(/<id>/g, chordTarget.id), - }); - } else { - _done({ type: 'shortcut' as const, command: chordCommand }); - } - return; - } - // Check if the extended chord is a valid prefix for deeper chords - const prefixMatches = shortcutRegistry!.getChordByPrefix(fullChord, 'list', selectedStage); - if (prefixMatches.length > 0) { - pendingChord = fullChord; - invalidateCache(); - tui.requestRender(); - return; - } - pendingChord = []; - invalidateCache(); - tui.requestRender(); - return; - } - - // ── Normal input ─────────────────────────────────────────── - if (lookupKey && !RESERVED_NAVIGATION_KEYS.has(lookupKey) && shortcutRegistry) { - const selectedStage = items[selectedIndex]?.stage; - - const command = shortcutRegistry.lookup(lookupKey, 'list', selectedStage); - if (command) { - if (command.includes('<id>')) { - const shortcutTarget = items[selectedIndex]; - if (!shortcutTarget) return; - _done({ type: 'shortcut' as const, command: command.replace(/<id>/g, shortcutTarget.id) }); - } else { - _done({ type: 'shortcut' as const, command }); - } - return; - } - - const chordPrefixMatches = shortcutRegistry.getChordByPrefix([lookupKey], 'list', selectedStage) - .filter(c => !(items.length === 0 && c.command.includes('<id>'))); - if (chordPrefixMatches.length > 0) { - pendingChord = [lookupKey]; - invalidateCache(); - tui.requestRender(); - return; - } - } - - if (isUpKey(data)) { - moveSelection(selectedIndex - 1); - tui.requestRender(); - return; - } - - if (isDownKey(data)) { - moveSelection(selectedIndex + 1); - tui.requestRender(); - return; - } - - if (isEnterKey(data) || isTabKey(data)) { - const selected = items[selectedIndex]; - if (!selected) { - _done(null); - return; - } - - if (selected.id === '..') { - const parentState = navStack.pop(); - if (parentState) { - items.length = 0; - items.push(...parentState.items); - selectedIndex = parentState.selectedIndex; - lastSelectionId = parentState.lastSelectionId; - - const restoredItem = items[selectedIndex]; - if (restoredItem && restoredItem.id !== lastSelectionId) { - lastSelectionId = restoredItem.id; - onSelectionChange(restoredItem); - } - - invalidateCache(); - tui.requestRender(); - } - return; - } - - // Tab on a parent item → navigate into children - // Enter on any item (including parents) → open detail view - if ( - isTabKey(data) - && selected.childCount !== undefined - && selected.childCount > 0 - && fetchChildren - && !isLoadingChildren - ) { - navStack.push({ - items: [...items], - selectedIndex, - lastSelectionId, - }); - - isLoadingChildren = true; - - fetchChildren(selected.id) - .then(childItems => { - isLoadingChildren = false; - - const parentEntry: WorklogBrowseItem = { - id: '..', - title: '..', - status: 'open', - }; - - items.length = 0; - items.push(parentEntry, ...childItems); - selectedIndex = 0; - lastSelectionId = items[0]?.id; - - if (items[0]) { - onSelectionChange(items[0]); - } - - invalidateCache(); - tui.requestRender(); - }) - .catch(() => { - isLoadingChildren = false; - navStack.pop(); - ctx.ui.notify('Failed to fetch children.', 'warning'); - invalidateCache(); - tui.requestRender(); - }); - - return; - } - - _done(selected); - return; - } - - if (isEscapeKey(data)) { - if (pendingChord.length > 0) { - pendingChord = []; - invalidateCache(); - tui.requestRender(); - return; - } - - if (navStack.length > 0) { - const parentState = navStack.pop()!; - items.length = 0; - items.push(...parentState.items); - selectedIndex = parentState.selectedIndex; - lastSelectionId = parentState.lastSelectionId; - - const restoredItem = items[selectedIndex]; - if (restoredItem && restoredItem.id !== lastSelectionId) { - lastSelectionId = restoredItem.id; - onSelectionChange(restoredItem); - } - - invalidateCache(); - tui.requestRender(); - return; - } - - _done(null); - } - }, - }; - }); - - return result ?? undefined; -} - -// ── Scrollable detail view widget ───────────────────────────────────── - -/** - * Create a scrollable widget factory for rendering work item details. - */ -export function createScrollableWidget( - contentLines: string[], -): (tui: any, theme: any) => { - render: (width: number) => string[]; - invalidate: () => void; - handleInput: (data: string) => void; -} { - return (tui: any, _theme: any) => { - let offset = 0; - let lastWrappedLines: string[] = []; - let lastViewport = 12; - - const computeViewport = (totalLines: number) => { - try { - const height = - typeof tui?.getHeight === 'function' - ? tui.getHeight() - : tui?.terminal?.rows ?? tui?.height; - if (typeof height === 'number' && height > 8) { - return Math.min(Math.max(3, Math.floor(height - 6)), totalLines); - } - } catch (_) { - // ignore - } - return Math.max(12, totalLines); - }; - - const render = (width: number) => { - lastWrappedLines = contentLines.flatMap( - line => wrapToTerminalWidth(line, width), - ); - lastViewport = computeViewport(lastWrappedLines.length); - const start = Math.min( - Math.max(0, offset), - Math.max(0, lastWrappedLines.length - lastViewport), - ); - const end = Math.min(lastWrappedLines.length, start + lastViewport); - offset = start; - return lastWrappedLines.slice(start, end); - }; - - const invalidate = () => { - try { tui?.requestRender?.(); } catch (_) {} - }; - - const handleInput = (data: string) => { - const totalLines = lastWrappedLines.length || contentLines.length; - const vp = lastViewport; - - if (isUpKey(data)) { - offset = Math.max(0, offset - 1); - invalidate(); - return; - } - - if (isDownKey(data)) { - offset = Math.min(Math.max(0, totalLines - 1), offset + 1); - invalidate(); - return; - } - - if (isPageUpKey(data)) { - offset = Math.max(0, offset - vp); - invalidate(); - return; - } - - if (isPageDownKey(data)) { - offset = Math.min(Math.max(0, totalLines - 1), offset + vp); - invalidate(); - return; - } - - if (data === 'g') { - offset = 0; - invalidate(); - return; - } - - if (data === 'G') { - offset = Math.max(0, totalLines - vp); - invalidate(); - return; - } - }; - - return { render, invalidate, handleInput }; - }; -} - -// ── Browse flow orchestrator ─────────────────────────────────────────── - -export interface BrowseFlowOptions { - listWorkItems: () => Promise<WorklogBrowseItem[]>; - listWorkItemsWithStage: (stage: string) => Promise<WorklogBrowseItem[]>; - runWlImpl: RunWlFn; - shortcutRegistry: ShortcutRegistry; - /** Optional injected chooseWorkItem (for tests). Falls back to defaultChooseWorkItem. */ - chooseWorkItem?: ChooseWorkItemFn; -} - -/** - * Run the browse flow: fetch items, show selection widget, handle results. - * - * Extracted from createWorklogBrowseExtension to keep index.ts thin. - */ -export async function runBrowseFlow( - ctx: BrowseContext, - options: BrowseFlowOptions, - stage?: string, -): Promise<void> { - const { listWorkItems, listWorkItemsWithStage, runWlImpl, shortcutRegistry, chooseWorkItem } = options; - - try { - let lastAnnouncedId: string | undefined; - const announceSelection: SelectionChangeHandler = ( - item: WorklogBrowseItem, - ) => { - lastAnnouncedId = item.id; - ctx.ui.setWidget?.('worklog-browse-selection', buildSelectionWidget(item, currentSettings), { placement: 'belowEditor' }); - }; - - const reFetchItems = stage - ? () => listWorkItemsWithStage(stage) - : () => listWorkItems(); - - const fetchChildren = async (parentId: string): Promise<WorklogBrowseItem[]> => { - const output = await runWlImpl(['list', '--parent', parentId]); - const payload = extractJsonObject(output); - return normalizeListPayload(payload); - }; - - const totalActionableCount = await fetchTotalActionableCount(runWlImpl); - - // ── Preserved selection state for hierarchy restoration ───────── - // When the user drills into children and opens a detail view, the - // selection state (items, navStack) is captured so the loop can - // restore the same hierarchy level when Escape closes the detail. - const selectionState: BrowseSelectionState = { - currentItems: [], - selectedIndex: 0, - lastSelectionId: undefined, - navStack: [], - }; - - // When Tab is pressed in the detail view on a parent item, store the - // parent id here so the loop can navigate to children on restart. - let detailTabNavigationParentId: string | null = null; - - // ── Browse loop: selection list → detail → selection list → … ── - while (true) { - // Check if we have preserved items from a previous loop iteration - // (e.g. user was in a child hierarchy and pressed Escape in detail). - const hasPreservedItems = selectionState.currentItems.length > 0; - - const items = hasPreservedItems - ? (() => { - const restored = selectionState.currentItems; - selectionState.currentItems = []; // Consume once - return restored; - })() - : stage - ? (await listWorkItemsWithStage(stage)) - : (await listWorkItems()); - - if (items[0]) { - announceSelection(items[0]); - } - - let result: WorklogBrowseItem | ShortcutResult | undefined; - if (chooseWorkItem) { - result = await chooseWorkItem(items, ctx, announceSelection); - } else { - result = await defaultChooseWorkItem(items, ctx, announceSelection, shortcutRegistry, reFetchItems, fetchChildren, totalActionableCount, selectionState); - } - - if (result && 'type' in result && result.type === 'shortcut') { - ctx.ui.setEditorText?.(result.command); - ctx.ui.setWidget?.('worklog-browse-selection', undefined); - return; - } - - const selectedItem = result as WorklogBrowseItem | undefined; - - if (!selectedItem) { - ctx.ui.setWidget?.('worklog-browse-selection', undefined); - return; - } - - announceSelection(selectedItem); - - if (!ctx.ui.custom) { - ctx.ui.notify('Scrollable detail view requires a TUI that supports custom overlays.', 'warning'); - return; - } - - try { - const mdOutput = await runWlImpl(['show', selectedItem.id, '--format', 'markdown', '--no-icons'], false); - const cleanOutput = mdOutput.replace(/\{[^}]*\}/g, ''); - const detailLines = cleanOutput.split(/\r?\n/); - - let detailPendingChord: string[] = []; - const detailResult = await ctx.ui.custom<ShortcutResult | string | null>( - (tui, _theme, _keybindings, done) => { - const factory = createScrollableWidget(detailLines); - const widget = factory(tui, _theme); - - return { - render: (width: number) => { - const lines = widget.render(width); - - // ── Shortcut hints ────────────────────────────────────────── - if (currentSettings.showHelpText) { - let helpText = ''; - const formatHint = (e: ShortcutEntry): string => { - const label = e.label ?? e.command - .replace(/<[^>]+>/g, '') - .split(/\r?\n/)[0] - .trim() - .replace(/^\/(skill:)?/, ''); - const chord = (e as Record<string, unknown>).chord; - if (Array.isArray(chord) && chord.length >= 2) { - const leaderKey = (chord as string[])[0]; - const firstWord = label.split(/\s+/)[0]; - return `${leaderKey}:${firstWord}...`; - } - return `${e.key}:${label}`; - }; - - if (detailPendingChord.length > 0) { - const chords = shortcutRegistry.getChordByPrefix(detailPendingChord, 'detail', selectedItem.stage); - if (chords.length > 0) { - const hints = formatChordHints(chords, detailPendingChord); - if (hints.length > 0) { - helpText = `\uD83D\uDD17 ${hints}`; - } - } - } else { - const relevantEntries = shortcutRegistry - .getEntriesForStage(selectedItem.stage) - .filter(e => e.view === 'detail' || e.view === 'both'); - if (relevantEntries.length > 0) { - const seenChordLeaders = new Set<string>(); - helpText = relevantEntries - .filter(e => { - const chord = (e as Record<string, unknown>).chord; - if (Array.isArray(chord) && chord.length >= 2) { - const leader = (chord as string[])[0]; - if (seenChordLeaders.has(leader)) return false; - seenChordLeaders.add(leader); - } - return true; - }) - .map(e => formatHint(e)) - .join(' '); - } - } - // Append Tab:children hint when the item has children - if (selectedItem.childCount !== undefined && selectedItem.childCount > 0) { - const tabHint = 'Tab:children'; - helpText = helpText ? `${helpText} ${tabHint}` : tabHint; - } - if (helpText) { - return [...lines, '', _theme.fg('dim', truncateToWidth(helpText, width))]; - } - } - - return lines; - }, - invalidate: () => widget.invalidate(), - handleInput: (data: string) => { - const lookupKey = data.length === 1 ? data : undefined; - - if (detailPendingChord.length > 0 && lookupKey) { - if (isEscapeKey(data)) { - detailPendingChord = []; - tui.requestRender(); - return; - } - const fullChord = [...detailPendingChord, lookupKey]; - const chordCommand = shortcutRegistry.lookupChord( - fullChord, - 'detail', - selectedItem.stage, - ); - if (chordCommand) { - detailPendingChord = []; - done({ - type: 'shortcut' as const, - command: chordCommand.replace(/<id>/g, selectedItem.id), - }); - return; - } - // Check if the extended chord is a valid prefix for deeper chords - const prefixMatches = shortcutRegistry.getChordByPrefix(fullChord, 'detail', selectedItem.stage); - if (prefixMatches.length > 0) { - detailPendingChord = fullChord; - tui.requestRender(); - return; - } - detailPendingChord = []; - tui.requestRender(); - return; - } - - if (lookupKey && !RESERVED_NAVIGATION_KEYS.has(lookupKey)) { - const command = shortcutRegistry.lookup(lookupKey, 'detail', selectedItem.stage); - if (command) { - done({ type: 'shortcut' as const, command: command.replace(/<id>/g, selectedItem.id) }); - return; - } - - const chordPrefixMatches = shortcutRegistry.getChordByPrefix([lookupKey], 'detail', selectedItem.stage); - if (chordPrefixMatches.length > 0) { - detailPendingChord = [lookupKey]; - tui.requestRender(); - return; - } - } - - if (isTabKey(data) && selectedItem.childCount !== undefined && selectedItem.childCount > 0) { - // Tab on a parent item → navigate to children - detailTabNavigationParentId = selectedItem.id; - ctx.ui.setWidget?.('worklog-browse-selection', undefined); - done(null); - return; - } - - if (isEscapeKey(data)) { - if (detailPendingChord.length === 0) { - ctx.ui.setWidget?.('worklog-browse-selection', undefined); - done(null); - return; - } - detailPendingChord = []; - tui.requestRender(); - return; - } - widget.handleInput(data); - tui.requestRender(); - }, - }; - }, - ).catch(() => null); - - if (detailResult && typeof detailResult === 'object' && detailResult.type === 'shortcut') { - ctx.ui.setEditorText?.(detailResult.command); - ctx.ui.setWidget?.('worklog-browse-selection', undefined); - return; - } - - // detailResult is null — loop back to selection list - // Check if Tab was pressed in detail view to navigate into children - if (detailTabNavigationParentId !== null && fetchChildren && selectedItem.childCount !== undefined && selectedItem.childCount > 0) { - const parentId = detailTabNavigationParentId; - detailTabNavigationParentId = null; - try { - const childItems = await fetchChildren(parentId); - const parentEntry: WorklogBrowseItem = { id: '..', title: '..', status: 'open' }; - selectionState.currentItems = [parentEntry, ...childItems]; - selectionState.selectedIndex = 0; - selectionState.lastSelectionId = undefined; - selectionState.navStack = [{ - items: items, - selectedIndex: items.findIndex(i => i.id === parentId), - lastSelectionId: parentId, - }]; - } catch { - ctx.ui.notify('Failed to fetch children from detail view.', 'warning'); - } - } - } catch (innerErr) { - const message = innerErr instanceof Error ? innerErr.message : String(innerErr); - ctx.ui.notify(`Failed to render work item details: ${message}`, 'error'); - // On error, also loop back to selection list - } - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - ctx.ui.notify(`Failed to browse work items: ${message}`, 'error'); - } -} diff --git a/packages/tui/extensions/Worklog/lib/grouping.test.ts b/packages/tui/extensions/Worklog/lib/grouping.test.ts deleted file mode 100644 index 5d1a9ce11..000000000 --- a/packages/tui/extensions/Worklog/lib/grouping.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Unit tests for lib/grouping.ts — the duplicated grouping algorithm applied - * to the merged (selected) browse list in the Pi TUI Worklog extension. - * - * Mirrors the Herdr plugin grouping suite (decision Q2c in - * WL-0MS8W5LTW006YZ4B); covers the merged-list regression for the duplicate - * "In Review" sections bug (WL-0MSAK8YLB0025EGW). - * - * Run: npx vitest run packages/tui/extensions/Worklog/lib/grouping.test.ts - */ - -import { describe, it, expect } from 'vitest'; -import { - assignItemGroups, - regroupWorkItems, - compareGroupableItems, - extractFilePaths, - type GroupableItem, -} from './grouping.js'; -import type { WorklogBrowseItem } from './tools.js'; - -/** - * Build a minimal WorklogBrowseItem. - */ -function makeItem(id: string, overrides: Partial<WorklogBrowseItem> = {}): WorklogBrowseItem { - return { - id, - title: `Item ${id}`, - status: 'open', - priority: 'medium', - stage: 'idea', - description: `**Key Files:**\n- \`src/${id.toLowerCase()}.ts\``, - ...overrides, - }; -} - -describe('grouping.ts — duplicated algorithm mirrors core spec', () => { - it('produces the canonical group order: Critical Group N → Group N → Idea → Other → In Review', () => { - const items: GroupableItem[] = [ - { id: 'WL-C1', stage: 'plan_complete', filePaths: ['src/c.ts'], priority: 'critical' }, - { id: 'WL-P1', stage: 'plan_complete', filePaths: ['src/p1.ts'], priority: 'high' }, - { id: 'WL-I1', stage: 'intake_complete', filePaths: ['src/i1.ts'], priority: 'medium' }, - { id: 'WL-idea', stage: 'idea', filePaths: [], priority: 'low' }, - { id: 'WL-other', stage: 'in_progress', filePaths: [], priority: 'medium' }, - { id: 'WL-R1', stage: 'in_review', filePaths: [], priority: 'medium' }, - ]; - const groups = assignItemGroups(items, 3); - const groupOf = (id: string): number => groups.get(id)!.group; - expect(groups.get('WL-C1')!.groupLabel).toBe('Critical Group 1'); - expect(groups.get('WL-P1')!.groupLabel).toBe('Group 1'); - expect(groups.get('WL-I1')!.groupLabel).toBe('Group 1'); - expect(groupOf('WL-C1')).toBeLessThan(groupOf('WL-P1')); - expect(groupOf('WL-P1')).toBeLessThan(groupOf('WL-idea')); - expect(groupOf('WL-idea')).toBeLessThan(groupOf('WL-other')); - expect(groupOf('WL-other')).toBeLessThan(groupOf('WL-R1')); - expect(groups.get('WL-R1')!.groupLabel).toBe('In Review'); - }); - - it('partitions critical items into Critical Group N by file-path conflicts', () => { - const items: GroupableItem[] = [ - { id: 'WL-C1', stage: 'plan_complete', filePaths: ['src/foo.ts'], priority: 'critical' }, - { id: 'WL-C2', stage: 'plan_complete', filePaths: ['src/foo.ts'], priority: 'critical' }, - { id: 'WL-C3', stage: 'idea', filePaths: [], priority: 'critical' }, - ]; - const groups = assignItemGroups(items, 3); - expect(groups.get('WL-C1')!.groupLabel).toBe('Critical Group 1'); - expect(groups.get('WL-C2')!.groupLabel).toBe('Critical Group 2'); - expect(groups.get('WL-C3')!.groupLabel).toBe('Critical Group 3'); - for (const [, assignment] of groups) { - expect(assignment.groupLabel).not.toBe('Critical'); - } - }); - - it('sorts within a group by stage sub-order then priority', () => { - const items: GroupableItem[] = [ - { id: 'I-low', stage: 'intake_complete', filePaths: [], priority: 'low' }, - { id: 'P-med', stage: 'plan_complete', filePaths: [], priority: 'medium' }, - { id: 'I-high', stage: 'intake_complete', filePaths: [], priority: 'high' }, - { id: 'P-high', stage: 'plan_complete', filePaths: [], priority: 'high' }, - ]; - const sorted = items.slice().sort(compareGroupableItems); - expect(sorted.map(i => i.id)).toEqual(['P-high', 'P-med', 'I-high', 'I-low']); - }); -}); - -describe('regroupWorkItems — merged-list regression (WL-0MSAK8YLB0025EGW)', () => { - it('assigns group metadata to every displayed item (mandatory wl list items included)', () => { - const merged: WorklogBrowseItem[] = [ - // From wl next — already grouped by the CLI. - makeItem('WL-NEXT-1', { stage: 'plan_complete', priority: 'high', group: 2, groupLabel: 'Group 1' }), - makeItem('WL-NEXT-2', { stage: 'in_review', priority: 'medium', status: 'in-progress', group: 5, groupLabel: 'In Review' }), - // From wl list (mandatory subset) — NO group metadata. - makeItem('WL-LIST-CRIT', { stage: 'intake_complete', priority: 'critical' }), - makeItem('WL-LIST-REV', { stage: 'in_review', priority: 'medium', status: 'completed' }), - ]; - - const regrouped = regroupWorkItems(merged, 3); - - for (const item of regrouped) { - expect(item.group).toBeDefined(); - expect(item.groupLabel).toBeDefined(); - } - - const inReviewItems = regrouped.filter(i => i.id === 'WL-NEXT-2' || i.id === 'WL-LIST-REV'); - expect(inReviewItems.every(i => i.groupLabel === 'In Review')).toBe(true); - expect(inReviewItems[0].group).toBe(inReviewItems[1].group); - expect(new Set(regrouped.filter(i => i.groupLabel === 'In Review').map(i => i.group)).size).toBe(1); - }); - - it('regression: merged list renders exactly one In Review section, positioned after Other', () => { - const merged: WorklogBrowseItem[] = [ - makeItem('WL-NEXT-PLAN', { stage: 'plan_complete', priority: 'high', group: 2, groupLabel: 'Group 1' }), - makeItem('WL-NEXT-REVIEW', { stage: 'in_review', priority: 'medium', status: 'in-progress', group: 5, groupLabel: 'In Review' }), - makeItem('WL-NEXT-OTHER', { stage: 'in_progress', priority: 'medium', group: 3, groupLabel: 'Other' }), - makeItem('WL-LIST-CRIT', { stage: 'plan_complete', priority: 'critical' }), - makeItem('WL-LIST-REV', { stage: 'in_review', priority: 'medium', status: 'completed' }), - ]; - - const regrouped = regroupWorkItems(merged, 3); - - // Simulate the browse renderer: separator lines emitted when the group - // number changes between consecutive items. - const renderedSections: string[] = []; - let lastGroup: number | undefined; - for (const item of regrouped) { - if (item.group !== undefined) { - if (lastGroup === undefined || item.group !== lastGroup) { - renderedSections.push(item.groupLabel!); - } - lastGroup = item.group; - } - } - - expect(renderedSections.filter(s => s === 'In Review').length).toBe(1); - expect(renderedSections[renderedSections.length - 1]).toBe('In Review'); - const otherIndex = renderedSections.indexOf('Other'); - expect(otherIndex).toBeGreaterThan(-1); - expect(renderedSections.indexOf('In Review')).toBeGreaterThan(otherIndex); - expect(new Set(renderedSections).size).toBe(renderedSections.length); - }); - - it('preserves every item across regroup (no filtering) — mandatory guarantee intact', () => { - const merged: WorklogBrowseItem[] = [ - makeItem('WL-A', { stage: 'plan_complete', priority: 'high' }), - makeItem('WL-B', { stage: 'in_review', priority: 'medium', status: 'completed' }), - makeItem('WL-C', { stage: 'idea', priority: 'low' }), - ]; - const regrouped = regroupWorkItems(merged, 3); - expect(regrouped.map(i => i.id).sort()).toEqual(['WL-A', 'WL-B', 'WL-C']); - }); - - it('orders the regrouped list by group then within-group order', () => { - const merged: WorklogBrowseItem[] = [ - makeItem('WL-I-low', { stage: 'intake_complete', priority: 'low' }), - makeItem('WL-P-med', { stage: 'plan_complete', priority: 'medium' }), - makeItem('WL-idea', { stage: 'idea', priority: 'low' }), - makeItem('WL-R', { stage: 'in_review', priority: 'medium' }), - ]; - const regrouped = regroupWorkItems(merged, 3); - const order = regrouped.map(i => i.id); - expect(order.indexOf('WL-P-med')).toBeLessThan(order.indexOf('WL-I-low')); - expect(order.indexOf('WL-I-low')).toBeLessThan(order.indexOf('WL-idea')); - expect(order.indexOf('WL-idea')).toBeLessThan(order.indexOf('WL-R')); - }); -}); - -describe('extractFilePaths (duplicated)', () => { - it('extracts paths from a **Key Files:** section', () => { - const description = `## Summary\nDo the thing.\n\n**Key Files:**\n- \`src/commands/next.ts\`\n- \`docs/CLI.md\``; - expect(extractFilePaths(description)).toEqual(['src/commands/next.ts', 'docs/CLI.md']); - }); - - it('returns an empty array when no Key Files section exists', () => { - expect(extractFilePaths('Just a description.')).toEqual([]); - }); -}); diff --git a/packages/tui/extensions/Worklog/lib/grouping.ts b/packages/tui/extensions/Worklog/lib/grouping.ts deleted file mode 100644 index 0882a4d10..000000000 --- a/packages/tui/extensions/Worklog/lib/grouping.ts +++ /dev/null @@ -1,379 +0,0 @@ -/** - * lib/grouping.ts — Grouping for the Pi TUI Worklog extension browse list - * - * Pure, dependency-free duplication of the core `wl next --groups` grouping - * algorithm (src/commands/grouping.ts) for the Pi TUI Worklog extension, - * which cannot import from src/commands/ or the Herdr plugin. - * - * The functions here are intentionally duplicated per TUI (decision Q2c in - * WL-0MS8W5LTW006YZ4B): a copy lives in this extension and another in the - * Herdr plugin (packages/herdr/src/grouping.ts). Keep the two copies and the - * core implementation in sync. - * - * Why regrouping is needed: the browse list merges `wl next` results (which - * carry `group`/`groupLabel` assignments) with mandatory subsets fetched via - * `wl list` (critical + completed/in_review items, which carry NO group - * metadata). `selectWorkItems` then reorders the merged list, breaking group - * adjacency. `regroupWorkItems` recomputes group assignments for the final - * selected set so every displayed item gets a correct group and no duplicate - * section headings render (WL-0MSAK8YLB0025EGW). - */ - -// ── File path extraction ────────────────────────────────────────────── - -/** - * Extract file paths from a work item description. - * - * Looks for a "Key Files" / "Key Files:" section (case-insensitive, with or - * without bold markers or a trailing colon) and extracts path-like strings - * from subsequent bullet list items. Mirrors `extractFilePaths` in - * src/commands/helpers.ts; see docs/FILE_PATH_CONVENTION.md. - */ -export function extractFilePaths(description: string): string[] { - if (!description || description.trim().length === 0) { - return []; - } - - const paths: string[] = []; - - // Match the "Key Files:" header (case-insensitive, optional bold markers). - const keyFilesRegex = /^#{0,3}\s*\*{0,2}key files:?\*{0,2}\s*$/im; - const match = description.match(keyFilesRegex); - - if (!match) { - return []; - } - - const headerIndex = match.index!; - const afterHeader = description.slice(headerIndex + match[0].length); - - const lines = afterHeader.split('\n'); - for (const line of lines) { - const trimmed = line.trim(); - - // Stop if we hit another Markdown heading. - if (/^#{1,3}\s/.test(trimmed)) { - break; - } - - // Stop if we hit another bold section header (e.g., **Some Section:**). - if (/^\*{1,2}\w.*:\*{0,2}\s*$/.test(trimmed) && !/^[-*]\s/.test(trimmed)) { - break; - } - - // Stop if we hit another "Key Files:" header (case-insensitive). - if (/\*{0,2}key files:?\*{0,2}\s*$/i.test(trimmed) && !/^[-*]\s/.test(trimmed)) { - break; - } - - // Match bullet items: `- ` or `* ` prefix, backtick-wrapped path first. - let pathCandidate: string | null = null; - const backtickMatch = trimmed.match(/^[-*]\s+`([^`]+)`/); - if (backtickMatch) { - pathCandidate = backtickMatch[1].trim(); - } else { - const plainMatch = trimmed.match(/^[-*]\s+([^\s]+)/); - if (plainMatch) { - pathCandidate = plainMatch[1].trim(); - } - } - - if (!pathCandidate) continue; - - if (isFilePath(pathCandidate)) { - paths.push(pathCandidate); - } - } - - return paths; -} - -/** - * Check if a string looks like a valid file path (contains `/` and ends with - * a file extension; rejects URLs). - */ -function isFilePath(candidate: string): boolean { - if (/^https?:\/\//i.test(candidate)) return false; - if (!candidate.includes('/')) return false; - const extMatch = candidate.match(/\.([a-zA-Z0-9]+)$/); - if (!extMatch) return false; - return extMatch[1].length >= 1; -} - -// ── Grouping types ──────────────────────────────────────────────────── - -/** - * Input item for grouping — must have an `id`, `stage`, and a list of - * `filePaths`. - */ -export interface GroupableItem { - id: string; - stage?: string; - filePaths: string[]; - priority?: string; -} - -/** - * Result of assigning an item to a group. - * `group` is a 1-indexed integer for ordering. - * `groupLabel` is a human-readable label for display. - */ -export interface GroupAssignment { - group: number; - groupLabel: string; -} - -// ── Grouping algorithm ──────────────────────────────────────────────── - -/** - * Greedy first-fit grouping algorithm for file-path-based conflict detection. - * - * Assigns each item to the first group (1-indexed) that contains no item - * sharing any file path with it. Items with empty file paths (unknown) are - * each placed in their own singleton "restricted" group. - * - * Mirrors `groupItemsByFilePaths` in src/commands/grouping.ts. - */ -export function groupItemsByFilePaths( - items: GroupableItem[], - maxGroups: number = 3, -): Map<string, number> { - const itemGroup = new Map<string, number>(); - const groupPaths = new Map<number, Set<string>>(); - const restrictedGroups = new Set<number>(); - let nextGroup = 1; - - for (const item of items) { - const { id, filePaths } = item; - - // Items with unknown file paths → singleton restricted group. - if (filePaths.length === 0) { - itemGroup.set(id, nextGroup); - groupPaths.set(nextGroup, new Set()); - restrictedGroups.add(nextGroup); - nextGroup++; - continue; - } - - // Try to find an existing group this item fits in. - let assigned = false; - const groupsToCheck = Math.min(maxGroups, nextGroup - 1); - for (let g = 1; g <= groupsToCheck; g++) { - const existingPaths = groupPaths.get(g); - if (!existingPaths) continue; - - if (restrictedGroups.has(g)) continue; - - const hasConflict = filePaths.some(fp => existingPaths.has(fp)); - if (!hasConflict) { - for (const fp of filePaths) { - existingPaths.add(fp); - } - itemGroup.set(id, g); - assigned = true; - break; - } - } - - if (!assigned) { - const newGroup = nextGroup; - groupPaths.set(newGroup, new Set(filePaths)); - itemGroup.set(id, newGroup); - nextGroup++; - } - } - - return itemGroup; -} - -/** - * Assign items to groups based on priority, stage and file-path conflicts. - * - * Group display order (most actionable first): - * - **Critical Group N** — critical items partitioned by file-path conflicts - * (per-category label counter, no single all-inclusive "Critical" group). - * - **Group N** — non-critical `plan_complete` + `intake_complete` items - * partitioned by file-path conflicts (no stage prefix in the label). - * - **Idea** — single group for all `idea` items. - * - **Other** — single group for all remaining non-critical items. - * - **In Review** — single group for all `in_review` items, placed last. - * - * Mirrors `assignItemGroups` in src/commands/grouping.ts. - */ -export function assignItemGroups( - items: GroupableItem[], - maxFilePathGroups: number = 3, -): Map<string, GroupAssignment> { - const result = new Map<string, GroupAssignment>(); - - let nextGroup = 1; - - // Remap file-path group numbers to sequential display group numbers - // starting at `startGroup`; returns the mapping and the count consumed. - const remapToSequential = ( - fileGroups: Map<string, number>, - startGroup: number, - ): { map: Map<number, number>; count: number } => { - const unique = [...new Set(fileGroups.values())].sort((a, b) => a - b); - const map = new Map<number, number>(); - unique.forEach((g, i) => map.set(g, startGroup + i)); - return { map, count: unique.length }; - }; - - // 0. Critical Group N. - const criticalItems = items.filter(item => item.priority === 'critical'); - if (criticalItems.length > 0) { - const criticalGroups = groupItemsByFilePaths(criticalItems, maxFilePathGroups); - const { map: groupNumMap, count } = remapToSequential(criticalGroups, nextGroup); - const labelNumByFileGroup = buildLabelCounter(criticalGroups); - for (const [id, g] of criticalGroups) { - const groupNum = groupNumMap.get(g)!; - result.set(id, { group: groupNum, groupLabel: `Critical Group ${labelNumByFileGroup.get(g)}` }); - } - nextGroup += count; - } - - // 1. Group N (plan_complete + intake_complete). - const planIntakeItems = items.filter( - item => item.priority !== 'critical' && (item.stage === 'plan_complete' || item.stage === 'intake_complete'), - ); - if (planIntakeItems.length > 0) { - const planIntakeGroups = groupItemsByFilePaths(planIntakeItems, maxFilePathGroups); - const { map: groupNumMap, count } = remapToSequential(planIntakeGroups, nextGroup); - const labelNumByFileGroup = buildLabelCounter(planIntakeGroups); - for (const [id, g] of planIntakeGroups) { - const groupNum = groupNumMap.get(g)!; - result.set(id, { group: groupNum, groupLabel: `Group ${labelNumByFileGroup.get(g)}` }); - } - nextGroup += count; - } - - // 2. Idea. - const ideaItems = items.filter(item => item.priority !== 'critical' && item.stage === 'idea'); - if (ideaItems.length > 0) { - for (const item of ideaItems) { - result.set(item.id, { group: nextGroup, groupLabel: 'Idea' }); - } - nextGroup++; - } - - // 3. Other. - const otherItems = items.filter( - item => - item.priority !== 'critical' && - item.stage !== 'in_review' && - item.stage !== 'plan_complete' && - item.stage !== 'intake_complete' && - item.stage !== 'idea', - ); - if (otherItems.length > 0) { - for (const item of otherItems) { - result.set(item.id, { group: nextGroup, groupLabel: 'Other' }); - } - nextGroup++; - } - - // 4. In Review (last). - const inReviewItems = items.filter(item => item.priority !== 'critical' && item.stage === 'in_review'); - if (inReviewItems.length > 0) { - for (const item of inReviewItems) { - result.set(item.id, { group: nextGroup, groupLabel: 'In Review' }); - } - nextGroup++; - } - - return result; -} - -/** - * Build a per-category label counter mapping file-path group numbers (from - * groupItemsByFilePaths) to sequential category labels (1, 2, ...). - */ -function buildLabelCounter(fileGroups: Map<string, number>): Map<number, number> { - const unique = [...new Set(fileGroups.values())].sort((a, b) => a - b); - const map = new Map<number, number>(); - unique.forEach((g, i) => map.set(g, i + 1)); - return map; -} - -// ── Within-group ordering ───────────────────────────────────────────── - -/** - * Stage sub-order within a group: plan_complete first, then intake_complete, - * then all remaining stages. No headings are rendered between sub-groups. - */ -const WITHIN_GROUP_STAGE_ORDER: Record<string, number> = { - plan_complete: 0, - intake_complete: 1, -}; -const REMAINING_STAGE_ORDER = 2; - -/** - * Priority order for within-group sorting: high → medium → low. - * Unknown priorities are treated as medium. - */ -const WITHIN_GROUP_PRIORITY_ORDER: Record<string, number> = { - high: 0, - medium: 1, - low: 2, -}; -const DEFAULT_PRIORITY_ORDER = WITHIN_GROUP_PRIORITY_ORDER.medium; - -/** - * Compare two items for within-group display order: - * stage sub-sort (plan_complete → intake_complete → remaining stages), then - * priority (high → medium → low), then id as a deterministic tie-break. - */ -export function compareGroupableItems(a: GroupableItem, b: GroupableItem): number { - const stageA = WITHIN_GROUP_STAGE_ORDER[a.stage ?? ''] ?? REMAINING_STAGE_ORDER; - const stageB = WITHIN_GROUP_STAGE_ORDER[b.stage ?? ''] ?? REMAINING_STAGE_ORDER; - if (stageA !== stageB) return stageA - stageB; - const prioA = WITHIN_GROUP_PRIORITY_ORDER[a.priority ?? ''] ?? DEFAULT_PRIORITY_ORDER; - const prioB = WITHIN_GROUP_PRIORITY_ORDER[b.priority ?? ''] ?? DEFAULT_PRIORITY_ORDER; - if (prioA !== prioB) return prioA - prioB; - return a.id.localeCompare(b.id); -} - -// ── Regrouping the merged/selected list ─────────────────────────────── - -/** - * Regroup and order a set of selected work items (e.g. the output of - * selectWorkItems) so that every item receives a `group`/`groupLabel` - * (mandatory wl list items included) and the list renders in the canonical - * group order with no duplicate section headings. - * - * Does not filter or drop items — the always-show-mandatory guarantee of - * selectWorkItems is preserved. Items are only reordered and stamped. - */ -export function regroupWorkItems<T extends { id: string; stage?: string; priority?: string; description?: string; group?: number; groupLabel?: string }>( - items: T[], - maxFilePathGroups: number = 3, -): T[] { - const groupable = items.map(item => ({ - id: item.id, - stage: item.stage, - priority: item.priority, - filePaths: extractFilePaths(item.description ?? ''), - })); - const groupMap = assignItemGroups(groupable, maxFilePathGroups); - - const sorted = items.slice().sort((a, b) => { - const ga = groupMap.get(a.id)?.group ?? Number.MAX_SAFE_INTEGER; - const gb = groupMap.get(b.id)?.group ?? Number.MAX_SAFE_INTEGER; - if (ga !== gb) return ga - gb; - return compareGroupableItems( - { id: a.id, stage: a.stage, priority: a.priority, filePaths: [] }, - { id: b.id, stage: b.stage, priority: b.priority, filePaths: [] }, - ); - }); - - return sorted.map(item => { - const assignment = groupMap.get(item.id); - if (!assignment) return item; - return { - ...item, - group: assignment.group, - groupLabel: assignment.groupLabel, - }; - }); -} diff --git a/packages/tui/extensions/Worklog/lib/scheduler.test.ts b/packages/tui/extensions/Worklog/lib/scheduler.test.ts deleted file mode 100644 index c37c3403d..000000000 --- a/packages/tui/extensions/Worklog/lib/scheduler.test.ts +++ /dev/null @@ -1,566 +0,0 @@ -/** - * Unit tests for lib/scheduler.ts — Periodic request scheduler for the - * Worklog Pi extension. - * - * Tests cover: - * 1. Cron expression parsing and matching - * 2. Schedule entry management (add, remove, toggle, list) - * 3. Scheduler lifecycle (start, tick, stop) - * 4. Idle check before submission - * 5. Duplicate fire prevention - * 6. Configuration persistence - * - * Run: npx vitest run packages/tui/extensions/Worklog/lib/scheduler.test.ts - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; - -// ── Mocks ───────────────────────────────────────────────────────────── - -// Mock the settings module so we can control schedule config in tests -const mockGetSetting = vi.hoisted(() => vi.fn()); -const mockUpdateSetting = vi.hoisted(() => vi.fn()); - -vi.mock('./settings.js', () => ({ - currentSettings: { - get schedules() { return mockGetSetting(); }, - }, - reloadSettings: vi.fn(), -})); - -// Track sendUserMessage calls -const mockSendUserMessage = vi.hoisted(() => vi.fn()); -const mockIsIdle = vi.hoisted(() => vi.fn()); - -function createMockPi(overrides: Record<string, any> = {}): ExtensionAPI { - return { - on: vi.fn(), - sendUserMessage: mockSendUserMessage, - ...overrides, - } as unknown as ExtensionAPI; -} - -describe('cronMatch', () => { - it('should match * * * * * (every minute)', async () => { - const { cronMatch } = await import('./scheduler.js'); - const date = new Date('2026-07-12T15:30:00Z'); - expect(cronMatch('* * * * *', date)).toBe(true); - }); - - it('should match exact minute', async () => { - const { cronMatch } = await import('./scheduler.js'); - // At 15:30, minute=30 should match - const date = new Date('2026-07-12T15:30:00Z'); - expect(cronMatch('30 * * * *', date)).toBe(true); - }); - - it('should not match different minute', async () => { - const { cronMatch } = await import('./scheduler.js'); - const date = new Date('2026-07-12T15:30:00Z'); - expect(cronMatch('0 * * * *', date)).toBe(false); - }); - - it('should match exact hour', async () => { - const { cronMatch } = await import('./scheduler.js'); - const date = new Date('2026-07-12T15:30:00Z'); - expect(cronMatch('* 15 * * *', date)).toBe(true); - }); - - it('should not match different hour', async () => { - const { cronMatch } = await import('./scheduler.js'); - const date = new Date('2026-07-12T15:30:00Z'); - expect(cronMatch('* 14 * * *', date)).toBe(false); - }); - - it('should match exact day of month', async () => { - const { cronMatch } = await import('./scheduler.js'); - const date = new Date('2026-07-12T00:00:00Z'); - expect(cronMatch('* * 12 * *', date)).toBe(true); - }); - - it('should match exact month', async () => { - const { cronMatch } = await import('./scheduler.js'); - const date = new Date('2026-07-12T00:00:00Z'); - expect(cronMatch('* * * 7 *', date)).toBe(true); - }); - - it('should match exact day of week (0=Sunday)', async () => { - const { cronMatch } = await import('./scheduler.js'); - // 2026-07-12 is a Sunday (getUTCDay() returns 0) - const date = new Date('2026-07-12T00:00:00Z'); - expect(cronMatch('* * * * 0', date)).toBe(true); - }); - - it('should match day of week 7 as Sunday', async () => { - const { cronMatch } = await import('./scheduler.js'); - const date = new Date('2026-07-12T00:00:00Z'); - expect(cronMatch('* * * * 7', date)).toBe(true); - }); - - it('should handle comma-separated lists', async () => { - const { cronMatch } = await import('./scheduler.js'); - const date = new Date('2026-07-12T15:30:00Z'); - expect(cronMatch('15,30,45 * * * *', date)).toBe(true); - expect(cronMatch('0,15,45 * * * *', date)).toBe(false); - }); - - it('should handle ranges', async () => { - const { cronMatch } = await import('./scheduler.js'); - const date = new Date('2026-07-12T15:30:00Z'); - expect(cronMatch('15-45 * * * *', date)).toBe(true); - expect(cronMatch('0-15 * * * *', date)).toBe(false); - }); - - it('should handle step values with wildcard', async () => { - const { cronMatch } = await import('./scheduler.js'); - const date = new Date('2026-07-12T15:30:00Z'); - // */15 matches 0,15,30,45 - expect(cronMatch('*/15 * * * *', date)).toBe(true); - expect(cronMatch('*/10 * * * *', date)).toBe(true); // 30 is divisible by 10 - expect(cronMatch('*/31 * * * *', date)).toBe(false); // 30 not divisible by 31 - }); - - it('should handle step values with ranges', async () => { - const { cronMatch } = await import('./scheduler.js'); - const date = new Date('2026-07-12T15:30:00Z'); - // 0-30/10 matches 0,10,20,30 - expect(cronMatch('0-30/10 * * * *', date)).toBe(true); - // 1-31/10 matches 1,11,21,31 — 30 not included - expect(cronMatch('1-31/10 * * * *', date)).toBe(false); - }); - - it('should reject invalid cron expressions', async () => { - const { cronMatch } = await import('./scheduler.js'); - expect(() => cronMatch('invalid', new Date())).toThrow(); - expect(() => cronMatch('* * * *', new Date())).toThrow(); // 4 fields - expect(() => cronMatch('* * * * * *', new Date())).toThrow(); // 6 fields - expect(() => cronMatch('a b c d e', new Date())).toThrow(); - }); - - it('should handle "daily at 1am" pattern (0 1 * * *)', async () => { - const { cronMatch } = await import('./scheduler.js'); - const matching = new Date('2026-07-12T01:00:00Z'); - const nonMatching = new Date('2026-07-12T02:00:00Z'); - expect(cronMatch('0 1 * * *', matching)).toBe(true); - expect(cronMatch('0 1 * * *', nonMatching)).toBe(false); - }); - - it('should handle "every hour" pattern (0 * * * *)', async () => { - const { cronMatch } = await import('./scheduler.js'); - const date0 = new Date('2026-07-12T15:00:00Z'); - const date30 = new Date('2026-07-12T15:30:00Z'); - expect(cronMatch('0 * * * *', date0)).toBe(true); - expect(cronMatch('0 * * * *', date30)).toBe(false); - }); - - it('should handle "weekdays at 9am" pattern (0 9 * * 1-5)', async () => { - const { cronMatch } = await import('./scheduler.js'); - // 2026-07-13 is a Monday - const monday = new Date('2026-07-13T09:00:00Z'); - // 2026-07-12 is a Sunday - const sunday = new Date('2026-07-12T09:00:00Z'); - expect(cronMatch('0 9 * * 1-5', monday)).toBe(true); - expect(cronMatch('0 9 * * 1-5', sunday)).toBe(false); - }); -}); - -describe('parseScheduleEntry', () => { - it('should parse a valid schedule entry config', async () => { - const { parseScheduleEntry } = await import('./scheduler.js'); - const entry = parseScheduleEntry({ - cron: '0 1 * * *', - request: '/skill:audit', - enabled: true, - id: 'sched-1', - }); - expect(entry.cron).toBe('0 1 * * *'); - expect(entry.request).toBe('/skill:audit'); - expect(entry.enabled).toBe(true); - expect(entry.id).toBe('sched-1'); - }); - - it('should default enabled to true if not specified', async () => { - const { parseScheduleEntry } = await import('./scheduler.js'); - const entry = parseScheduleEntry({ - cron: '0 * * * *', - request: 'daily audit', - }); - expect(entry.enabled).toBe(true); - expect(entry.id).toBeDefined(); - }); - - it('should generate an id if not provided', async () => { - const { parseScheduleEntry } = await import('./scheduler.js'); - const entry = parseScheduleEntry({ - cron: '0 * * * *', - request: 'test', - }); - expect(entry.id).toBeDefined(); - expect(typeof entry.id).toBe('string'); - expect(entry.id.length).toBeGreaterThan(0); - }); - - it('should reject invalid cron expressions', async () => { - const { parseScheduleEntry } = await import('./scheduler.js'); - expect(() => parseScheduleEntry({ - cron: 'bad cron', - request: 'test', - })).toThrow(); - }); -}); - -describe('Scheduler', () => { - let mockPi: ExtensionAPI; - let Scheduler: any; - - beforeEach(async () => { - vi.clearAllMocks(); - mockSendUserMessage.mockReset(); - mockIsIdle.mockReset(); - mockGetSetting.mockReset(); - - const mod = await import('./scheduler.js'); - Scheduler = mod.Scheduler; - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should start and stop without errors', () => { - const scheduler = new Scheduler(createMockPi()); - scheduler.start(); - expect(scheduler.isRunning()).toBe(true); - scheduler.stop(); - expect(scheduler.isRunning()).toBe(false); - }); - - it('should not start if already running', () => { - const scheduler = new Scheduler(createMockPi()); - scheduler.start(); - // Clear mocks to verify no additional setup - scheduler.start(); // Should be no-op - expect(scheduler.isRunning()).toBe(true); - scheduler.stop(); - }); - - it('should stop gracefully even if never started', () => { - const scheduler = new Scheduler(createMockPi()); - expect(() => scheduler.stop()).not.toThrow(); - }); - - it('should read schedules from settings on tick', async () => { - mockGetSetting.mockReturnValue([ - { cron: '*/5 * * * *', request: '/skill:audit', enabled: true, id: 'sched-1' }, - ]); - - const scheduler = new Scheduler(createMockPi()); - scheduler.start(); - - // Manually trigger a tick - await scheduler.tick(); - - // Should have checked settings - expect(mockGetSetting).toHaveBeenCalled(); - scheduler.stop(); - }); - - it('should submit request via sendUserMessage when schedule fires and agent is idle', async () => { - mockIsIdle.mockReturnValue(true); - mockGetSetting.mockReturnValue([ - // Match current minute — using * for all fields so it always matches - { cron: '* * * * *', request: 'test request', enabled: true, id: 'sched-1' }, - ]); - - const scheduler = new Scheduler(createMockPi(), { - isIdleFn: mockIsIdle, - }); - - await scheduler.tick(); - - expect(mockSendUserMessage).toHaveBeenCalledWith('test request', { deliverAs: 'steer' }); - }); - - it('should not submit request when agent is not idle', async () => { - mockIsIdle.mockReturnValue(false); - mockGetSetting.mockReturnValue([ - { cron: '* * * * *', request: 'test request', enabled: true, id: 'sched-1' }, - ]); - - const scheduler = new Scheduler(createMockPi(), { - isIdleFn: mockIsIdle, - }); - - await scheduler.tick(); - - expect(mockSendUserMessage).not.toHaveBeenCalled(); - }); - - it('should not submit request for disabled schedules', async () => { - mockIsIdle.mockReturnValue(true); - mockGetSetting.mockReturnValue([ - { cron: '* * * * *', request: 'test request', enabled: false, id: 'sched-1' }, - ]); - - const scheduler = new Scheduler(createMockPi(), { - isIdleFn: mockIsIdle, - }); - - await scheduler.tick(); - - expect(mockSendUserMessage).not.toHaveBeenCalled(); - }); - - it('should not submit request if cron expression does not match current time', async () => { - mockIsIdle.mockReturnValue(true); - mockGetSetting.mockReturnValue([ - // Match a minute that will never match current time (impossible combo) - { cron: '0 0 1 1 0', request: 'test request', enabled: true, id: 'sched-1' }, - ]); - - const scheduler = new Scheduler(createMockPi(), { - isIdleFn: mockIsIdle, - }); - - await scheduler.tick(); - - expect(mockSendUserMessage).not.toHaveBeenCalled(); - }); - - it('should prevent duplicate fires within the same tick', async () => { - mockIsIdle.mockReturnValue(true); - const requestText = 'duplicate test'; - mockGetSetting.mockReturnValue([ - { cron: '* * * * *', request: requestText, enabled: true, id: 'sched-1' }, - ]); - - const scheduler = new Scheduler(createMockPi(), { - isIdleFn: mockIsIdle, - }); - - // Two ticks in the same minute should only fire once - await scheduler.tick(); - await scheduler.tick(); - - expect(mockSendUserMessage).toHaveBeenCalledTimes(1); - expect(mockSendUserMessage).toHaveBeenCalledWith(requestText, { deliverAs: 'steer' }); - }); - - it('should handle empty schedule list gracefully', async () => { - mockGetSetting.mockReturnValue(undefined); - - const scheduler = new Scheduler(createMockPi(), { - isIdleFn: mockIsIdle, - }); - - await expect(scheduler.tick()).resolves.not.toThrow(); - }); - - it('should handle missing schedules setting gracefully', async () => { - mockGetSetting.mockReturnValue(null); - - const scheduler = new Scheduler(createMockPi(), { - isIdleFn: mockIsIdle, - }); - - await expect(scheduler.tick()).resolves.not.toThrow(); - }); - - it('should handle schedule with invalid cron expression gracefully', async () => { - mockIsIdle.mockReturnValue(true); - mockGetSetting.mockReturnValue([ - { cron: 'invalid!!!', request: 'test', enabled: true, id: 'sched-bad' }, - ]); - - const scheduler = new Scheduler(createMockPi(), { - isIdleFn: mockIsIdle, - }); - - // Should not throw — invalid cron expressions should be logged and skipped - await expect(scheduler.tick()).resolves.not.toThrow(); - expect(mockSendUserMessage).not.toHaveBeenCalled(); - }); - - it('should track last fire time per schedule to prevent double fire across ticks', async () => { - mockIsIdle.mockReturnValue(true); - mockGetSetting.mockReturnValue([ - { cron: '* * * * *', request: 'test', enabled: true, id: 'sched-1' }, - ]); - - const scheduler = new Scheduler(createMockPi(), { - isIdleFn: mockIsIdle, - }); - - // First tick: should fire - await scheduler.tick(); - expect(mockSendUserMessage).toHaveBeenCalledTimes(1); - - // Simulate next minute by advancing last-fires but still same minute - // The scheduler should see lastFires has an entry for this minute - await scheduler.tick(); - expect(mockSendUserMessage).toHaveBeenCalledTimes(1); - }); - - it('should fire again in a new minute', async () => { - mockIsIdle.mockReturnValue(true); - - const scheduler = new Scheduler(createMockPi(), { - isIdleFn: mockIsIdle, - }); - - // Set up a schedule - mockGetSetting.mockReturnValue([ - { cron: '* * * * *', request: 'test', enabled: true, id: 'sched-1' }, - ]); - - // First tick — should fire - await scheduler.tick(); - expect(mockSendUserMessage).toHaveBeenCalledTimes(1); - - // Clear last-fires to simulate passing to a new minute - scheduler.clearLastFires(); - - // Second tick — should fire again - await scheduler.tick(); - expect(mockSendUserMessage).toHaveBeenCalledTimes(2); - }); -}); - -describe('scheduleCliCommand', () => { - let scheduleCliCommand: any; - let setSchedulePersister: any; - - beforeEach(async () => { - vi.clearAllMocks(); - mockGetSetting.mockReset(); - mockUpdateSetting.mockReset(); - const mod = await import('./scheduler.js'); - scheduleCliCommand = mod.scheduleCliCommand; - setSchedulePersister = mod.setSchedulePersister; - // Wire the persister to the mock update function - setSchedulePersister((schedules: any) => { - mockUpdateSetting({ schedules }); - }); - }); - - it('should list schedules (empty list)', () => { - mockGetSetting.mockReturnValue([]); - const result = scheduleCliCommand(''); - expect(result).toContain('No'); - }); - - it('should list schedules (with entries)', () => { - mockGetSetting.mockReturnValue([ - { cron: '0 1 * * *', request: 'daily task', enabled: true, id: 'sched-1', label: 'Daily' }, - ]); - const result = scheduleCliCommand('list'); - expect(result).toContain('sched-1'); - expect(result).toContain('0 1 * * *'); - expect(result).toContain('daily task'); - expect(result).toContain('Daily'); - }); - - it('should add a schedule', () => { - mockGetSetting.mockReturnValue([]); - const result = scheduleCliCommand('add "0 1 * * *" "daily audit"'); - expect(result).not.toContain('Error'); - expect(mockUpdateSetting).toHaveBeenCalled(); - }); - - it('should reject add with invalid cron', () => { - mockGetSetting.mockReturnValue([]); - const result = scheduleCliCommand('add "bad" "test"'); - expect(result).toContain('Error'); - expect(mockUpdateSetting).not.toHaveBeenCalled(); - }); - - it('should reject add with missing arguments', () => { - mockGetSetting.mockReturnValue([]); - const result = scheduleCliCommand('add'); - expect(result).toContain('Usage'); - }); - - it('should remove a schedule by id', () => { - mockGetSetting.mockReturnValue([ - { cron: '0 1 * * *', request: 'test', enabled: true, id: 'sched-1' }, - { cron: '0 2 * * *', request: 'test2', enabled: true, id: 'sched-2' }, - ]); - const result = scheduleCliCommand('remove sched-1'); - expect(result).not.toContain('Error'); - // Should have called update with only sched-2 remaining - const updateCall = mockUpdateSetting.mock.calls[0]; - const remainingSchedules = updateCall[0].schedules; - expect(remainingSchedules).toHaveLength(1); - expect(remainingSchedules[0].id).toBe('sched-2'); - }); - - it('should show error when removing non-existent id', () => { - mockGetSetting.mockReturnValue([ - { cron: '0 1 * * *', request: 'test', enabled: true, id: 'sched-1' }, - ]); - const result = scheduleCliCommand('remove non-existent'); - expect(result).toContain('Error'); - expect(result).toContain('not found'); - }); - - it('should toggle a schedule on/off', () => { - mockGetSetting.mockReturnValue([ - { cron: '0 1 * * *', request: 'test', enabled: true, id: 'sched-1' }, - ]); - const result = scheduleCliCommand('toggle sched-1'); - expect(result).not.toContain('Error'); - const updateCall = mockUpdateSetting.mock.calls[0]; - expect(updateCall[0].schedules[0].enabled).toBe(false); - }); - - it('should show error when toggling non-existent id', () => { - mockGetSetting.mockReturnValue([ - { cron: '0 1 * * *', request: 'test', enabled: true, id: 'sched-1' }, - ]); - const result = scheduleCliCommand('toggle bad-id'); - expect(result).toContain('Error'); - expect(result).toContain('not found'); - }); - - it('should show help text for unrecognized subcommand', () => { - const result = scheduleCliCommand('unknown'); - expect(result).toContain('Usage'); - }); - - afterEach(() => { - // Reset the persister to avoid interfering with other test blocks - setSchedulePersister(() => {}); - }); -}); - -describe('validateCronExpression', () => { - it('should return true for valid cron expressions', async () => { - const { validateCronExpression } = await import('./scheduler.js'); - expect(validateCronExpression('* * * * *')).toBe(true); - expect(validateCronExpression('0 1 * * *')).toBe(true); - expect(validateCronExpression('*/15 * * * *')).toBe(true); - expect(validateCronExpression('1-30/10 * * * *')).toBe(true); - expect(validateCronExpression('0 9 * * 1-5')).toBe(true); - expect(validateCronExpression('30 4 1,15 * 0')).toBe(true); - }); - - it('should return false for invalid cron expressions', async () => { - const { validateCronExpression } = await import('./scheduler.js'); - expect(validateCronExpression('')).toBe(false); - expect(validateCronExpression('* * * *')).toBe(false); // 4 fields - expect(validateCronExpression('* * * * * *')).toBe(false); // 6 fields - expect(validateCronExpression('a b c d e')).toBe(false); - expect(validateCronExpression('60 * * * *')).toBe(false); // minute > 59 - expect(validateCronExpression('* 24 * * *')).toBe(false); // hour > 23 - expect(validateCronExpression('* * 32 * *')).toBe(false); // day > 31 - expect(validateCronExpression('* * * 13 *')).toBe(false); // month > 12 - expect(validateCronExpression('* * * * 8')).toBe(false); // day-of-week > 7 - }); - - it('should return false for null/undefined', async () => { - const { validateCronExpression } = await import('./scheduler.js'); - expect(validateCronExpression(null as any)).toBe(false); - expect(validateCronExpression(undefined as any)).toBe(false); - }); -}); diff --git a/packages/tui/extensions/Worklog/lib/scheduler.ts b/packages/tui/extensions/Worklog/lib/scheduler.ts deleted file mode 100644 index 4a781d50d..000000000 --- a/packages/tui/extensions/Worklog/lib/scheduler.ts +++ /dev/null @@ -1,701 +0,0 @@ -/** - * lib/scheduler.ts — Periodic request scheduler for the Worklog Pi extension. - * - * Provides: - * - A minimal cron expression parser (5-field standard cron) - * - A Scheduler class that periodically checks schedules and submits requests - * via pi.sendUserMessage() when the agent is idle - * - A CLI command handler for managing schedules (/wl schedule) - * - * Cron format: minute hour day-of-month month day-of-week - * minute: 0-59 - * hour: 0-23 - * day-of-month: 1-31 - * month: 1-12 - * day-of-week: 0-7 (0 and 7 are Sunday) - * - * Supported syntax: - * * - wildcard (every value) - * N - exact value - * N,M - list of values - * N-M - range of values (inclusive) - * step(N) - step with wildcard (e.g. asterisk-slash-5 = every 5 minutes) - * N-M/step - step with range (e.g. 1-30/10 = 1,11,21) - */ - -import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; -import { currentSettings } from './settings.js'; - -// ── Types ───────────────────────────────────────────────────────────── - -/** - * A schedule entry that maps a cron expression to a pi request. - */ -export interface ScheduleEntry { - /** Unique identifier for this schedule. */ - id: string; - /** Cron expression (5-field standard cron). */ - cron: string; - /** The pi request text to submit when the schedule fires. */ - request: string; - /** Optional human-readable label for display. */ - label?: string; - /** Whether this schedule is enabled. */ - enabled: boolean; -} - -/** - * Raw schedule entry as stored in settings (before validation). - */ -export interface RawScheduleEntry { - id?: string; - cron: string; - request: string; - label?: string; - enabled?: boolean; -} - -/** - * Options for the Scheduler constructor. - */ -export interface SchedulerOptions { - /** - * Custom function to check if the agent is idle. - * Defaults to checking from the extension context. - */ - isIdleFn?: () => boolean; - /** - * Custom tick interval in milliseconds. - * Default: 30000 (30 seconds). - */ - tickIntervalMs?: number; - /** - * Custom now function for testing. - */ - nowFn?: () => Date; -} - -// ── Cron expression parsing ─────────────────────────────────────────── - -/** Maximum valid values for each cron field. */ -const FIELD_MAX: Record<number, number> = { - 0: 59, // minute - 1: 23, // hour - 2: 31, // day of month - 3: 12, // month - 4: 7, // day of week (0-7, both 0 and 7 = Sunday) -}; - -/** Number of fields in a standard cron expression. */ -const CRON_FIELDS = 5; - -/** - * Parse a single cron field value (e.g., "star-slash-15", "1-5", "1,3,5", "10"). - * - * Returns an array of matching numeric values for the given field index. - * Field index: 0=minute, 1=hour, 2=day-of-month, 3=month, 4=day-of-week. - */ -function parseCronField(field: string, fieldIndex: number): number[] { - const max = FIELD_MAX[fieldIndex]; - const trimmed = field.trim(); - - if (!trimmed) throw new Error(`Empty field at position ${fieldIndex}`); - - // Step pattern: */N or N-M/N - const stepMatch = trimmed.match(/^(\*|\d+-\d+)\/(\d+)$/); - if (stepMatch) { - const step = parseInt(stepMatch[2], 10); - if (step <= 0 || !Number.isFinite(step)) { - throw new Error(`Invalid step value '${stepMatch[2]}' at position ${fieldIndex}`); - } - - if (stepMatch[1] === '*') { - // */N — step through all values - const result: number[] = []; - for (let i = 0; i <= max; i += step) { - result.push(i); - } - return result; - } - - // range/N — step through range - const rangeParts = stepMatch[1].split('-'); - const rangeStart = parseInt(rangeParts[0], 10); - const rangeEnd = parseInt(rangeParts[1], 10); - if (!Number.isFinite(rangeStart) || !Number.isFinite(rangeEnd)) { - throw new Error(`Invalid range '${stepMatch[1]}' at position ${fieldIndex}`); - } - const result: number[] = []; - for (let i = rangeStart; i <= rangeEnd; i += step) { - if (i >= 0 && i <= max) result.push(i); - } - return result; - } - - // Comma-separated list: N,M or N-M,M or combinations - if (trimmed.includes(',')) { - return trimmed.split(',').flatMap(part => parseCronField(part, fieldIndex)); - } - - // Range: N-M - const rangeMatch = trimmed.match(/^(\d+)-(\d+)$/); - if (rangeMatch) { - const start = parseInt(rangeMatch[1], 10); - const end = parseInt(rangeMatch[2], 10); - if (!Number.isFinite(start) || !Number.isFinite(end)) { - throw new Error(`Invalid range '${trimmed}' at position ${fieldIndex}`); - } - const result: number[] = []; - for (let i = start; i <= end; i++) { - if (i >= 0 && i <= max) result.push(i); - } - return result; - } - - // Wildcard - if (trimmed === '*') { - const result: number[] = []; - for (let i = 0; i <= max; i++) { - result.push(i); - } - return result; - } - - // Single number - const num = parseInt(trimmed, 10); - if (!Number.isFinite(num) || num < 0 || num > max) { - throw new Error(`Invalid value '${trimmed}' at position ${fieldIndex} (expected 0-${max})`); - } - return [num]; -} - -/** - * Parse a cron expression string into arrays of valid values for each field. - * - * @param expr - 5-field cron expression - * @returns Array of 5 number arrays, each containing matching values - * @throws Error if the expression is invalid - */ -export function parseCronExpression(expr: string): number[][] { - const fields = expr.trim().split(/\s+/); - - if (fields.length !== CRON_FIELDS) { - throw new Error( - `Invalid cron expression: expected ${CRON_FIELDS} fields, got ${fields.length}. ` + - 'Format: minute hour day-of-month month day-of-week', - ); - } - - return fields.map((field, index) => parseCronField(field, index)); -} - -/** - * Check if a cron expression matches a given date. - * - * @param expr - 5-field cron expression - * @param date - The date to check - * @returns true if the cron expression matches the date's fields - * @throws Error if the cron expression is invalid - */ -export function cronMatch(expr: string, date: Date): boolean { - const parsed = parseCronExpression(expr); - - const minute = date.getUTCMinutes(); - const hour = date.getUTCHours(); - const dayOfMonth = date.getUTCDate(); - const month = date.getUTCMonth() + 1; // getUTCMonth() is 0-indexed - const dayOfWeek = date.getUTCDay(); // 0=Sunday - - // Check each field - if (!parsed[0].includes(minute)) return false; - if (!parsed[1].includes(hour)) return false; - if (!parsed[2].includes(dayOfMonth)) return false; - if (!parsed[3].includes(month)) return false; - - // Day of week: 0 and 7 both represent Sunday - if (!parsed[4].includes(dayOfWeek) && !(dayOfWeek === 0 && parsed[4].includes(7))) { - return false; - } - - return true; -} - -/** - * Validate a cron expression string. - * - * Returns true if the expression is a valid 5-field cron expression, - * false otherwise (no throw). - */ -export function validateCronExpression(expr: string): boolean { - if (!expr || typeof expr !== 'string') return false; - try { - parseCronExpression(expr); - return true; - } catch { - return false; - } -} - -// ── Schedule entry management ───────────────────────────────────────── - -let _idCounter = 0; - -/** - * Generate a unique schedule ID. - */ -function generateId(): string { - _idCounter++; - return `sched-${Date.now().toString(36)}-${_idCounter}`; -} - -/** - * Parse and validate a raw schedule entry from settings. - * - * @param raw - Raw schedule entry from settings - * @returns A validated ScheduleEntry - * @throws Error if the entry is invalid - */ -export function parseScheduleEntry(raw: RawScheduleEntry): ScheduleEntry { - if (!raw.cron || typeof raw.cron !== 'string') { - throw new Error('Schedule entry missing required field: cron'); - } - if (!raw.request || typeof raw.request !== 'string') { - throw new Error('Schedule entry missing required field: request'); - } - - // Validate cron expression - if (!validateCronExpression(raw.cron)) { - throw new Error(`Invalid cron expression: '${raw.cron}'`); - } - - return { - id: raw.id || generateId(), - cron: raw.cron, - request: raw.request, - label: raw.label || undefined, - enabled: raw.enabled !== false, // default to true - }; -} - -/** - * Get the current schedules from settings. - */ -export function getSchedules(): ScheduleEntry[] { - const raw = currentSettings.schedules; - if (!raw || !Array.isArray(raw)) return []; - - return raw - .map((entry: any) => { - try { - return parseScheduleEntry(entry); - } catch { - // Skip invalid entries - return null; - } - }) - .filter((entry: ScheduleEntry | null): entry is ScheduleEntry => entry !== null); -} - -// ── Settings persistence ────────────────────────────────────────────── - -/** - * Persist schedules to settings. - * - * This is a lightweight wrapper that uses the config module's update - * mechanism. Injected via the schedule command handler. - */ -let _persistSchedules: ((schedules: RawScheduleEntry[]) => void) | null = null; - -/** - * Set the persistence function for schedules. - * Called by the extension during initialization. - */ -export function setSchedulePersister( - persister: (schedules: RawScheduleEntry[]) => void, -): void { - _persistSchedules = persister; -} - -/** - * Save schedules to persistent settings. - */ -function saveSchedules(schedules: RawScheduleEntry[]): void { - if (_persistSchedules) { - _persistSchedules(schedules); - } -} - -// ── Scheduler class ─────────────────────────────────────────────────── - -/** - * Periodic request scheduler. - * - * Manages a background interval that periodically checks configured schedules - * and submits pi requests when the agent is idle. - * - * Tracks agent idle state internally via pi lifecycle events. The scheduler - * only fires when the agent is confirmed idle (no active streaming, no tool - * execution in progress). - */ -export class Scheduler { - private _interval: ReturnType<typeof setInterval> | null = null; - private _running = false; - private _lastFires: Map<string, number> = new Map(); - private _options: Required<SchedulerOptions>; - private _pi: ExtensionAPI; - private _agentBusy = false; - - constructor( - pi: ExtensionAPI, - options: SchedulerOptions = {}, - ) { - this._pi = pi; - this._options = { - isIdleFn: options.isIdleFn ?? (() => !this._agentBusy), - tickIntervalMs: options.tickIntervalMs ?? 30000, - nowFn: options.nowFn ?? (() => new Date()), - }; - - // Track agent idle state via pi lifecycle events - pi.on('turn_start', () => { - this._agentBusy = true; - }); - - pi.on('message_end', () => { - this._agentBusy = false; - }); - - pi.on('tool_execution_start', () => { - this._agentBusy = true; - }); - - // Note: tool_execution_end deliberately omitted — a turn may use - // multiple tools, so the busy flag must stay true until message_end. - } - - /** - * Start the scheduler. Creates a background interval that periodically - * checks schedules and submits requests when the agent is idle. - * - * Safe to call multiple times — subsequent calls are no-ops if already - * running. - */ - start(): void { - if (this._running) return; - this._running = true; - this._interval = setInterval(() => { - void this.tick(); - }, this._options.tickIntervalMs); - } - - /** - * Stop the scheduler. Clears the background interval. - * - * Safe to call even if the scheduler is not running. - */ - stop(): void { - this._running = false; - if (this._interval !== null) { - clearInterval(this._interval); - this._interval = null; - } - } - - /** - * Check if the scheduler is currently running. - */ - isRunning(): boolean { - return this._running; - } - - /** - * Execute a single tick: check all schedules and fire any that match. - * - * Exposed as a public method so it can be called in tests without - * waiting for the interval. - */ - async tick(): Promise<void> { - const now = this._options.nowFn(); - const schedules = getSchedules(); - const currentMinuteKey = this._getMinuteKey(now); - - for (const schedule of schedules) { - if (!schedule.enabled) continue; - - try { - if (!cronMatch(schedule.cron, now)) continue; - } catch { - // Invalid cron expression in stored schedule — skip silently - continue; - } - - // Check if already fired in this minute - const lastFireKey = `${schedule.id}:${currentMinuteKey}`; - if (this._lastFires.has(lastFireKey)) continue; - - // Mark as fired to prevent duplicate - this._lastFires.set(lastFireKey, now.getTime()); - - // Check if agent is idle - if (!this._options.isIdleFn()) continue; - - // Submit the request through pi's normal message flow - this._pi.sendUserMessage(schedule.request, { deliverAs: 'steer' }); - } - } - - /** - * Clear tracked last-fire times. Used in tests to simulate advancing time. - */ - clearLastFires(): void { - this._lastFires.clear(); - } - - /** - * Get a minute-level key string for duplicate-fire detection. - * Format: "YYYY-MM-DDTHH:mm" - */ - private _getMinuteKey(date: Date): string { - const y = date.getUTCFullYear(); - const m = String(date.getUTCMonth() + 1).padStart(2, '0'); - const d = String(date.getUTCDate()).padStart(2, '0'); - const h = String(date.getUTCHours()).padStart(2, '0'); - const min = String(date.getUTCMinutes()).padStart(2, '0'); - return `${y}-${m}-${d}T${h}:${min}`; - } -} - -// ── CLI command handler ─────────────────────────────────────────────── - -/** - * Help text for the /wl schedule command. - */ -const SCHEDULE_HELP = `Usage: /wl schedule <subcommand> [args] - -Subcommands: - list List all configured schedules - add <cron> <request> Add a new schedule - [--label <label>] Optional label for display - remove <id> Remove a schedule by its ID - toggle <id> Enable or disable a schedule by its ID - help Show this help text - -Examples: - /wl schedule add "0 1 * * *" "Run daily audit" --label "Daily audit" - /wl schedule add "*/15 * * * *" "Check work items" - /wl schedule list - /wl schedule remove sched-abc123-1 - /wl schedule toggle sched-abc123-1 - -Cron format: minute hour day-of-month month day-of-week - * * * * * command to execute - | | | | └─ day of week (0-7, 0 and 7 are Sunday) - | | | └──── month (1-12) - | | └─────── day of month (1-31) - | └────────── hour (0-23) - └───────────── minute (0-59) - -Supported syntax: *, N, N-M, N,M, */N, N-M/N`; - -/** - * Handle the /wl schedule command. - * - * @param args - The raw argument string after "/wl schedule" - * @returns A user-facing message string describing the result - */ -export function scheduleCliCommand(args: string): string { - const trimmed = (args || '').trim(); - const parts = trimmed.split(/\s+/); - - const subcommand = parts[0]?.toLowerCase() || 'list'; - - switch (subcommand) { - case 'list': { - const schedules = getSchedules(); - if (schedules.length === 0) { - return 'No schedules configured. Use "add" to create one.'; - } - - const lines = schedules.map((s, i) => { - const status = s.enabled ? '✓' : '✗'; - const label = s.label ? ` "${s.label}"` : ''; - return `${i + 1}. [${status}] ${s.id}${label}\n Cron: ${s.cron}\n Request: ${s.request}`; - }); - return lines.join('\n\n'); - } - - case 'add': { - // Parse: add "<cron>" "<request>" [--label <label>] - // The cron and request may be quoted. Use a simple manual parser. - const addArgs = parseQuotedArgs(trimmed.slice(3).trim()); - if (addArgs.length < 2) { - return 'Error: Usage: add "<cron>" "<request>" [--label <label>]'; - } - - const cronStr = addArgs[0]; - const requestStr = addArgs[1]; - const labelIdx = addArgs.indexOf('--label'); - const labelStr = labelIdx >= 0 && labelIdx + 1 < addArgs.length ? addArgs[labelIdx + 1] : undefined; - - if (!validateCronExpression(cronStr)) { - return `Error: Invalid cron expression: '${cronStr}'`; - } - - const currentSchedules = getSchedules(); - const newEntry: RawScheduleEntry = { - cron: cronStr, - request: requestStr, - enabled: true, - label: labelStr, - }; - - try { - const validated = parseScheduleEntry(newEntry); - currentSchedules.push(validated); - saveSchedules(currentSchedules); - return `Schedule added: ${validated.id} — Cron: ${cronStr}, Request: ${requestStr}`; - } catch (err: any) { - return `Error adding schedule: ${err.message}`; - } - } - - case 'remove': { - if (parts.length < 2) { - return 'Error: Usage: remove <id>'; - } - const removeId = parts[1]; - const currentSchedules = getSchedules(); - const index = currentSchedules.findIndex(s => s.id === removeId); - if (index === -1) { - return `Error: Schedule '${removeId}' not found`; - } - const removed = currentSchedules.splice(index, 1)[0]; - saveSchedules(currentSchedules); - return `Schedule removed: ${removed.id} (${removed.cron})`; - } - - case 'toggle': { - if (parts.length < 2) { - return 'Error: Usage: toggle <id>'; - } - const toggleId = parts[1]; - const currentSchedules = getSchedules(); - const found = currentSchedules.find(s => s.id === toggleId); - if (!found) { - return `Error: Schedule '${toggleId}' not found`; - } - found.enabled = !found.enabled; - saveSchedules(currentSchedules); - return `Schedule ${toggleId} is now ${found.enabled ? 'enabled' : 'disabled'}`; - } - - case 'help': { - return SCHEDULE_HELP; - } - - default: { - return `Unknown subcommand: '${subcommand}'.\n\n${SCHEDULE_HELP}`; - } - } -} - -/** - * Parse quoted arguments from a command string. - * - * Handles double-quoted strings (with escaped quotes) and unquoted tokens. - * - * @param input - The input string to parse - * @returns Array of argument strings - */ -function parseQuotedArgs(input: string): string[] { - const args: string[] = []; - let current = ''; - let inQuotes = false; - let i = 0; - - while (i < input.length) { - const c = input[i]; - - if (inQuotes) { - if (c === '\\' && i + 1 < input.length && input[i + 1] === '"') { - // Escaped quote - current += '"'; - i += 2; - continue; - } - if (c === '"') { - // End of quoted string - inQuotes = false; - i++; - continue; - } - current += c; - i++; - continue; - } - - // Not in quotes - if (c === '"') { - if (current.trim()) { - args.push(current.trim()); - } - current = ''; - inQuotes = true; - i++; - continue; - } - - if (c === ' ') { - if (current.trim()) { - args.push(current.trim()); - } - current = ''; - i++; - continue; - } - - current += c; - i++; - } - - // Flush remaining - if (current.trim()) { - args.push(current.trim()); - } - - return args; -} - -// ── Extension lifecycle integration ─────────────────────────────────── - -/** - * Register the periodic request scheduler with a pi extension instance. - * - * This function: - * 1. Starts the scheduler on session_start - * 2. Stops the scheduler on session_shutdown - * - * NOTE: The /wl schedule subcommand is registered by index.ts as part of - * the existing /wl command handler. This function only handles the - * background lifecycle. - * - * @returns The created Scheduler instance (useful for testing) - */ -export function registerScheduler( - pi: ExtensionAPI, - options: SchedulerOptions = {}, -): Scheduler { - const scheduler = new Scheduler(pi, options); - - // Start scheduler on session_start - pi.on('session_start', () => { - scheduler.start(); - }); - - // Stop scheduler on session_shutdown - pi.on('session_shutdown', () => { - scheduler.stop(); - }); - - return scheduler; -} \ No newline at end of file diff --git a/packages/tui/extensions/Worklog/lib/settings.test.ts b/packages/tui/extensions/Worklog/lib/settings.test.ts deleted file mode 100644 index 0d0326f14..000000000 --- a/packages/tui/extensions/Worklog/lib/settings.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Unit tests for lib/settings.ts — configuration management and settings - * overlay for the Worklog extension. - * - * Run: npx vitest run packages/tui/extensions/lib/settings.test.ts - */ - -import { describe, it, expect, vi } from 'vitest'; - -vi.mock('@earendil-works/pi-coding-agent', () => ({ - getAgentDir: () => '/home/test-user/.pi/agent', - getSettingsListTheme: () => ({}), -})); - -describe('lib/settings exports', () => { - it('should export settings state and helpers', async () => { - const mod = await import('./settings.js'); - // State - expect(mod.currentSettings).toBeDefined(); - expect(typeof mod.STAGE_MAP).toBe('object'); - expect(mod.VALID_STAGES).toBeDefined(); - expect(mod.VALID_STAGES instanceof Set).toBe(true); - - // Functions - expect(typeof mod.updateSettings).toBe('function'); - expect(typeof mod.openSettingsOverlay).toBe('function'); - }); -}); - -describe('STAGE_MAP', () => { - it('should map shorthand stages to canonical names', async () => { - const { STAGE_MAP } = await import('./settings.js'); - expect(STAGE_MAP.intake).toBe('intake_complete'); - expect(STAGE_MAP.plan).toBe('plan_complete'); - expect(STAGE_MAP.progress).toBe('in_progress'); - expect(STAGE_MAP.review).toBe('in_review'); - }); - - it('should map canonical names to themselves', async () => { - const { STAGE_MAP } = await import('./settings.js'); - expect(STAGE_MAP.idea).toBe('idea'); - expect(STAGE_MAP.intake_complete).toBe('intake_complete'); - expect(STAGE_MAP.plan_complete).toBe('plan_complete'); - expect(STAGE_MAP.in_progress).toBe('in_progress'); - expect(STAGE_MAP.in_review).toBe('in_review'); - }); -}); - -describe('VALID_STAGES', () => { - it('should contain all stage keys', async () => { - const { VALID_STAGES, STAGE_MAP } = await import('./settings.js'); - const keys = Object.keys(STAGE_MAP); - for (const key of keys) { - expect(VALID_STAGES.has(key)).toBe(true); - } - }); -}); - -describe('updateSettings', () => { - it('should update partial settings and return merged result', async () => { - const { updateSettings } = await import('./settings.js'); - const result = updateSettings({ browseItemCount: 15 }); - expect(result.browseItemCount).toBe(15); - }); -}); diff --git a/packages/tui/extensions/Worklog/lib/settings.ts b/packages/tui/extensions/Worklog/lib/settings.ts deleted file mode 100644 index ff2798b9d..000000000 --- a/packages/tui/extensions/Worklog/lib/settings.ts +++ /dev/null @@ -1,290 +0,0 @@ -/** - * lib/settings.ts — Configuration management for the Worklog extension - * - * Extracted from the monolithic index.ts. Provides settings state, stage - * mappings, and the settings overlay UI component. - */ - -import { type Settings } from '../settings-config.js'; -import { worklogConfig } from '../config.js'; - -// ── Settings state ───────────────────────────────────────────────────── - -/** - * Current settings for the extension. Initialised from Pi's canonical - * settings files on module load and updated by the /wl settings command. - * - * Uses the shared WorklogConfig singleton for hot-reload support. - * This is a live binding; it is reassigned on every update/reload so that - * importing modules see the latest values. - */ -export let currentSettings: Settings = worklogConfig.get(); - -/** - * Update the current settings, persist to .pi/settings.json under the - * context-hub namespace, and return the new settings object. - * - * Changes propagate immediately to all onChange subscribers via the - * WorklogConfig singleton, without requiring /reload. - */ -export function updateSettings(partial: Partial<Settings>): Settings { - worklogConfig.update(partial); - currentSettings = worklogConfig.get(); - return currentSettings; -} - -/** - * Reload settings from Pi settings files. Delegates to WorklogConfig.load() - * which refreshes from disk and notifies onChange subscribers if values - * actually changed. - */ -export function reloadSettings(): void { - worklogConfig.load(); - currentSettings = worklogConfig.get(); -} - -// ── Stage mapping ───────────────────────────────────────────────────── - -/** - * Map of shorthand stage aliases to canonical stage names. - * Both keys and values are valid stage values for the /wl command. - */ -export const STAGE_MAP: Record<string, string> = { - intake: 'intake_complete', - plan: 'plan_complete', - progress: 'in_progress', - review: 'in_review', - // Canonical names mapped to themselves for validation - idea: 'idea', - intake_complete: 'intake_complete', - plan_complete: 'plan_complete', - in_progress: 'in_progress', - in_review: 'in_review', -}; - -export const VALID_STAGES = new Set(Object.keys(STAGE_MAP)); - -// ── Settings overlay (Pi TUI) ────────────────────────────────────────── - -// Lazy-loaded Pi TUI components for the settings overlay. -let piContainerCtor: any = null; -let piSettingsListCtor: any = null; -let piTextCtor: any = null; -let piGetSettingsListTheme: any = null; - -async function ensurePiComponents(): Promise<boolean> { - if (piContainerCtor && piSettingsListCtor && piTextCtor && piGetSettingsListTheme) { - return true; - } - try { - const tui = await import('@earendil-works/pi-tui'); - const agent = await import('@earendil-works/pi-coding-agent'); - piContainerCtor = tui.Container; - piSettingsListCtor = tui.SettingsList; - piTextCtor = tui.Text; - piGetSettingsListTheme = agent.getSettingsListTheme; - return true; - } catch { - return false; - } -} - -export interface BrowseContext { - ui: { - select?: (title: string, options: string[]) => Promise<string | undefined>; - custom?: <T>( - render: ( - tui: { requestRender: () => void }, - theme: { - fg: (color: string, text: string) => string; - bold: (text: string) => string; - }, - keybindings: unknown, - done: (value: T) => void, - ) => { - render: (width: number) => string[]; - invalidate: () => void; - handleInput?: (data: string) => void; - }, - ) => Promise<T>; - setWidget?: (id: string, content?: string[] | ((tui: unknown, theme: unknown) => { render: (width: number) => string[]; invalidate: () => void; handleInput?: (data: string) => void; dispose?: () => void; })) => void; - notify: (message: string, level?: 'info' | 'warning' | 'error') => void; - setEditorText?: (text: string) => void; - getEditorText?: () => string; - onTerminalInput?: (handler: (data: string) => { consume?: boolean; data?: string } | undefined) => () => void; - getHeight?: () => number; - setStatus?: (key: string, text: string | undefined) => void; - readonly theme?: { - fg: (color: string, text: string) => string; - bg: (color: string, text: string) => string; - bold: (text: string) => string; - }; - }; -} - -/** - * Open the settings overlay for the Worklog Pi extension. - * - * Uses Pi's SettingsList component with browseItemCount and showIcons - * settings. Changes are applied immediately via onChange callback and - * persisted to .pi/settings.json under the context-hub namespace. - */ -export function openSettingsOverlay(ctx: BrowseContext): void { - // Build items array from current settings - const items = [ - { - id: 'browseItemCount', - label: 'Number of items', - currentValue: String(currentSettings.browseItemCount), - values: ['3', '5', '10', '15', '20'], - }, - { - id: 'showIcons', - label: 'Show icons', - currentValue: currentSettings.showIcons ? 'on' : 'off', - values: ['on', 'off'], - }, - { - id: 'showActivityIndicator', - label: 'Activity indicator', - currentValue: currentSettings.showActivityIndicator ? 'on' : 'off', - values: ['on', 'off'], - }, - { - id: 'showHelpText', - label: 'Help text', - currentValue: currentSettings.showHelpText ? 'on' : 'off', - values: ['on', 'off'], - }, - { - id: 'autoInjectEnabled', - label: 'Auto-inject items', - currentValue: currentSettings.autoInjectEnabled ? 'on' : 'off', - values: ['on', 'off'], - }, - { - id: 'guardrailsEnabled', - label: 'Data guardrails', - currentValue: currentSettings.guardrailsEnabled ? 'on' : 'off', - values: ['on', 'off'], - }, - { - id: 'autoSyncIntervalSeconds', - label: 'Auto-sync interval (s)', - currentValue: String(currentSettings.autoSyncIntervalSeconds), - values: ['0', '5', '10', '30', '60', '120', '300'], - }, - ]; - - // Open the settings overlay - ctx.ui.custom<void>( - (tui, theme, _kb, done) => { - // Kick off async import but return a placeholder synchronously - let ready = false; - let component: any = null; - - ensurePiComponents().then((ok) => { - if (!ok) { - ctx.ui.notify('Settings overlay unavailable: Pi TUI components not found.', 'error'); - done(undefined); - return; - } - - const Container = piContainerCtor; - const SettingsList = piSettingsListCtor; - const Text = piTextCtor; - const getSettingsListTheme = piGetSettingsListTheme; - - const container = new Container(); - container.addChild( - new Text(theme.fg('accent', theme.bold('Worklog Settings')), 1, 1), - ); - - const settingsList = new SettingsList( - items, - Math.min(items.length + 2, 15), - getSettingsListTheme(), - (id: string, newValue: string) => { - // Apply the setting immediately - if (id === 'browseItemCount') { - const count = parseInt(newValue, 10); - if (!isNaN(count) && count >= 1 && count <= 50) { - updateSettings({ browseItemCount: count }); - ctx.ui.notify(`Browse item count set to ${count}`, 'info'); - } - } else if (id === 'showIcons') { - const show = newValue === 'on'; - updateSettings({ showIcons: show }); - ctx.ui.notify(`Icons ${show ? 'enabled' : 'disabled'}`, 'info'); - } else if (id === 'showActivityIndicator') { - const show = newValue === 'on'; - updateSettings({ showActivityIndicator: show }); - ctx.ui.notify(`Activity indicator ${show ? 'enabled' : 'disabled'}`, 'info'); - } else if (id === 'showHelpText') { - const show = newValue === 'on'; - updateSettings({ showHelpText: show }); - ctx.ui.notify(`Help text ${show ? 'enabled' : 'disabled'}`, 'info'); - } else if (id === 'autoInjectEnabled') { - const show = newValue === 'on'; - updateSettings({ autoInjectEnabled: show }); - ctx.ui.notify(`Auto-inject ${show ? 'enabled' : 'disabled'}`, 'info'); - } else if (id === 'guardrailsEnabled') { - const show = newValue === 'on'; - updateSettings({ guardrailsEnabled: show }); - ctx.ui.notify(`Data guardrails ${show ? 'enabled' : 'disabled'}`, 'info'); - } else if (id === 'autoSyncIntervalSeconds') { - const val = parseInt(newValue, 10); - if (!isNaN(val) && val >= 0 && val <= 300) { - updateSettings({ autoSyncIntervalSeconds: val }); - ctx.ui.notify(`Auto-sync interval set to ${val}s`, 'info'); - } - } - }, - () => { - // Close dialog - done(undefined); - }, - { enableSearch: false }, - ); - - container.addChild(settingsList); - - component = { - render: (width: number) => container.render(width), - invalidate: () => container.invalidate(), - handleInput: (data: string) => { - settingsList.handleInput?.(data); - tui.requestRender(); - }, - }; - ready = true; - tui.requestRender(); - }).catch((err) => { - console.error('[worklog-browse] Failed to load Pi components:', err); - ctx.ui.notify('Failed to open settings overlay.', 'error'); - done(undefined); - }); - - return { - render: (width: number) => { - if (ready && component) { - return component.render(width); - } - return [theme.fg('dim', 'Loading settings...')]; - }, - invalidate: () => { - if (component) component.invalidate(); - }, - handleInput: (_data: string) => { - if (ready && component?.handleInput) { - component.handleInput(_data); - tui.requestRender(); - } - }, - }; - }, - ).catch(() => { - // Graceful degradation if overlay fails - ctx.ui.notify('Settings overlay requires TUI mode.', 'warning'); - }); -} diff --git a/packages/tui/extensions/Worklog/lib/shortcuts.test.ts b/packages/tui/extensions/Worklog/lib/shortcuts.test.ts deleted file mode 100644 index 92b3a4292..000000000 --- a/packages/tui/extensions/Worklog/lib/shortcuts.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * Unit tests for lib/shortcuts.ts — keyboard shortcut detection and - * navigation helpers. - * - * Run: npx vitest run packages/tui/extensions/lib/shortcuts.test.ts - */ - -import { describe, it, expect } from 'vitest'; - -describe('lib/shortcuts exports', () => { - it('should export keyboard navigation helpers', async () => { - const mod = await import('./shortcuts.js'); - // _matchesKey may be null (Pi TUI unavailable) or function - expect(mod._matchesKey === null || typeof mod._matchesKey === 'function').toBe(true); - expect(typeof mod.isUpKey).toBe('function'); - expect(typeof mod.isDownKey).toBe('function'); - expect(typeof mod.isPageUpKey).toBe('function'); - expect(typeof mod.isPageDownKey).toBe('function'); - expect(typeof mod.isEnterKey).toBe('function'); - expect(typeof mod.isEscapeKey).toBe('function'); - expect(typeof mod.isCtrlEnterKey).toBe('function'); - expect(typeof mod.isShiftEnterKey).toBe('function'); - expect(typeof mod.isTabKey).toBe('function'); - }); -}); - -describe('isEnterKey', () => { - it('should detect carriage return', async () => { - const { isEnterKey } = await import('./shortcuts.js'); - expect(isEnterKey('\r')).toBe(true); - }); - - it('should detect newline', async () => { - const { isEnterKey } = await import('./shortcuts.js'); - expect(isEnterKey('\n')).toBe(true); - }); - - it('should detect the string "enter"', async () => { - const { isEnterKey } = await import('./shortcuts.js'); - expect(isEnterKey('enter')).toBe(true); - }); - - it('should return false for non-enter keys', async () => { - const { isEnterKey } = await import('./shortcuts.js'); - expect(isEnterKey('a')).toBe(false); - expect(isEnterKey('\u001b')).toBe(false); - }); -}); - -describe('isEscapeKey', () => { - it('should detect escape character', async () => { - const { isEscapeKey } = await import('./shortcuts.js'); - expect(isEscapeKey('\u001b')).toBe(true); - }); - - it('should detect the string "escape"', async () => { - const { isEscapeKey } = await import('./shortcuts.js'); - expect(isEscapeKey('escape')).toBe(true); - }); - - it('should return false for non-escape keys', async () => { - const { isEscapeKey } = await import('./shortcuts.js'); - expect(isEscapeKey('a')).toBe(false); - expect(isEscapeKey('\r')).toBe(false); - }); -}); - -describe('isUpKey', () => { - it('should detect ANSI up escape sequence', async () => { - const { isUpKey } = await import('./shortcuts.js'); - expect(isUpKey('\u001b[A')).toBe(true); - }); - - it('should detect the string "up"', async () => { - const { isUpKey } = await import('./shortcuts.js'); - expect(isUpKey('up')).toBe(true); - }); -}); - -describe('isDownKey', () => { - it('should detect ANSI down escape sequence', async () => { - const { isDownKey } = await import('./shortcuts.js'); - expect(isDownKey('\u001b[B')).toBe(true); - }); - - it('should detect the string "down"', async () => { - const { isDownKey } = await import('./shortcuts.js'); - expect(isDownKey('down')).toBe(true); - }); -}); - -describe('isPageUpKey', () => { - it('should detect ANSI page up', async () => { - const { isPageUpKey } = await import('./shortcuts.js'); - expect(isPageUpKey('\u001b[5~')).toBe(true); - }); - - it('should detect "pageup"', async () => { - const { isPageUpKey } = await import('./shortcuts.js'); - expect(isPageUpKey('pageup')).toBe(true); - }); -}); - -describe('isPageDownKey', () => { - it('should detect ANSI page down', async () => { - const { isPageDownKey } = await import('./shortcuts.js'); - expect(isPageDownKey('\u001b[6~')).toBe(true); - }); - - it('should detect space as page down', async () => { - const { isPageDownKey } = await import('./shortcuts.js'); - expect(isPageDownKey(' ')).toBe(true); - }); -}); - -describe('isCtrlEnterKey', () => { - it('should detect Kitty protocol Ctrl+Enter sequence', async () => { - const { isCtrlEnterKey } = await import('./shortcuts.js'); - expect(isCtrlEnterKey('\u001b[13;5u')).toBe(true); - }); - - it('should detect ANSI modifyOtherKeys Ctrl+Enter sequence', async () => { - const { isCtrlEnterKey } = await import('./shortcuts.js'); - expect(isCtrlEnterKey('\u001b[27;5;13~')).toBe(true); - }); - - it('should detect the string "ctrl+enter"', async () => { - const { isCtrlEnterKey } = await import('./shortcuts.js'); - expect(isCtrlEnterKey('ctrl+enter')).toBe(true); - }); - - it('should detect the string "ctrl+return"', async () => { - const { isCtrlEnterKey } = await import('./shortcuts.js'); - expect(isCtrlEnterKey('ctrl+return')).toBe(true); - }); - - it('should return false for regular Enter', async () => { - const { isCtrlEnterKey } = await import('./shortcuts.js'); - expect(isCtrlEnterKey('\r')).toBe(false); - expect(isCtrlEnterKey('\n')).toBe(false); - expect(isCtrlEnterKey('enter')).toBe(false); - }); - - it('should return false for non-enter keys', async () => { - const { isCtrlEnterKey } = await import('./shortcuts.js'); - expect(isCtrlEnterKey('a')).toBe(false); - expect(isCtrlEnterKey('\u001b')).toBe(false); - }); -}); - -describe('isShiftEnterKey', () => { - it('should detect Kitty protocol Shift+Enter sequence', async () => { - const { isShiftEnterKey } = await import('./shortcuts.js'); - expect(isShiftEnterKey('\u001b[13;2u')).toBe(true); - }); - - it('should detect ANSI modifyOtherKeys Shift+Enter sequence', async () => { - const { isShiftEnterKey } = await import('./shortcuts.js'); - expect(isShiftEnterKey('\u001b[27;2;13~')).toBe(true); - }); - - it('should detect the string "shift+enter"', async () => { - const { isShiftEnterKey } = await import('./shortcuts.js'); - expect(isShiftEnterKey('shift+enter')).toBe(true); - }); - - it('should detect the string "shift+return"', async () => { - const { isShiftEnterKey } = await import('./shortcuts.js'); - expect(isShiftEnterKey('shift+return')).toBe(true); - }); - - it('should return false for regular Enter', async () => { - const { isShiftEnterKey } = await import('./shortcuts.js'); - expect(isShiftEnterKey('\r')).toBe(false); - expect(isShiftEnterKey('\n')).toBe(false); - expect(isShiftEnterKey('enter')).toBe(false); - }); - - it('should return false for Ctrl+Enter', async () => { - const { isShiftEnterKey } = await import('./shortcuts.js'); - expect(isShiftEnterKey('\u001b[13;5u')).toBe(false); - }); - - it('should return false for non-enter keys', async () => { - const { isShiftEnterKey } = await import('./shortcuts.js'); - expect(isShiftEnterKey('a')).toBe(false); - expect(isShiftEnterKey('\u001b')).toBe(false); - }); -}); - -describe('isTabKey', () => { - it('should detect Tab character', async () => { - const { isTabKey } = await import('./shortcuts.js'); - expect(isTabKey('\t')).toBe(true); - }); - - it('should detect the string "tab"', async () => { - const { isTabKey } = await import('./shortcuts.js'); - expect(isTabKey('tab')).toBe(true); - }); - - it('should return false for non-tab keys', async () => { - const { isTabKey } = await import('./shortcuts.js'); - expect(isTabKey('a')).toBe(false); - expect(isTabKey('\r')).toBe(false); - expect(isTabKey('\u001b')).toBe(false); - }); -}); diff --git a/packages/tui/extensions/Worklog/lib/shortcuts.ts b/packages/tui/extensions/Worklog/lib/shortcuts.ts deleted file mode 100644 index bbc058bbf..000000000 --- a/packages/tui/extensions/Worklog/lib/shortcuts.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * lib/shortcuts.ts — Keyboard shortcut detection and navigation helpers - * - * Extracted from the monolithic index.ts. Provides raw terminal input - * matching functions and the set of reserved navigation keys that cannot - * be overridden by config-driven shortcuts. - */ - -/** - * Lazy-loaded reference to Pi's matchesKey() for cross-platform keyboard input. - * When the extension runs inside Pi, this uses @earendil-works/pi-tui's - * matchesKey() which handles all terminal escape sequences (legacy and Kitty - * protocol). Falls back to raw ANSI comparison when Pi's TUI is not available - * (e.g., during testing outside the Pi runtime). - */ -export let _matchesKey: ((data: string, keyId: string) => boolean) | null = null; - -try { - const { matchesKey } = await import('@earendil-works/pi-tui'); - _matchesKey = matchesKey; -} catch { - // Pi TUI not available — fall back to raw ANSI sequence comparison -} - -/** - * Set of single-character keys that are reserved for navigation and MUST NOT - * be overridable by config-driven shortcuts. - * - * Currently: - * - `g` — scroll to top (detail view scrollable widget) - * - `G` — scroll to bottom (detail view scrollable widget) - * - ` ` — page down (detail view scrollable widget, via isPageDownKey) - * - * Multi-character navigation keys (e.g., escape sequences for arrow keys, - * key-id strings like "enter", "escape", "up", "down") are already excluded - * from shortcut lookup because the dispatcher only checks `data.length === 1`. - */ -export const RESERVED_NAVIGATION_KEYS = new Set(['g', 'G', ' ']); - -// ── Keyboard helpers ────────────────────────────────────────────────── - -export function isUpKey(data: string): boolean { - if (_matchesKey) return _matchesKey(data, 'up'); - return data === '\u001b[A' || data === 'up' || /^\u001b\[1;\d+(?::\d+)?A$/.test(data); -} - -export function isDownKey(data: string): boolean { - if (_matchesKey) return _matchesKey(data, 'down'); - return data === '\u001b[B' || data === 'down' || /^\u001b\[1;\d+(?::\d+)?B$/.test(data); -} - -export function isPageUpKey(data: string): boolean { - if (_matchesKey) return _matchesKey(data, 'pageUp'); - return ( - data === '\u001b[5~' - || data === '\u001b[[5~' - || data === 'pageup' - || data === 'pageUp' - || /^\u001b\[5;\d+(?::\d+)?~$/.test(data) - ); -} - -export function isPageDownKey(data: string): boolean { - if (_matchesKey) return _matchesKey(data, 'pageDown'); - return ( - data === '\u001b[6~' - || data === '\u001b[[6~' - || data === 'pagedown' - || data === 'pageDown' - || data === ' ' - || data === 'space' - || /^\u001b\[6;\d+(?::\d+)?~$/.test(data) - ); -} - -export function isEnterKey(data: string): boolean { - if (_matchesKey) return _matchesKey(data, 'enter'); - return data === '\r' || data === '\n' || data === 'enter' || data === 'return'; -} - -export function isCtrlEnterKey(data: string): boolean { - if (_matchesKey) return _matchesKey(data, 'ctrl+enter'); - // Fallback: Kitty protocol CSI-u sequences for Ctrl+Enter (codepoint 13, modifier 5 = ctrl) - // Basic: \x1b[13;5u - // With alternate keys: \x1b[13:13;5u, \x1b[13:13:13;5u, \x1b[13::13;5u - // With event type: \x1b[13;5:1u, \x1b[13;5:2u, \x1b[13;5:3u - // Also support raw ANSI with modifyOtherKeys (CSI 27;5;13~) - return ( - /^\u001b\[13(?:\u003a\d*)*(?:;5(?:\u003a\d+)?)u$/.test(data) - || data === '\u001b[27;5;13~' - || data === 'ctrl+enter' - || data === 'ctrl+return' - ); -} - -export function isShiftEnterKey(data: string): boolean { - if (_matchesKey) return _matchesKey(data, 'shift+enter'); - // Fallback: Kitty protocol CSI-u sequences for Shift+Enter (codepoint 13, modifier 2 = shift) - // Basic: \x1b[13;2u - // With alternate keys: \x1b[13:13;2u, \x1b[13:13:13;2u, \x1b[13::13;2u - // With event type: \x1b[13;2:1u, \x1b[13;2:2u, \x1b[13;2:3u - // Also support raw ANSI with modifyOtherKeys (CSI 27;2;13~) - return ( - /^\u001b\[13(?:\u003a\d*)*(?:;2(?:\u003a\d+)?)u$/.test(data) - || data === '\u001b[27;2;13~' - || data === 'shift+enter' - || data === 'shift+return' - ); -} - -export function isEscapeKey(data: string): boolean { - if (_matchesKey) return _matchesKey(data, 'escape'); - return data === '\u001b' || data === 'escape'; -} - -export function isTabKey(data: string): boolean { - if (_matchesKey) return _matchesKey(data, 'tab'); - // Fallback: Tab character (ASCII 9) - return data === '\t' || data === 'tab'; -} diff --git a/packages/tui/extensions/Worklog/lib/smart-selection.test.ts b/packages/tui/extensions/Worklog/lib/smart-selection.test.ts deleted file mode 100644 index 1481ce31d..000000000 --- a/packages/tui/extensions/Worklog/lib/smart-selection.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * Unit tests for selectWorkItems — the smart selection algorithm that - * guarantees all critical and completed/in_review items are always shown - * in the Pi TUI Worklog extension selection list regardless of the - * browseItemCount setting. - * - * The selection function is intentionally duplicated per TUI (decision Q2c - * in WL-0MS8W5LTW006YZ4B); this suite mirrors the Herdr plugin suite. - * - * Run: npx vitest run packages/tui/extensions/Worklog/lib/smart-selection.test.ts - */ - -import { describe, it, expect } from 'vitest'; -import { selectWorkItems } from './smart-selection.js'; -import type { WorklogBrowseItem } from './tools.js'; - -/** - * Build a minimal WorklogBrowseItem for testing. - */ -function makeItem(id: string, overrides: Partial<WorklogBrowseItem> = {}): WorklogBrowseItem { - return { - id, - title: `Item ${id}`, - status: 'open', - priority: 'medium', - stage: 'idea', - ...overrides, - }; -} - -/** Convenience builders for the mandatory-set criteria. */ -const critical = (id: string): WorklogBrowseItem => makeItem(id, { priority: 'critical' }); -const inReview = (id: string): WorklogBrowseItem => makeItem(id, { status: 'completed', stage: 'in_review' }); -/** Item that is BOTH critical and completed/in_review (overlap case). */ -const criticalInReview = (id: string): WorklogBrowseItem => makeItem(id, { priority: 'critical', status: 'completed', stage: 'in_review' }); -const other = (id: string): WorklogBrowseItem => makeItem(id, { priority: 'medium', status: 'open', stage: 'idea' }); -/** Item whose stage is 'done' (fully closed — must never appear in the default list). */ -const done = (id: string, overrides: Partial<WorklogBrowseItem> = {}): WorklogBrowseItem => makeItem(id, { stage: 'done', status: 'completed', ...overrides }); - -describe('selectWorkItems — smart selection algorithm', () => { - it('returns a new array and does not mutate the input (pure & deterministic)', () => { - const input = [critical('C1'), other('O1'), inReview('R1')]; - const snapshot = JSON.stringify(input); - - const result = selectWorkItems(input, 10); - - expect(result).not.toBe(input); - expect(JSON.stringify(input)).toBe(snapshot); - expect(selectWorkItems(input, 10)).toEqual(result); - }); - - describe('reference example 1 — browseItemCount=15, 2 critical + 3 in_review + 10 others', () => { - it('returns exactly 15 items (2 mandatory-critical + 3 mandatory-review + 10 others)', () => { - const items = [ - critical('C1'), - critical('C2'), - inReview('R1'), - inReview('R2'), - inReview('R3'), - ...Array.from({ length: 20 }, (_, i) => other(`O${i + 1}`)), - ]; - - const result = selectWorkItems(items, 15); - - expect(result).toHaveLength(15); - expect(result.filter(i => i.priority === 'critical')).toHaveLength(2); - expect(result.filter(i => i.status === 'completed' && i.stage === 'in_review')).toHaveLength(3); - expect(result.filter(i => i.priority !== 'critical' && !(i.status === 'completed' && i.stage === 'in_review'))).toHaveLength(10); - // The 10 "other" items are the first 10 of the input's 20 others. - expect(result.map(i => i.id)).toEqual([ - 'C1', 'C2', 'R1', 'R2', 'R3', 'O1', 'O2', 'O3', 'O4', 'O5', - 'O6', 'O7', 'O8', 'O9', 'O10', - ]); - }); - }); - - describe('reference example 2 — browseItemCount=15, 2 critical + 20 in_review', () => { - it('returns all 22 items (total exceeds the setting; no hard cap on mandatory set)', () => { - const items = [ - critical('C1'), - critical('C2'), - ...Array.from({ length: 20 }, (_, i) => inReview(`R${i + 1}`)), - ]; - - const result = selectWorkItems(items, 15); - - expect(result).toHaveLength(22); - expect(result.filter(i => i.priority === 'critical')).toHaveLength(2); - expect(result.filter(i => i.status === 'completed' && i.stage === 'in_review')).toHaveLength(20); - }); - }); - - describe('edge cases', () => { - it('empty mandatory set → behaves like plain top-N (others.slice(0, browseItemCount))', () => { - const items = Array.from({ length: 25 }, (_, i) => other(`O${i + 1}`)); - - const result = selectWorkItems(items, 10); - - expect(result).toHaveLength(10); - expect(result.map(i => i.id)).toEqual( - Array.from({ length: 10 }, (_, i) => `O${i + 1}`), - ); - }); - - it('overlap critical ∩ completed/in_review → item counts once (deduplicated)', () => { - const items = [ - criticalInReview('BOTH1'), - other('O1'), - other('O2'), - other('O3'), - other('O4'), - other('O5'), - ]; - - // browseItemCount=5: mandatory set = 1 (BOTH1 counts once), so 4 others shown. - const result = selectWorkItems(items, 5); - - expect(result).toHaveLength(5); - expect(result.filter(i => i.priority === 'critical' || (i.status === 'completed' && i.stage === 'in_review'))).toHaveLength(1); - expect(result.map(i => i.id)).toEqual(['BOTH1', 'O1', 'O2', 'O3', 'O4']); - }); - - it('mandatory-only exceeding the cap → all mandatory items shown, zero others', () => { - const items = [ - critical('C1'), - critical('C2'), - critical('C3'), - inReview('R1'), - inReview('R2'), - other('O1'), - other('O2'), - ]; - - // browseItemCount=3: 5 mandatory (3 critical + 2 in_review) exceed the cap - // → all 5 shown in full, zero others (no hard cap on mandatory set). - const result = selectWorkItems(items, 3); - - expect(result).toHaveLength(5); - expect(result.every(i => i.priority === 'critical' || (i.status === 'completed' && i.stage === 'in_review'))).toBe(true); - expect(result.map(i => i.id)).toEqual(['C1', 'C2', 'C3', 'R1', 'R2']); - }); - - it('slots floor at zero → othersLimit = max(0, browseItemCount - mandatory.length) never negative', () => { - const items = [ - critical('C1'), - critical('C2'), - critical('C3'), - critical('C4'), - inReview('R1'), - inReview('R2'), - inReview('R3'), - other('O1'), - ]; - - // browseItemCount=2: mandatory (7) exceeds the cap → all 7 shown, 0 others. - const result = selectWorkItems(items, 2); - - expect(result).toHaveLength(7); - expect(result.filter(i => i.priority !== 'critical' && !(i.status === 'completed' && i.stage === 'in_review'))).toHaveLength(0); - }); - - it('handles empty input array', () => { - expect(selectWorkItems([], 10)).toEqual([]); - }); - - it('handles browseItemCount of 0 → mandatory items only', () => { - const items = [critical('C1'), inReview('R1'), other('O1')]; - const result = selectWorkItems(items, 0); - expect(result.map(i => i.id)).toEqual(['C1', 'R1']); - }); - }); - - describe('ordering assertion', () => { - it('mandatory items first (critical group, then completed/in_review), others retain input order', () => { - // Deliberately interleaved input: others, review, others, critical, others. - const items = [ - other('O1'), - inReview('R1'), - other('O2'), - critical('C1'), - other('O3'), - inReview('R2'), - other('O4'), - ]; - - const result = selectWorkItems(items, 10); - - // Critical group first, then completed/in_review group. - expect(result.slice(0, 1).map(i => i.id)).toEqual(['C1']); - expect(result.slice(1, 3).map(i => i.id)).toEqual(['R1', 'R2']); - // Others retain original relative order. - expect(result.slice(3).map(i => i.id)).toEqual(['O1', 'O2', 'O3', 'O4']); - }); - }); - - describe('done-stage exclusion (WL-0MS94VAII00054L9)', () => { - it('excludes a stage=done item from the returned list entirely', () => { - const items = [done('D1'), other('O1')]; - const result = selectWorkItems(items, 10); - expect(result.map(i => i.id)).toEqual(['O1']); - }); - - it('excludes a stage=done item even when it is priority=critical', () => { - const items = [done('DC1', { priority: 'critical' }), critical('C1'), other('O1')]; - const result = selectWorkItems(items, 10); - expect(result.map(i => i.id)).toEqual(['C1', 'O1']); - }); - - it('excludes a stage=done item even when it is status=completed (closed item)', () => { - const items = [done('DC1'), inReview('R1'), other('O1')]; - const result = selectWorkItems(items, 10); - expect(result.map(i => i.id)).toEqual(['R1', 'O1']); - }); - - it('a stage=done item does not consume a browseItemCount slot', () => { - const items = [done('D1'), other('O1'), other('O2'), other('O3'), other('O4'), other('O5')]; - // browseItemCount=5: the done item must not count, so 5 others fill the list. - const result = selectWorkItems(items, 5); - expect(result).toHaveLength(5); - expect(result.map(i => i.id)).toEqual(['O1', 'O2', 'O3', 'O4', 'O5']); - }); - - it('returns an empty list when all items are stage=done', () => { - const items = [done('D1'), done('D2'), done('D3')]; - expect(selectWorkItems(items, 10)).toEqual([]); - }); - - it('still shows all mandatory non-done items when done items are interleaved', () => { - const items = [ - critical('C1'), - done('D1'), - inReview('R1'), - done('D2', { priority: 'critical' }), - other('O1'), - other('O2'), - ]; - // browseItemCount=2: mandatory (C1, R1) = 2 → zero others; done items never appear. - const result = selectWorkItems(items, 2); - expect(result.map(i => i.id)).toEqual(['C1', 'R1']); - }); - - it('hides child items from the selection list (WL-0MS964SIA0057ABR)', () => { - const items = [ - critical('C1'), - inReview('R1'), - other('O1'), - // Children must never appear at top level even if they match a - // mandatory criterion or are otherwise actionable. - makeItem('ChildCritical', { priority: 'critical', parentId: 'C1' }), - makeItem('ChildReview', { status: 'completed', stage: 'in_review', parentId: 'R1' }), - makeItem('ChildOther', { parentId: 'O1' }), - ]; - const result = selectWorkItems(items, 10); - const ids = result.map(i => i.id); - expect(ids).toEqual(['C1', 'R1', 'O1']); - }); - }); -}); diff --git a/packages/tui/extensions/Worklog/lib/smart-selection.ts b/packages/tui/extensions/Worklog/lib/smart-selection.ts deleted file mode 100644 index 1fb988af1..000000000 --- a/packages/tui/extensions/Worklog/lib/smart-selection.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * lib/smart-selection.ts — Smart selection for the Pi TUI Worklog extension - * - * Guarantees that all critical-priority items and all completed/in_review - * items (the producer-review queue) are ALWAYS shown in the default browse - * selection list, regardless of the `browseItemCount` setting. The count - * limit applies only to "other" items that are neither critical nor - * completed/in_review. - * - * This function is intentionally duplicated per TUI (decision Q2c in - * WL-0MS8W5LTW006YZ4B): a copy lives in this extension and another in the - * Herdr plugin. Do NOT move it to a shared package — Herdr has zero npm - * dependencies and sharing would require new packaging wiring. - */ - -import type { WorklogBrowseItem } from './tools.js'; - -/** - * Returns true when an item is part of the mandatory set that must always - * be shown: priority=critical OR (status=completed AND stage=in_review). - */ -export function isMandatoryItem(item: Pick<WorklogBrowseItem, 'priority' | 'status' | 'stage'>): boolean { - return item.priority === 'critical' || (item.status === 'completed' && item.stage === 'in_review'); -} - -/** - * Smart-select work items for the default browse selection list. - * - * - Items whose stage is 'done' (fully closed) are always excluded — the - * default list only shows actionable work (WL-0MS94VAII00054L9). - * - All mandatory items (critical ∪ completed/in_review) are always included, - * deduplicated (an item matching both criteria counts once). - * - The remaining `browseItemCount` slots are filled with "other" items in - * their original (wl next) order. - * - When the mandatory set alone meets or exceeds `browseItemCount`, all - * mandatory items are shown and zero others (no hard cap on mandatory). - * - * Pure & deterministic: takes (items, browseItemCount), returns a new array, - * does not mutate the input. The caller clamps `browseItemCount` to 1–50. - */ -export function selectWorkItems<T extends Pick<WorklogBrowseItem, 'priority' | 'status' | 'stage' | 'parentId'>>( - items: T[], - browseItemCount: number, -): T[] { - // Defensive root-only filter (WL-0MS964SIA0057ABR): merged lists can never - // contain child items regardless of source. Children are only visible under - // their parent via drill-down. - const rootOnly = items.filter((i) => !i.parentId); - const actionable = rootOnly.filter((i) => i.stage !== 'done'); - const criticals = actionable.filter((i) => i.priority === 'critical'); - const reviews = actionable.filter((i) => i.status === 'completed' && i.stage === 'in_review' && i.priority !== 'critical'); - const others = actionable.filter((i) => !isMandatoryItem(i)); - const othersLimit = Math.max(0, browseItemCount - (criticals.length + reviews.length)); - return [...criticals, ...reviews, ...others.slice(0, othersLimit)]; -} diff --git a/packages/tui/extensions/Worklog/lib/tools.test.ts b/packages/tui/extensions/Worklog/lib/tools.test.ts deleted file mode 100644 index 41909238c..000000000 --- a/packages/tui/extensions/Worklog/lib/tools.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Unit tests for lib/tools.ts — work item tool functions (CLI integration, - * JSON parsing, list creation). - * - * Run: npx vitest run packages/tui/extensions/lib/tools.test.ts - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@earendil-works/pi-coding-agent', () => ({ - getAgentDir: () => '/home/test-user/.pi/agent', -})); - -describe('lib/tools exports', () => { - it('should export the expected functions and types', async () => { - const mod = await import('./tools.js'); - // Functions - expect(typeof mod.runWl).toBe('function'); - expect(typeof mod.extractJsonObject).toBe('function'); - expect(typeof mod.normalizeListPayload).toBe('function'); - expect(typeof mod.createDefaultListWorkItems).toBe('function'); - expect(typeof mod.createListWorkItemsWithStage).toBe('function'); - expect(typeof mod.fetchTotalActionableCount).toBe('function'); - - // Constants - expect(mod.NOT_INITIALIZED_PATTERN).toBeDefined(); - expect(typeof mod.NOT_INITIALIZED_FRIENDLY).toBe('string'); - }); - - describe('extractJsonObject', () => { - it('should parse a complete JSON string', async () => { - const { extractJsonObject } = await import('./tools.js'); - const result = extractJsonObject('{"key": "value"}'); - expect(result).toEqual({ key: 'value' }); - }); - - it('should extract JSON from surrounding text', async () => { - const { extractJsonObject } = await import('./tools.js'); - const result = extractJsonObject('Some text {"key": "value"} trailing'); - expect(result).toEqual({ key: 'value' }); - }); - - it('should throw on no JSON object', async () => { - const { extractJsonObject } = await import('./tools.js'); - expect(() => extractJsonObject('just text')).toThrow('No JSON object in output'); - }); - }); - - describe('normalizeListPayload', () => { - it('should normalize a direct array payload', async () => { - const { normalizeListPayload } = await import('./tools.js'); - const items = [{ id: 'WL-1', title: 'Test', status: 'open' }]; - const result = normalizeListPayload(items); - expect(result).toHaveLength(1); - expect(result[0].id).toBe('WL-1'); - }); - - it('should normalize a wrapped payload (workItems key)', async () => { - const { normalizeListPayload } = await import('./tools.js'); - const items = [{ id: 'WL-1', title: 'Test', status: 'open' }]; - const result = normalizeListPayload({ workItems: items }); - expect(result).toHaveLength(1); - expect(result[0].id).toBe('WL-1'); - }); - - it('should normalize a results-based payload', async () => { - const { normalizeListPayload } = await import('./tools.js'); - const items = [{ workItem: { id: 'WL-1', title: 'Test', status: 'open' } }]; - const result = normalizeListPayload({ results: items }); - expect(result).toHaveLength(1); - expect(result[0].id).toBe('WL-1'); - }); - - it('should filter out items without an id', async () => { - const { normalizeListPayload } = await import('./tools.js'); - const items = [ - { id: 'WL-1', title: 'Valid', status: 'open' }, - { noId: true }, - ]; - const result = normalizeListPayload(items); - expect(result).toHaveLength(1); - expect(result[0].id).toBe('WL-1'); - }); - }); - - describe('NOT_INITIALIZED_PATTERN', () => { - it('should match the not-initialized error message', async () => { - const { NOT_INITIALIZED_PATTERN } = await import('./tools.js'); - expect(NOT_INITIALIZED_PATTERN.test('worklog: not initialized in this checkout/worktree')).toBe(true); - expect(NOT_INITIALIZED_PATTERN.test('Worklog system is not initialized.')).toBe(true); - expect(NOT_INITIALIZED_PATTERN.test('normal output')).toBe(false); - }); - }); - - describe('runWl timeout (lock-storm prevention)', () => { - const mockExecFile = vi.hoisted(() => { - const store = (globalThis as any).__sharedChildProcessMocks; - return store?.mockExecFile ?? vi.fn(); - }); - - beforeEach(() => { - mockExecFile.mockReset(); - const realExecFile = (globalThis as any).__sharedChildProcessMocks?.realExecFile; - if (realExecFile) { - mockExecFile.mockImplementation(realExecFile); - } - }); - - it('passes a timeout option to execFile so hung syncs cannot persist', async () => { - const { runWl } = await import('./tools.js'); - let capturedOptions: any = null; - mockExecFile.mockImplementationOnce( - (_binary: string, _args: string[], options: any, callback: (err: Error | null, result: { stdout: string }) => void) => { - capturedOptions = options; - callback(null, { stdout: '{"success":true}' }); - }, - ); - - const out = await runWl(['sync']); - expect(out).toBe('{"success":true}'); - expect(capturedOptions).not.toBeNull(); - expect(capturedOptions.timeout).toBeGreaterThan(0); - }); - - it('surfaces a clear timeout error when execFile reports a killed child', async () => { - const { runWl } = await import('./tools.js'); - mockExecFile.mockImplementationOnce( - (_binary: string, _args: string[], _options: any, callback: (err: Error | null) => void) => { - const err = Object.assign(new Error('Command failed'), { killed: true, signal: 'SIGTERM', code: 'ETIMEDOUT' }); - callback(err); - }, - ); - - await expect(runWl(['sync'])).rejects.toThrow(/timed out after/); - }); - }); -}); diff --git a/packages/tui/extensions/Worklog/lib/tools.ts b/packages/tui/extensions/Worklog/lib/tools.ts deleted file mode 100644 index 4248d96ae..000000000 --- a/packages/tui/extensions/Worklog/lib/tools.ts +++ /dev/null @@ -1,518 +0,0 @@ -/** - * lib/tools.ts — Work item tool functions - * - * CLI integration, JSON parsing, and list creation helpers extracted from the - * monolithic index.ts. This module handles all wl/worklog CLI invocations - * and response parsing. - */ - -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import { currentSettings } from './settings.js'; -import { selectWorkItems } from './smart-selection.js'; -import { regroupWorkItems } from './grouping.js'; - -// Promisify lazily inside the call site rather than at module scope. -// ESM named imports are live bindings, but `promisify(execFile)` snapshots -// the function reference at module-load time; tests that mock -// `node:child_process` (e.g. runWl-init-detection.test.ts) would otherwise -// keep seeing the pre-mock binding. Resolving per-call keeps the mock -// observable. - -/** - * Lazily load getWorklogDb so that tests can mock wl-integration.js - * without being affected by this module's import side effects. - */ -async function getDb(): Promise<any | null> { - try { - const { getWorklogDb } = await import('../wl-integration.js'); - return getWorklogDb(); - } catch { - return null; - } -} - -// ── Types ───────────────────────────────────────────────────────────── - -export type RunWlFn = (args: string[], includeJson?: boolean) => Promise<string>; - -// ── JSON parsing ────────────────────────────────────────────────────── - -export function extractJsonObject(raw: string): unknown { - const start = raw.indexOf('{'); - if (start < 0) throw new Error('No JSON object in output'); - - // Try to parse the full output - it may be valid JSON already - const trimmed = raw.trim(); - const lastOpenQuote = trimmed.lastIndexOf('"'); - const lastCloseBrace = trimmed.lastIndexOf('}'); - - // If it looks like complete JSON, try to parse it - if (lastCloseBrace > lastOpenQuote) { - try { - return JSON.parse(trimmed); - } catch { - // Fall through to manual extraction - } - } - - // Manual extraction: count braces while respecting string boundaries - let depth = 0; - let inString = false; - for (let i = start; i < raw.length; i += 1) { - const c = raw[i]; - if (c === '"') { - // Count preceding backslashes to check if quote is escaped - let backslashes = 0; - for (let j = i - 1; j >= start && raw[j] === '\\'; j--) { - backslashes++; - } - if (backslashes % 2 === 0) { - inString = !inString; - } - } - if (!inString) { - if (c === '{') depth += 1; - if (c === '}') depth -= 1; - if (depth === 0) { - return JSON.parse(raw.slice(start, i + 1)); - } - } - } - - throw new Error('Unterminated JSON object in output'); -} - -// ── Payload normalization ───────────────────────────────────────────── - -export interface WorklogBrowseItem { - id: string; - title: string; - status: string; - priority?: string; - stage?: string; - /** Parent work item id; null/undefined for root items */ - parentId?: string | null; - risk?: string; - effort?: string; - description?: string; - auditResult?: boolean | null; - auditedAt?: string | null; - needsProducerReview?: boolean; - updatedAt?: string; - issueType?: string; - childCount?: number; - tags?: string[]; - githubIssueNumber?: number; - group?: number; - groupLabel?: string; -} - -export function normalizeListPayload(payload: unknown): WorklogBrowseItem[] { - const directItems = Array.isArray(payload) - ? payload - : (payload && typeof payload === 'object' && Array.isArray((payload as any).workItems) - ? (payload as any).workItems - : []); - - const nextItems = payload && typeof payload === 'object' && Array.isArray((payload as any).results) - ? (payload as any).results.map((entry: any) => { - const item = entry?.workItem; - if (!item) return null; - // Merge the `group` and `groupLabel` fields from the result entry into the workItem - if (entry.group !== undefined) { - item.group = entry.group; - } - if (entry.groupLabel !== undefined) { - item.groupLabel = entry.groupLabel; - } - return item; - }).filter(Boolean) - : []; - - const itemList = [...directItems, ...nextItems]; - - return itemList - .map((item: any) => ({ - id: String(item?.id ?? ''), - title: String(item?.title ?? 'Untitled'), - status: String(item?.status ?? 'unknown'), - priority: item?.priority ? String(item.priority) : undefined, - stage: item?.stage ? String(item.stage) : undefined, - parentId: item?.parentId != null ? String(item.parentId) : undefined, - risk: item?.risk ? String(item.risk) : undefined, - effort: item?.effort ? String(item.effort) : undefined, - description: item?.description ? String(item.description) : undefined, - auditResult: item?.auditResult !== undefined ? item.auditResult : undefined, - auditedAt: item?.auditedAt !== undefined && item?.auditedAt !== null ? String(item.auditedAt) : undefined, - needsProducerReview: item?.needsProducerReview !== undefined ? Boolean(item.needsProducerReview) : undefined, - updatedAt: item?.updatedAt ? String(item.updatedAt) : undefined, - issueType: item?.issueType ? String(item.issueType) : undefined, - childCount: item?.childCount !== undefined ? Number(item.childCount) : undefined, - tags: Array.isArray(item?.tags) ? item.tags.map(String) : undefined, - githubIssueNumber: item?.githubIssueNumber !== undefined ? Number(item.githubIssueNumber) : undefined, - group: item?.group !== undefined ? Number(item.group) : undefined, - groupLabel: item?.groupLabel !== undefined ? String(item.groupLabel) : undefined, - })) - .filter(item => item.id.length > 0); -} - -// ── "Not initialized" detection ─────────────────────────────────────── - -/** - * Known error message pattern emitted by the wl/worklog CLI and post-pull/push - * hooks when Worklog is not initialized in the current checkout or worktree. - */ -export const NOT_INITIALIZED_PATTERN = /worklog(?::\s*not initialized|\s+system\s+is\s+not\s+initialized)/i; - -/** - * Friendly, actionable message shown to users instead of the raw stderr - * when the "not initialized" error is detected. - */ -export const NOT_INITIALIZED_FRIENDLY = - 'Worklog is not initialized in this checkout/worktree. Run "wl init" to set up this location.'; - -// ── CLI execution ───────────────────────────────────────────────────── - -/** - * Default timeout (ms) for `wl`/`worklog` CLI invocations via runWl. - * A sync hung on the file lock previously hung forever (no timeout); this - * bounds every call so a stuck `wl sync` cannot persist indefinitely - * (lock-storm prevention, WL-0MSAB7ZUC004SK7E). Overridable via env. - */ -export const DEFAULT_WL_TIMEOUT_MS = Number(process.env.WL_RUN_TIMEOUT_MS) || 60_000; - -export async function runWl(args: string[], includeJson = true, timeoutMs: number = DEFAULT_WL_TIMEOUT_MS): Promise<string> { - const binaries = ['wl', 'worklog']; - let lastError: unknown; - - for (const binary of binaries) { - try { - const fullArgs = includeJson ? [...args, '--json'] : args; - const execFileAsync = promisify(execFile); - const result = await execFileAsync(binary, fullArgs, { - maxBuffer: 1024 * 1024 * 5, - timeout: timeoutMs, - }); - return result.stdout; - } catch (error: any) { - if (error && error.code === 'ENOENT') { - lastError = error; - continue; - } - - // execFile kills the child and rejects with killed/signal set when the - // timeout fires. Surface a clear, actionable message instead of the - // raw "Command failed" error. - if (error && (error.killed === true || error.signal || error.code === 'ETIMEDOUT')) { - const cmd = `${binary} ${args.join(' ')}`; - throw new Error(`Command timed out after ${timeoutMs}ms: ${cmd}. The worklog file lock may be contended; a sync may already be running.`); - } - - const stderr = typeof error?.stderr === 'string' ? error.stderr.trim() : ''; - const stdout = typeof error?.stdout === 'string' ? error.stdout.trim() : ''; - const message = stderr || stdout || error?.message || String(error); - - if (NOT_INITIALIZED_PATTERN.test(message)) { - const friendlyError = new Error(NOT_INITIALIZED_FRIENDLY); - (friendlyError as any).cause = error; - throw friendlyError; - } - - throw new Error(message); - } - } - - throw new Error(`Unable to execute wl/worklog CLI: ${String(lastError)}`); -} - -// ── List helpers ────────────────────────────────────────────────────── - -/** - * Merge work item arrays, deduplicating by item ID (first occurrence wins). - */ -export function mergeUniqueById(...arrays: WorklogBrowseItem[][]): WorklogBrowseItem[] { - const seen = new Set<string>(); - const merged: WorklogBrowseItem[] = []; - for (const item of arrays.flat()) { - if (!seen.has(item.id)) { - seen.add(item.id); - merged.push(item); - } - } - return merged; -} - -/** - * Fetch the mandatory subsets that must ALWAYS be shown in the default - * selection list: all critical items and all completed/in_review items (the - * producer-review queue). - * - * These are fetched explicitly via `wl list` because `wl next -n N` hard-caps - * at 32 items — even `-n 500` returns only 32, so a large superset cannot - * capture all critical/in_review items. Runs the two queries in parallel to - * mitigate refresh latency. - */ -async function fetchMandatorySubsets(run: RunWlFn): Promise<WorklogBrowseItem[]> { - // Root-only (WL-0MS964SIA0057ABR): child items are hidden from the - // top-level selection list — they are only visible under their parent via - // drill-down (Tab). - const [criticalOutput, reviewOutput] = await Promise.all([ - run(['list', '--priority', 'critical', '--root-only']), - run(['list', '--status', 'completed', '--stage', 'in_review', '--root-only']), - ]); - const criticalItems = normalizeListPayload(extractJsonObject(criticalOutput)); - const reviewItems = normalizeListPayload(extractJsonObject(reviewOutput)); - return mergeUniqueById(criticalItems, reviewItems); -} - -export function createDefaultListWorkItems( - run: RunWlFn = runWl, - count?: number, -): () => Promise<WorklogBrowseItem[]> { - return async (): Promise<WorklogBrowseItem[]> => { - const itemCount = count ?? currentSettings.browseItemCount; - const output = await run(['next', '-n', String(itemCount), '--include-in-progress']); - const payload = extractJsonObject(output); - const items = normalizeListPayload(payload); - - // Smart selection: merge the mandatory subsets (critical + completed/in_review, - // fetched explicitly via wl list because wl next caps at 32 items) with the - // regular wl next results, then always show the mandatory set and limit only - // the "other" items to fill the remaining count slots. - const mandatory = await fetchMandatorySubsets(run); - const merged = mergeUniqueById(items, mandatory); - const selected = selectWorkItems(merged, itemCount); - // Regroup the final selected set so every displayed item (mandatory wl - // list items included) receives a correct group assignment and no - // duplicate section headings render (WL-0MSAK8YLB0025EGW). - return regroupWorkItems(selected); - }; -} - -export function createListWorkItemsWithStage( - run: RunWlFn = runWl, - count?: number, -): (stage: string) => Promise<WorklogBrowseItem[]> { - return async (stage: string): Promise<WorklogBrowseItem[]> => { - const itemCount = count ?? currentSettings.browseItemCount; - const output = await run(['next', '-n', String(itemCount), '--stage', stage, '--include-in-progress']); - const payload = extractJsonObject(output); - return normalizeListPayload(payload).slice(0, itemCount); - }; -} - -export async function defaultListWorkItems(run: RunWlFn = runWl): Promise<WorklogBrowseItem[]> { - return createDefaultListWorkItems(run)(); -} - -export async function defaultListWorkItemsWithStage(stage: string, run: RunWlFn = runWl): Promise<WorklogBrowseItem[]> { - return createListWorkItemsWithStage(run)(stage); -} - -/** - * Fetch the total count of actionable work items (open + in-progress + blocked). - * Returns the count, or `undefined` if the fetch fails (graceful degradation). - */ -export async function fetchTotalActionableCount(run: RunWlFn = runWl): Promise<number | undefined> { - try { - const output = await run(['list', '--status', 'open,in-progress,blocked']); - const payload = JSON.parse(output); - if (payload && typeof payload === 'object' && typeof payload.count === 'number') { - return payload.count; - } - return undefined; - } catch { - return undefined; - } -} - -// ── Database-backed read operations (Phase 2) ──────────────────── - -/** - * Create a cached "next work items" list function using direct SQLite access. - * - * Smart selection is applied: all critical and completed/in_review items are - * always shown (fetched via db.list filters — see fetchMandatorySubsets for - * the CLI equivalent), and browseItemCount limits only the remaining items. - */ -export function createDefaultListWorkItemsDb( - count?: number, -): () => Promise<WorklogBrowseItem[]> { - return async (): Promise<WorklogBrowseItem[]> => { - const itemCount = count ?? currentSettings.browseItemCount; - const db = await getDb(); - if (!db) return defaultListWorkItems(); - try { - // Regular next-equivalent: findNextWorkItems mirrors `wl next -n <count> - // --include-in-progress` (stage undefined, includeInProgress true). - const nextResults = db.findNextWorkItems(itemCount, undefined, undefined, false, undefined, true); - const regular = (Array.isArray(nextResults) ? nextResults : []) - .filter((r: any) => r.workItem) - .map((r: any) => normalizeDbWorkItem(r.workItem)); - - // Mandatory subsets via db.list filters (db.list supports priority / - // status / stage filtering). Required because wl next caps at 32 items. - // Root-only (WL-0MS964SIA0057ABR): children are hidden from the - // top-level list; they remain visible via drill-down. - const criticalItems = db.list({ priority: 'critical', rootOnly: true }); - const reviewItems = db.list({ status: ['completed'], stage: 'in_review', rootOnly: true }); - const mandatory = mergeUniqueById( - (Array.isArray(criticalItems) ? criticalItems : []).map(normalizeDbWorkItem), - (Array.isArray(reviewItems) ? reviewItems : []).map(normalizeDbWorkItem), - ); - - const merged = mergeUniqueById(regular, mandatory); - const selected = selectWorkItems(merged, itemCount); - // Regroup so every displayed item receives a correct group assignment - // (WL-0MSAK8YLB0025EGW) — mirror of the CLI-backed path above. - return regroupWorkItems(selected); - } catch { - return defaultListWorkItems(); - } - }; -} - -/** - * Normalize a raw database WorkItem into a WorklogBrowseItem. - */ -function normalizeDbWorkItem(item: any): WorklogBrowseItem { - return { - id: item.id, - title: item.title, - status: item.status, - priority: item.priority || undefined, - stage: item.stage || undefined, - parentId: item.parentId != null ? String(item.parentId) : undefined, - risk: item.risk || undefined, - effort: item.effort || undefined, - description: item.description, - auditResult: item.auditResult !== undefined ? item.auditResult : undefined, - auditedAt: item.auditedAt !== undefined && item.auditedAt !== null ? String(item.auditedAt) : undefined, - needsProducerReview: item.needsProducerReview !== undefined ? Boolean(item.needsProducerReview) : undefined, - updatedAt: item.updatedAt ? String(item.updatedAt) : undefined, - issueType: item.issueType || undefined, - tags: item.tags?.length ? item.tags : undefined, - githubIssueNumber: item.githubIssueNumber, - }; -} - -/** - * Create a stage-filtered list function using direct SQLite access. - */ -export function createListWorkItemsWithStageDb( - count?: number, -): (stage: string) => Promise<WorklogBrowseItem[]> { - return async (stage: string): Promise<WorklogBrowseItem[]> => { - const itemCount = count ?? currentSettings.browseItemCount; - const db = await getDb(); - if (!db) return defaultListWorkItemsWithStage(stage); - try { - // Root-only (WL-0MS964SIA0057ABR): stage-filtered top-level lists hide - // child items; children remain reachable via drill-down (wl list --parent). - const items = db.list({ stage, rootOnly: true }); - if (!Array.isArray(items)) return defaultListWorkItemsWithStage(stage); - return items - .sort((a: any, b: any) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0)) - .map((item: any) => ({ - id: item.id, - title: item.title, - status: item.status, - priority: item.priority, - stage: item.stage || undefined, - risk: item.risk || undefined, - effort: item.effort || undefined, - description: item.description, - auditResult: item.auditResult !== undefined ? item.auditResult : undefined, - auditedAt: item.auditedAt !== undefined && item.auditedAt !== null ? String(item.auditedAt) : undefined, - needsProducerReview: item.needsProducerReview !== undefined ? Boolean(item.needsProducerReview) : undefined, - updatedAt: item.updatedAt ? String(item.updatedAt) : undefined, - issueType: item.issueType || undefined, - tags: item.tags?.length ? item.tags : undefined, - githubIssueNumber: item.githubIssueNumber, - })) - .slice(0, itemCount); - } catch { - return defaultListWorkItemsWithStage(stage); - } - }; -} - -/** - * Fetch the total actionable count using direct SQLite access. - */ -export async function fetchTotalActionableCountDb(): Promise<number | undefined> { - const db = await getDb(); - if (!db) return undefined; - try { - const all = db.getAll(); - if (!Array.isArray(all)) return undefined; - return all.filter( - (i: any) => i.status === 'open' || i.status === 'in-progress' || i.status === 'blocked' - ).length; - } catch { - return undefined; - } -} - -// ── Database-backed write operations (Phase 3) ─────────────────── - -/** - * Create a work item using direct SQLite access. - * Returns the created item's ID, or null on failure. - */ -export async function createWorkItemDb(title: string, description?: string): Promise<string | null> { - const db = await getDb(); - if (!db) return null; - try { - const created = db.create({ title: title || 'Untitled', description: description || title }); - return created?.id ?? null; - } catch { - return null; - } -} - -/** - * Update a work item using direct SQLite access. - * Returns true on success, false on failure. - */ -export async function updateWorkItemDb(id: string, updates: Record<string, unknown>): Promise<boolean> { - const db = await getDb(); - if (!db) return false; - try { - const result = db.update(id, updates); - return result !== null; - } catch { - return false; - } -} - -/** - * Close a work item using direct SQLite access. - * Returns true on success, false on failure. - */ -export async function closeWorkItemDb(id: string, reason?: string): Promise<boolean> { - const db = await getDb(); - if (!db) return false; - try { - const result = db.update(id, { status: 'completed', description: reason }); - return result !== null; - } catch { - return false; - } -} - -/** - * Add a comment to a work item using direct SQLite access. - * Returns the comment ID on success, or null on failure. - */ -export async function addCommentDb(workItemId: string, author: string, comment: string): Promise<string | null> { - const db = await getDb(); - if (!db) return null; - try { - const created = db.createComment({ workItemId, author, comment }); - return created?.id ?? null; - } catch { - return null; - } -} diff --git a/packages/tui/extensions/Worklog/settings-config.test.ts b/packages/tui/extensions/Worklog/settings-config.test.ts deleted file mode 100644 index 7b9a5d27b..000000000 --- a/packages/tui/extensions/Worklog/settings-config.test.ts +++ /dev/null @@ -1,1120 +0,0 @@ -/** - * Unit tests for settings-config.ts — settings loader and validator. - * - * Tests the Pi-based settings loading from global and project settings files - * under the `context-hub` namespace. - * - * Run: npx vitest run packages/tui/extensions/settings-config.test.ts - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { loadSettings, DEFAULT_SETTINGS } from './settings-config.js'; -import { WorklogConfig } from './config.js'; - -const mockReadFileSync = vi.hoisted(() => vi.fn()); -const mockWriteFileSync = vi.hoisted(() => vi.fn()); -const mockMkdirSync = vi.hoisted(() => vi.fn()); -const mockExistsSync = vi.hoisted(() => vi.fn(() => false)); - -// Track fs.watch calls for testing -const mockWatchClose = vi.hoisted(() => vi.fn()); -const mockWatchListeners: Array<{ path: string; handler: (event: string, filename: string | null) => void }> = []; -const mockWatch = vi.hoisted(() => vi.fn((path: string, handler: (event: string, filename: string | null) => void) => { - mockWatchListeners.push({ path, handler }); - return { close: mockWatchClose }; -})); - -vi.mock('node:fs', () => ({ - readFileSync: mockReadFileSync, - writeFileSync: mockWriteFileSync, - mkdirSync: mockMkdirSync, - existsSync: mockExistsSync, - watch: mockWatch, -})); - -vi.mock('@earendil-works/pi-coding-agent', () => ({ - getAgentDir: () => '/home/test-user/.pi/agent', -})); - -// A test helper that returns path as-is so we can match on it in mock -// implementations. The actual code uses `join()` which normalises paths, -// but for mocking we just need to know which file is being read. -const AGENT_DIR = '/home/test-user/.pi/agent'; -const CWD = '/home/test-user/projects/test-project'; -const PROJECT_PI_PATH = `${CWD}/.pi/settings.json`; -const GLOBAL_SETTINGS_PATH = `${AGENT_DIR}/settings.json`; - -describe('loadSettings', () => { - beforeEach(() => { - mockReadFileSync.mockReset(); - mockWriteFileSync.mockReset(); - mockMkdirSync.mockReset(); - mockWatch.mockReset(); - mockWatchClose.mockReset(); - mockWatchListeners.length = 0; - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('returns default settings when both settings files are missing', () => { - mockReadFileSync.mockImplementation(() => { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - - const settings = loadSettings(CWD, AGENT_DIR); - expect(settings).toEqual(DEFAULT_SETTINGS); - expect(settings.browseItemCount).toBe(5); - expect(settings.showIcons).toBe(true); - expect(settings.showActivityIndicator).toBe(true); - expect(settings.showHelpText).toBe(true); - }); - - it('reads settings from global settings file under context-hub namespace', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path === GLOBAL_SETTINGS_PATH) { - return JSON.stringify({ - 'context-hub': { - browseItemCount: 10, - showIcons: false, - }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - - const settings = loadSettings(CWD, AGENT_DIR); - expect(settings.browseItemCount).toBe(10); - expect(settings.showIcons).toBe(false); - // Falls back to defaults for values not set in global - expect(settings.showActivityIndicator).toBe(true); - expect(settings.showHelpText).toBe(true); - }); - - it('reads settings from project settings file under context-hub namespace', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path === PROJECT_PI_PATH) { - return JSON.stringify({ - 'context-hub': { - browseItemCount: 15, - showActivityIndicator: false, - }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - - const settings = loadSettings(CWD, AGENT_DIR); - expect(settings.browseItemCount).toBe(15); - expect(settings.showActivityIndicator).toBe(false); - // Falls back to defaults for values not set in project - expect(settings.showIcons).toBe(true); - expect(settings.showHelpText).toBe(true); - }); - - it('project settings override global settings', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path === GLOBAL_SETTINGS_PATH) { - return JSON.stringify({ - 'context-hub': { - browseItemCount: 10, - showIcons: false, - showActivityIndicator: false, - }, - }); - } - if (path === PROJECT_PI_PATH) { - return JSON.stringify({ - 'context-hub': { - browseItemCount: 20, - showIcons: true, - }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - - const settings = loadSettings(CWD, AGENT_DIR); - // Project values override global - expect(settings.browseItemCount).toBe(20); - expect(settings.showIcons).toBe(true); - // Global value for showActivityIndicator is not overridden by project - expect(settings.showActivityIndicator).toBe(false); - // Default for showHelpText since neither set it - expect(settings.showHelpText).toBe(true); - }); - - it('supports partial settings with defaults filling in missing fields', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path === PROJECT_PI_PATH) { - return JSON.stringify({ - 'context-hub': { - browseItemCount: 3, - }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - - const settings = loadSettings(CWD, AGENT_DIR); - expect(settings.browseItemCount).toBe(3); - expect(settings.showIcons).toBe(true); // default - expect(settings.showActivityIndicator).toBe(true); // default - expect(settings.showHelpText).toBe(true); // default - }); - - it('clamps browseItemCount to valid range [1, 50] from Pi settings', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path === PROJECT_PI_PATH) { - return JSON.stringify({ - 'context-hub': { browseItemCount: 0 }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - expect(loadSettings(CWD, AGENT_DIR).browseItemCount).toBe(1); - - mockReadFileSync.mockImplementation((path: string) => { - if (path === PROJECT_PI_PATH) { - return JSON.stringify({ - 'context-hub': { browseItemCount: -5 }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - expect(loadSettings(CWD, AGENT_DIR).browseItemCount).toBe(1); - - mockReadFileSync.mockImplementation((path: string) => { - if (path === PROJECT_PI_PATH) { - return JSON.stringify({ - 'context-hub': { browseItemCount: 100 }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - expect(loadSettings(CWD, AGENT_DIR).browseItemCount).toBe(50); - }); - - it('coerces string numeric browseItemCount to numbers', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path === PROJECT_PI_PATH) { - return JSON.stringify({ - 'context-hub': { browseItemCount: '8' }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - expect(loadSettings(CWD, AGENT_DIR).browseItemCount).toBe(8); - }); - - it('handles empty context-hub section in project settings', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path === PROJECT_PI_PATH) { - return JSON.stringify({ - 'context-hub': {}, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - const settings = loadSettings(CWD, AGENT_DIR); - expect(settings).toEqual(DEFAULT_SETTINGS); - }); - - it('handles malformed JSON in project settings file', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path === PROJECT_PI_PATH) { - return 'not valid json'; - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - - const settings = loadSettings(CWD, AGENT_DIR); - expect(settings).toEqual(DEFAULT_SETTINGS); - }); - - it('handles malformed JSON in global settings file', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path === GLOBAL_SETTINGS_PATH) { - return 'not valid json'; - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - - const settings = loadSettings(CWD, AGENT_DIR); - expect(settings).toEqual(DEFAULT_SETTINGS); - }); - - it('returns default showActivityIndicator when value is invalid', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path === PROJECT_PI_PATH) { - return JSON.stringify({ - 'context-hub': { showActivityIndicator: 'maybe' }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - expect(loadSettings(CWD, AGENT_DIR).showActivityIndicator).toBe(true); - }); - - it('returns default showHelpText when value is invalid', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path === PROJECT_PI_PATH) { - return JSON.stringify({ - 'context-hub': { showHelpText: null }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - expect(loadSettings(CWD, AGENT_DIR).showHelpText).toBe(true); - }); - - it('coerces string "true"/"false" for boolean settings', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path === PROJECT_PI_PATH) { - return JSON.stringify({ - 'context-hub': { - showActivityIndicator: 'false', - showHelpText: 'true', - }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - const settings = loadSettings(CWD, AGENT_DIR); - expect(settings.showActivityIndicator).toBe(false); - expect(settings.showHelpText).toBe(true); - }); - - it('handles null browseItemCount by using default', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path === PROJECT_PI_PATH) { - return JSON.stringify({ - 'context-hub': { browseItemCount: null }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - expect(loadSettings(CWD, AGENT_DIR).browseItemCount).toBe(5); - }); - - it('handles non-numeric browseItemCount by using default', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path === PROJECT_PI_PATH) { - return JSON.stringify({ - 'context-hub': { browseItemCount: 'abc' }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - expect(loadSettings(CWD, AGENT_DIR).browseItemCount).toBe(5); - }); - - it('reads autoInjectEnabled from project settings', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path === PROJECT_PI_PATH) { - return JSON.stringify({ - 'context-hub': { autoInjectEnabled: false }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - expect(loadSettings(CWD, AGENT_DIR).autoInjectEnabled).toBe(false); - }); - - it('autoInjectEnabled defaults to true when not set', () => { - mockReadFileSync.mockImplementation(() => { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - expect(loadSettings(CWD, AGENT_DIR).autoInjectEnabled).toBe(true); - }); - - it('coerces string "true"/"false" for autoInjectEnabled', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path === PROJECT_PI_PATH) { - return JSON.stringify({ - 'context-hub': { autoInjectEnabled: 'false' }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - expect(loadSettings(CWD, AGENT_DIR).autoInjectEnabled).toBe(false); - }); - - it('handles invalid autoInjectEnabled by using default', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path === PROJECT_PI_PATH) { - return JSON.stringify({ - 'context-hub': { autoInjectEnabled: 'maybe' }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - expect(loadSettings(CWD, AGENT_DIR).autoInjectEnabled).toBe(true); - }); - - it('handles null autoInjectEnabled by using default', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path === PROJECT_PI_PATH) { - return JSON.stringify({ - 'context-hub': { autoInjectEnabled: null }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - expect(loadSettings(CWD, AGENT_DIR).autoInjectEnabled).toBe(true); - }); - - it('ignores other namespace keys in Pi settings files', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path === PROJECT_PI_PATH) { - return JSON.stringify({ - 'llm-wiki': { notices: false }, - 'context-hub': { - browseItemCount: 7, - }, - 'other-namespace': { foo: 'bar' }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - const settings = loadSettings(CWD, AGENT_DIR); - expect(settings.browseItemCount).toBe(7); - expect(settings.showIcons).toBe(true); // default unaffected - }); - - it('uses default cwd and handles getAgentDir gracefully when not available', () => { - // When called without cwd/agentDir, loadSettings should use - // process.cwd() as fallback and try-catch getAgentDir errors. - // In the test environment, getAgentDir may throw. - // We just verify defaults are returned when files are missing. - mockReadFileSync.mockImplementation(() => { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - - const settings = loadSettings(); - expect(settings).toEqual(DEFAULT_SETTINGS); - }); -}); - -describe('Settings interface structure', () => { - it('DEFAULT_SETTINGS has the correct shape', () => { - expect(DEFAULT_SETTINGS).toEqual({ - browseItemCount: 5, - showIcons: true, - showActivityIndicator: true, - showHelpText: true, - autoInjectEnabled: true, - guardrailsEnabled: true, - autoSyncIntervalSeconds: 10, - version: 1, - }); - }); -}); - -// ── WorklogConfig hot-reload foundation ─────────────────────────────── - -describe('WorklogConfig — hot-reload foundation', () => { - beforeEach(() => { - mockReadFileSync.mockReset(); - mockWriteFileSync.mockReset(); - mockMkdirSync.mockReset(); - mockExistsSync.mockReset(); - mockWatch.mockReset(); - mockWatchClose.mockReset(); - mockWatchListeners.length = 0; - - // Default: all settings files missing - mockReadFileSync.mockImplementation(() => { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('config is loaded lazily on first get(), not at construction', () => { - // Construction shouldn't call readFileSync - const wc = new WorklogConfig(); - expect(mockReadFileSync).not.toHaveBeenCalled(); - - // First get() should trigger load - const config = wc.get(); - expect(config).toEqual(DEFAULT_SETTINGS); - expect(mockReadFileSync).toHaveBeenCalled(); - }); - - it('load() reads settings from disk using loadSettings', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path.includes('.pi/settings.json')) { - return JSON.stringify({ - 'context-hub': { browseItemCount: 15, showIcons: false }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - - const wc = new WorklogConfig(); - wc.load('/some/project'); - - const config = wc.get(); - expect(config.browseItemCount).toBe(15); - expect(config.showIcons).toBe(false); - }); - - it('get() returns a readonly view of the config (immutable)', () => { - const wc = new WorklogConfig(); - const config = wc.get(); - - // TypeScript enforces readonly at compile time; at runtime we verify - // the object is frozen or a copy that doesn't affect internal state. - expect(() => { - (config as any).browseItemCount = 99; - }).toThrow(); - }); - - it('update(partial) merges values into current config', () => { - const wc = new WorklogConfig(); - wc.get(); // trigger lazy load - - wc.update({ browseItemCount: 10, showIcons: false }); - const config = wc.get(); - expect(config.browseItemCount).toBe(10); - expect(config.showIcons).toBe(false); - // Unchanged fields should keep their defaults - expect(config.showActivityIndicator).toBe(true); - expect(config.showHelpText).toBe(true); - }); - - it('update(partial) persists settings via writeFileSync', () => { - const wc = new WorklogConfig(); - wc.get(); // trigger lazy load - - wc.update({ browseItemCount: 20 }); - expect(mockWriteFileSync).toHaveBeenCalled(); - }); - - it('update(partial) notifies onChange subscribers', () => { - const wc = new WorklogConfig(); - wc.get(); // trigger lazy load - - const callback = vi.fn(); - wc.onChange(callback); - - wc.update({ showIcons: false }); - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('onChange() returns a disposer that unsubscribes the callback', () => { - const wc = new WorklogConfig(); - wc.get(); // trigger lazy load - - const callback = vi.fn(); - const dispose = wc.onChange(callback); - - // Update triggers callback - wc.update({ browseItemCount: 7 }); - expect(callback).toHaveBeenCalledTimes(1); - - // Dispose removes the listener - dispose(); - wc.update({ browseItemCount: 10 }); - expect(callback).toHaveBeenCalledTimes(1); // still 1 - }); - - it('change notification propagates to all registered callbacks', () => { - const wc = new WorklogConfig(); - wc.get(); // trigger lazy load - - const cb1 = vi.fn(); - const cb2 = vi.fn(); - const cb3 = vi.fn(); - - wc.onChange(cb1); - wc.onChange(cb2); - wc.onChange(cb3); - - wc.update({ showActivityIndicator: false }); - expect(cb1).toHaveBeenCalledTimes(1); - expect(cb2).toHaveBeenCalledTimes(1); - expect(cb3).toHaveBeenCalledTimes(1); - }); - - it('supports multiple update() calls with correct state accumulation', () => { - const wc = new WorklogConfig(); - wc.get(); // trigger lazy load - - wc.update({ browseItemCount: 3 }); - expect(wc.get().browseItemCount).toBe(3); - - wc.update({ showIcons: false }); - expect(wc.get().browseItemCount).toBe(3); - expect(wc.get().showIcons).toBe(false); - - wc.update({ browseItemCount: 10, showHelpText: false }); - expect(wc.get().browseItemCount).toBe(10); - expect(wc.get().showIcons).toBe(false); - expect(wc.get().showHelpText).toBe(false); - }); - - it('is not affected by mutations of the returned get() object', () => { - const wc = new WorklogConfig(); - wc.get(); // trigger lazy load - - const config = wc.get(); - expect(config.browseItemCount).toBe(DEFAULT_SETTINGS.browseItemCount); - - wc.update({ browseItemCount: 25 }); - // The old reference should not reflect the change - expect(config.browseItemCount).toBe(DEFAULT_SETTINGS.browseItemCount); - }); - - it('load() with explicit cwd discards previously loaded config', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path.includes('project-a/.pi/settings.json')) { - return JSON.stringify({ - 'context-hub': { browseItemCount: 30 }, - }); - } - if (path.includes('project-b/.pi/settings.json')) { - return JSON.stringify({ - 'context-hub': { browseItemCount: 40 }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - - const wc = new WorklogConfig(); - wc.load('/path/to/project-a'); - expect(wc.get().browseItemCount).toBe(30); - - wc.load('/path/to/project-b'); - expect(wc.get().browseItemCount).toBe(40); - }); -}); - -// ── File watching tests ─────────────────────────────────────────────── - -describe('WorklogConfig — file watching', () => { - beforeEach(() => { - mockReadFileSync.mockReset(); - mockWriteFileSync.mockReset(); - mockMkdirSync.mockReset(); - mockWatch.mockReset(); - mockWatchClose.mockReset(); - mockWatchListeners.length = 0; - vi.useFakeTimers(); - - // Default: all settings files missing - mockReadFileSync.mockImplementation(() => { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - it('watchFile() calls fs.watch with the given path', () => { - const wc = new WorklogConfig(); - wc.load('/some/project'); - - wc.watchFile('/some/project/.pi/settings.json'); - expect(mockWatch).toHaveBeenCalledWith( - '/some/project/.pi/settings.json', - expect.any(Function), - ); - }); - - it('external file change triggers onChange subscribers after debounce', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path.includes('.pi/settings.json')) { - return JSON.stringify({ - 'context-hub': { browseItemCount: 10 }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - - const wc = new WorklogConfig(); - wc.load('/some/project'); - - const callback = vi.fn(); - wc.onChange(callback); - - wc.watchFile('/some/project/.pi/settings.json'); - - // Simulate a file change event - expect(mockWatchListeners.length).toBe(1); - const handler = mockWatchListeners[0].handler; - - // Change the file content for reload - mockReadFileSync.mockImplementation((path: string) => { - if (path.includes('.pi/settings.json')) { - return JSON.stringify({ - 'context-hub': { browseItemCount: 20 }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - - handler('change', 'settings.json'); - - // Should not fire immediately (debounced) - expect(callback).not.toHaveBeenCalled(); - - // Advance timers past debounce delay - vi.advanceTimersByTime(300); - - expect(callback).toHaveBeenCalledTimes(1); - expect(wc.get().browseItemCount).toBe(20); - }); - - it('debouncing coalesces rapid successive writes', () => { - // Track content version: each call to readFileSync for .pi/settings.json - // returns an incrementing version to simulate actual edits. - let version = 1; - - // Initial load: return version 1 - mockReadFileSync.mockImplementation((path: string) => { - if (path.includes('.pi/settings.json')) { - return JSON.stringify({ - 'context-hub': { browseItemCount: 10 + version }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - - const wc = new WorklogConfig(); - wc.load('/some/project'); - // After load, _config has browseItemCount=11 (10+1) - - const callback = vi.fn(); - wc.onChange(callback); - wc.watchFile('/some/project/.pi/settings.json'); - - expect(mockWatchListeners.length).toBe(1); - const handler = mockWatchListeners[0].handler; - - // Now increment version so file content is different - version = 5; - - // Rapid successive writes (editor auto-save style) - handler('change', 'settings.json'); - vi.advanceTimersByTime(50); - handler('change', 'settings.json'); - vi.advanceTimersByTime(100); - handler('change', 'settings.json'); - - // Should not have fired yet (debounced) - expect(callback).not.toHaveBeenCalled(); - - // Advance past the debounce window - vi.advanceTimersByTime(300); - - // Should have fired exactly once (last write wins) - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('dispose() closes all file watchers', () => { - const wc = new WorklogConfig(); - wc.load('/some/project'); - - wc.watchFile('/some/project/.pi/settings.json'); - wc.watchFile('/some/project/.pi/other.json'); - - expect(mockWatch).toHaveBeenCalledTimes(2); - - mockWatchClose.mockClear(); - wc.dispose(); - - expect(mockWatchClose).toHaveBeenCalledTimes(2); - }); - - it('dispose() clears all onChange subscribers', () => { - const wc = new WorklogConfig(); - wc.get(); - - const callback = vi.fn(); - wc.onChange(callback); - wc.dispose(); - - wc.update({ browseItemCount: 5 }); - expect(callback).not.toHaveBeenCalled(); - }); - - it('gracefully handles errors when watching a non-existent file', () => { - mockWatch.mockImplementation(() => { - throw new Error('ENOENT'); - }); - - const wc = new WorklogConfig(); - wc.load('/some/project'); - - // Should not throw - expect(() => { - wc.watchFile('/nonexistent/path/settings.json'); - }).not.toThrow(); - }); - - it('does not trigger onChange when file changes but values are unchanged', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path.includes('.pi/settings.json')) { - return JSON.stringify({ - 'context-hub': { browseItemCount: 10 }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - - const wc = new WorklogConfig(); - wc.load('/some/project'); - - const callback = vi.fn(); - wc.onChange(callback); - wc.watchFile('/some/project/.pi/settings.json'); - - expect(mockWatchListeners.length).toBe(1); - const handler = mockWatchListeners[0].handler; - - // Fire change event with same content - handler('change', 'settings.json'); - vi.advanceTimersByTime(300); - - // Should not fire onChange because content hasn't changed - expect(callback).not.toHaveBeenCalled(); - }); -}); - -// ── Runtime /wl settings update tests ────────────────────────────────── - -describe('WorklogConfig — runtime settings updates', () => { - beforeEach(() => { - mockReadFileSync.mockReset(); - mockWriteFileSync.mockReset(); - mockMkdirSync.mockReset(); - mockExistsSync.mockReset(); - - // Default: all settings files missing - mockReadFileSync.mockImplementation(() => { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('update() with full settings object replaces all values', () => { - const wc = new WorklogConfig(); - wc.get(); // trigger lazy load - - // Simulate /wl settings command applying a batch of changes - wc.update({ - browseItemCount: 10, - showIcons: false, - showActivityIndicator: false, - showHelpText: false, - autoInjectEnabled: false, - guardrailsEnabled: false, - autoSyncIntervalSeconds: 60, - }); - - const config = wc.get(); - expect(config.browseItemCount).toBe(10); - expect(config.showIcons).toBe(false); - expect(config.showActivityIndicator).toBe(false); - expect(config.showHelpText).toBe(false); - expect(config.autoInjectEnabled).toBe(false); - expect(config.guardrailsEnabled).toBe(false); - expect(config.autoSyncIntervalSeconds).toBe(60); - }); - - it('update() triggers onChange for all subscribed consumers', () => { - const wc = new WorklogConfig(); - wc.get(); // trigger lazy load - - const browseFlowCallback = vi.fn(); - const autoInjectCallback = vi.fn(); - const guardrailsCallback = vi.fn(); - const activityIndicatorCallback = vi.fn(); - - wc.onChange(browseFlowCallback); - wc.onChange(autoInjectCallback); - wc.onChange(guardrailsCallback); - wc.onChange(activityIndicatorCallback); - - // Simulate running /wl settings - wc.update({ browseItemCount: 8 }); - - expect(browseFlowCallback).toHaveBeenCalledTimes(1); - expect(autoInjectCallback).toHaveBeenCalledTimes(1); - expect(guardrailsCallback).toHaveBeenCalledTimes(1); - expect(activityIndicatorCallback).toHaveBeenCalledTimes(1); - }); - - it('values are immediately available via get() after update() without reload', () => { - // Mock such that the file on disk has different values than what we set - mockReadFileSync.mockImplementation((path: string) => { - if (path.includes('.pi/settings.json')) { - return JSON.stringify({ - 'context-hub': { browseItemCount: 3 }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - - const wc = new WorklogConfig(); - wc.load('/some/project'); - - // Initially loaded from file - expect(wc.get().browseItemCount).toBe(3); - - // Update at runtime — should override the on-disk value without reload - wc.update({ browseItemCount: 20 }); - - // Immediately available without any reload - expect(wc.get().browseItemCount).toBe(20); - - // Changing it again should reflect immediately - wc.update({ browseItemCount: 15 }); - expect(wc.get().browseItemCount).toBe(15); - }); - - it('partial update does not affect unchanged values', () => { - const wc = new WorklogConfig(); - wc.get(); - - wc.update({ browseItemCount: 25 }); - const config = wc.get(); - expect(config.browseItemCount).toBe(25); - expect(config.showIcons).toBe(true); // unchanged - expect(config.showActivityIndicator).toBe(true); // unchanged - expect(config.autoSyncIntervalSeconds).toBe(10); // unchanged - }); - - it('multiple runtime update calls accumulate correctly', () => { - const wc = new WorklogConfig(); - wc.get(); - - // Simulate step-by-step configuration changes - wc.update({ browseItemCount: 10 }); - expect(wc.get().browseItemCount).toBe(10); - - wc.update({ showIcons: false }); - expect(wc.get().browseItemCount).toBe(10); // preserved from earlier - expect(wc.get().showIcons).toBe(false); - - wc.update({ browseItemCount: 30, showHelpText: false }); - expect(wc.get().browseItemCount).toBe(30); - expect(wc.get().showIcons).toBe(false); // preserved from earlier - expect(wc.get().showHelpText).toBe(false); - }); - - it('does not throw on empty or undefined partial', () => { - const wc = new WorklogConfig(); - wc.get(); - - expect(() => wc.update({})).not.toThrow(); - expect(() => wc.update({} as any)).not.toThrow(); - - const config = wc.get(); - expect(config).toEqual(DEFAULT_SETTINGS); - }); -}); - -// ── Config validation tests ─────────────────────────────────────────── - -describe('WorklogConfig — config validation', () => { - beforeEach(() => { - mockReadFileSync.mockReset(); - mockWriteFileSync.mockReset(); - mockMkdirSync.mockReset(); - mockExistsSync.mockReset(); - - // Default: all settings files missing - mockReadFileSync.mockImplementation(() => { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('browseItemCount validation', () => { - it('clamps values below 1 to 1', () => { - const wc = new WorklogConfig(); - wc.get(); - wc.update({ browseItemCount: 0 }); - expect(wc.get().browseItemCount).toBe(1); - - wc.update({ browseItemCount: -5 }); - expect(wc.get().browseItemCount).toBe(1); - }); - - it('clamps values above 50 to 50', () => { - const wc = new WorklogConfig(); - wc.get(); - wc.update({ browseItemCount: 100 }); - expect(wc.get().browseItemCount).toBe(50); - }); - - it('accepts values within the valid range', () => { - const wc = new WorklogConfig(); - wc.get(); - wc.update({ browseItemCount: 1 }); - expect(wc.get().browseItemCount).toBe(1); - - wc.update({ browseItemCount: 25 }); - expect(wc.get().browseItemCount).toBe(25); - - wc.update({ browseItemCount: 50 }); - expect(wc.get().browseItemCount).toBe(50); - }); - - it('replaces non-numeric values with default', () => { - const wc = new WorklogConfig(); - wc.get(); - wc.update({ browseItemCount: null as any }); - expect(wc.get().browseItemCount).toBe(DEFAULT_SETTINGS.browseItemCount); - - wc.update({ browseItemCount: 'abc' as any }); - expect(wc.get().browseItemCount).toBe(DEFAULT_SETTINGS.browseItemCount); - - wc.update({ browseItemCount: undefined as any }); - expect(wc.get().browseItemCount).toBe(DEFAULT_SETTINGS.browseItemCount); - }); - }); - - describe('boolean settings validation', () => { - it('rejects non-boolean values for showIcons, falling back to default', () => { - const wc = new WorklogConfig(); - wc.get(); - wc.update({ showIcons: 'maybe' as any }); - expect(wc.get().showIcons).toBe(DEFAULT_SETTINGS.showIcons); - }); - - it('rejects null for showActivityIndicator', () => { - const wc = new WorklogConfig(); - wc.get(); - wc.update({ showActivityIndicator: null as any }); - expect(wc.get().showActivityIndicator).toBe(DEFAULT_SETTINGS.showActivityIndicator); - }); - - it('rejects undefined for showHelpText', () => { - const wc = new WorklogConfig(); - wc.get(); - wc.update({ showHelpText: undefined as any }); - expect(wc.get().showHelpText).toBe(DEFAULT_SETTINGS.showHelpText); - }); - - it('rejects non-boolean values for autoInjectEnabled', () => { - const wc = new WorklogConfig(); - wc.get(); - wc.update({ autoInjectEnabled: 'yes' as any }); - expect(wc.get().autoInjectEnabled).toBe(DEFAULT_SETTINGS.autoInjectEnabled); - }); - - it('rejects non-boolean values for guardrailsEnabled', () => { - const wc = new WorklogConfig(); - wc.get(); - wc.update({ guardrailsEnabled: 123 as any }); - expect(wc.get().guardrailsEnabled).toBe(DEFAULT_SETTINGS.guardrailsEnabled); - }); - - it('accepts valid boolean values', () => { - const wc = new WorklogConfig(); - wc.get(); - - wc.update({ showIcons: false }); - expect(wc.get().showIcons).toBe(false); - - wc.update({ showIcons: true }); - expect(wc.get().showIcons).toBe(true); - }); - }); - - describe('partial update validation', () => { - it('only validates the provided keys in a partial update', () => { - const wc = new WorklogConfig(); - wc.get(); - - // Set browseItemCount to a known value first - wc.update({ browseItemCount: 10 }); - expect(wc.get().browseItemCount).toBe(10); - - // Now update only autoInjectEnabled with an invalid value - wc.update({ autoInjectEnabled: 'invalid' as any }); - // browseItemCount should remain unchanged - expect(wc.get().browseItemCount).toBe(10); - // autoInjectEnabled should fall back to default - expect(wc.get().autoInjectEnabled).toBe(DEFAULT_SETTINGS.autoInjectEnabled); - }); - - it('does not modify valid values when other keys are invalid', () => { - const wc = new WorklogConfig(); - wc.get(); - - wc.update({ - browseItemCount: 25, - showIcons: 'bad' as any, - }); - - // Valid value should be applied - expect(wc.get().browseItemCount).toBe(25); - }); - - it('silently ignores unknown keys', () => { - const wc = new WorklogConfig(); - wc.get(); - - // Should not throw or corrupt state - wc.update({ unknownKey: 'value' } as any); - - // State should remain at defaults - expect(wc.get()).toEqual(DEFAULT_SETTINGS); - }); - }); - - describe('graceful degradation', () => { - it('does not throw when all values are invalid', () => { - const wc = new WorklogConfig(); - wc.get(); - - expect(() => { - wc.update({ - browseItemCount: 'not-a-number' as any, - showIcons: 'not-a-boolean' as any, - showActivityIndicator: null as any, - guardrailsEnabled: 42 as any, - }); - }).not.toThrow(); - - // All values should fall back to defaults - const config = wc.get(); - expect(config.browseItemCount).toBe(DEFAULT_SETTINGS.browseItemCount); - expect(config.showIcons).toBe(DEFAULT_SETTINGS.showIcons); - expect(config.showActivityIndicator).toBe(DEFAULT_SETTINGS.showActivityIndicator); - expect(config.guardrailsEnabled).toBe(DEFAULT_SETTINGS.guardrailsEnabled); - }); - - it('gracefully handles empty objects without modifying state', () => { - const wc = new WorklogConfig(); - wc.get(); - - wc.update({} as any); - expect(wc.get()).toEqual(DEFAULT_SETTINGS); - }); - - it('gracefully handles null partial (should not throw)', () => { - const wc = new WorklogConfig(); - wc.get(); - - // TypeScript would catch this, but at runtime it could happen - expect(() => wc.update(null as any)).not.toThrow(); - }); - }); -}); diff --git a/packages/tui/extensions/Worklog/settings-config.ts b/packages/tui/extensions/Worklog/settings-config.ts deleted file mode 100644 index 43bf9c3ca..000000000 --- a/packages/tui/extensions/Worklog/settings-config.ts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * Settings loader for the Worklog Pi extension. - * - * Reads settings from Pi's canonical settings files under the `context-hub` - * namespace. Resolution order (later wins): - * 1. Built-in defaults (DEFAULT_SETTINGS) - * 2. Global settings: ~/.pi/agent/settings.json → { "context-hub": { ... } } - * 3. Project settings: <project>/.pi/settings.json → { "context-hub": { ... } } - * - * The project settings directory is resolved repo-root aware via - * resolvePiDir() in src/worklog-paths.ts (same discovery as .worklog): - * walking up from the working directory to the git repo root, the nearest - * directory with a `.pi/settings.json` wins (local overrides repo root); - * otherwise the repo root is used. - * - * Follows the same namespaced-read pattern established by - * @zosmaai/pi-llm-wiki (see packages/llm-wiki/lib/task-config.ts). - * - * Config entry schema: - * - browseItemCount (number): Number of work items to show in the browse list (1–50, default: 5) - * - showIcons (boolean): Whether to show emoji icons in the browse list (default: true) - * - showActivityIndicator (boolean): Whether to show the activity indicator in the footer (default: true) - * - showHelpText (boolean): Whether to show the help text line in the browse selection overlay (default: true) - * - autoInjectEnabled (boolean): Whether to auto-inject relevant work items before agent turns (default: true) - * - guardrailsEnabled (boolean): Whether to enable guardrails that protect worklog data (default: true) - * - autoSyncIntervalSeconds (number): Auto-sync interval in seconds for TUI background sync (0–300, default: 10, 0 = disabled) - */ - -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { getAgentDir } from '@earendil-works/pi-coding-agent'; -import { resolvePiDir } from '../../../../src/worklog-paths.js'; -import { DEFAULT_RECOVERY_CONFIG, type RecoveryConfig } from './lib/recovery/error-patterns.js'; - -/** - * Settings interface for the Worklog Pi extension. - */ -export interface Settings { - /** Number of work items to show in the browse list (1–50). */ - browseItemCount: number; - /** Whether to show emoji icons in the browse list and preview widget. */ - showIcons: boolean; - /** Whether to show the activity indicator in the footer (⏵ prefix). */ - showActivityIndicator: boolean; - /** Whether to show the help text line in the browse selection overlay. */ - showHelpText: boolean; - /** Whether to auto-inject relevant work items into the system prompt before agent turns. */ - autoInjectEnabled: boolean; - /** Whether to enable guardrails that protect worklog database files from accidental modification. */ - guardrailsEnabled: boolean; - /** - * Auto-sync interval in seconds for TUI background sync. - * Controls how often the browse widget triggers a background `wl sync` during auto-refresh. - * Set to 0 to disable TUI auto-sync entirely. Range: 0–300. Default: 10. - */ - autoSyncIntervalSeconds: number; - /** - * Per-category recovery configuration for automatic error recovery. - * Maps each error category to its recovery behavior (retry, terminate, compact+continue). - * When not set, DEFAULT_RECOVERY_CONFIG from error-patterns.ts is used. - */ - recovery?: Partial<RecoveryConfig>; - /** - * Periodic request schedules. Each entry maps a cron expression to a pi request - * that is automatically submitted when the agent is idle. - */ - schedules?: Array<{ - id?: string; - cron: string; - request: string; - label?: string; - enabled?: boolean; - }>; - /** - * Config format version. Used for migration support when config structure - * changes between releases. Default: 1 (current version). - */ - version?: number; -} - -/** - * Default settings used when settings files are missing or values are not set. - */ -/** Current config format version. Increment when backward-incompatible changes are made. */ -export const CONFIG_VERSION = 1; - -export const DEFAULT_SETTINGS: Settings = { - browseItemCount: 5, - showIcons: true, - showActivityIndicator: true, - showHelpText: true, - autoInjectEnabled: true, - guardrailsEnabled: true, - autoSyncIntervalSeconds: 10, - recovery: undefined, - schedules: undefined, - version: CONFIG_VERSION, -}; - -/** Namespace key used in Pi settings files for Worklog extension settings. */ -const SETTINGS_NAMESPACE = 'context-hub'; - -/** - * Validate a parsed value as a number, clamping to [min, max]. - * - * Returns the clamped number if valid, or `defaultValue` if the input is - * not a valid finite number (including strings like "abc", null, undefined). - */ -export function validateNumber( - value: unknown, - defaultValue: number, - min: number, - max: number, -): number { - if (value === null || value === undefined) return defaultValue; - if (typeof value === 'string') { - const parsed = Number(value); - if (!Number.isFinite(parsed)) return defaultValue; - return Math.max(min, Math.min(max, parsed)); - } - if (typeof value === 'number' && Number.isFinite(value)) { - return Math.max(min, Math.min(max, value)); - } - return defaultValue; -} - -/** - * Validate a parsed value as a boolean. - * - * Accepts actual `true`/`false`, or the strings `"true"`/`"false"`. - * Returns `defaultValue` for any other value. - */ -export function validateBoolean(value: unknown, defaultValue: boolean): boolean { - if (value === true || value === false) return value; - if (value === 'true') return true; - if (value === 'false') return false; - return defaultValue; -} - -/** - * Read a JSON settings file as a plain object. - * - * Returns `{}` when the file is absent or corrupt. Uses a single - * try/catch (no `existsSync` pre-check) so there is no check-then-use - * race: a missing file throws ENOENT, which the catch treats the same - * as an empty file. - */ -function readSettingsObject(path: string): Record<string, unknown> { - try { - const parsed = JSON.parse(readFileSync(path, 'utf-8')); - if (parsed && typeof parsed === 'object') return parsed as Record<string, unknown>; - } catch { - // Missing or corrupt settings file: start from an empty object. - } - return {}; -} - -/** - * Read settings from a Pi settings file under the `context-hub` namespace. - * - * Extracts and validates only the Worklog extension settings fields from - * the namespaced section. Non-Worklog keys and other namespaces are ignored. - * - * @param path - Path to the Pi settings file - * @returns Partial settings if the file has a `context-hub` section, or `{}` - */ -function readNamespacedSettings(path: string): Partial<Settings> { - const raw = readSettingsObject(path); - const section = raw[SETTINGS_NAMESPACE]; - if (!section || typeof section !== 'object') return {}; - const ns = section as Record<string, unknown>; - - // Only include values that are explicitly set in the namespace section. - // Missing values should not override defaults or values from other sources - // (global → project resolution chain). - const result: Partial<Settings> = {}; - - if (ns.browseItemCount !== undefined) { - result.browseItemCount = validateNumber(ns.browseItemCount, DEFAULT_SETTINGS.browseItemCount, 1, 50); - } - if (ns.showIcons !== undefined) { - result.showIcons = validateBoolean(ns.showIcons, DEFAULT_SETTINGS.showIcons); - } - if (ns.showActivityIndicator !== undefined) { - result.showActivityIndicator = validateBoolean(ns.showActivityIndicator, DEFAULT_SETTINGS.showActivityIndicator); - } - if (ns.showHelpText !== undefined) { - result.showHelpText = validateBoolean(ns.showHelpText, DEFAULT_SETTINGS.showHelpText); - } - if (ns.autoInjectEnabled !== undefined) { - result.autoInjectEnabled = validateBoolean(ns.autoInjectEnabled, DEFAULT_SETTINGS.autoInjectEnabled); - } - if (ns.guardrailsEnabled !== undefined) { - result.guardrailsEnabled = validateBoolean(ns.guardrailsEnabled, DEFAULT_SETTINGS.guardrailsEnabled); - } - if (ns.autoSyncIntervalSeconds !== undefined) { - result.autoSyncIntervalSeconds = validateNumber(ns.autoSyncIntervalSeconds, DEFAULT_SETTINGS.autoSyncIntervalSeconds, 0, 300); - } - if (ns.recovery !== undefined && ns.recovery !== null && typeof ns.recovery === 'object') { - result.recovery = ns.recovery as Partial<RecoveryConfig>; - } - if (ns.schedules !== undefined && Array.isArray(ns.schedules)) { - // Pass through schedule entries as-is; validation happens in the scheduler module - result.schedules = ns.schedules as Settings['schedules']; - } - - return result; -} - -/** - * Load and validate settings from Pi's canonical settings files. - * - * Resolution order: - * 1. Built-in defaults (DEFAULT_SETTINGS) - * 2. Global settings: ~/.pi/agent/settings.json → { "context-hub": { ... } } - * 3. Project settings: <project>/.pi/settings.json → { "context-hub": { ... } } - * - * The project settings directory is resolved repo-root aware via - * resolvePiDir(): walking up from `cwd` toward the git repo root, the - * nearest directory with a `.pi/settings.json` wins (local overrides repo - * root); otherwise the repo root is used. - * - * Later sources override earlier ones (project wins over global, etc.). - * - * @param cwd - Project working directory (defaults to process.cwd()) - * @param agentDir - Pi agent directory (defaults to getAgentDir()) - * @returns A fully populated Settings object (no partials, never undefined) - */ -export function loadSettings(cwd?: string, agentDir?: string): Settings { - const projectDir = resolvePiDir(cwd ?? process.cwd()); - - // Resolve the Pi agent global settings directory. - // If getAgentDir() is unavailable (e.g., outside Pi runtime), skip global. - const globalDir: string = - agentDir ?? - (() => { - try { - return getAgentDir(); - } catch { - return ''; - } - })(); - - const globalPath = globalDir ? join(globalDir, 'settings.json') : ''; - const projectPath = join(projectDir, '.pi', 'settings.json'); - - return { - ...DEFAULT_SETTINGS, - ...(globalPath ? readNamespacedSettings(globalPath) : {}), - ...readNamespacedSettings(projectPath), - }; -} - -/** - * Persist settings to the project's `.pi/settings.json` under the - * `context-hub` namespace. - * - * The target project directory is resolved repo-root aware via - * resolvePiDir(): local `.pi/settings.json` in the working directory wins, - * otherwise settings are written to the git repo root's `.pi/settings.json` - * (project-level config shared across the repository). - * - * Reads the existing file (if any), merges the provided settings into the - * `context-hub` section while preserving other namespaces and keys, and - * writes the result back. Creates the `.pi/` directory if it does not exist. - * - * @param partial - Partial settings to persist - * @param cwd - Project working directory (defaults to process.cwd()) - */ -export function persistSettings(partial: Partial<Settings>, cwd?: string): void { - const projectDir = resolvePiDir(cwd ?? process.cwd()); - const settingsPath = join(projectDir, '.pi', 'settings.json'); - - try { - const raw = readSettingsObject(settingsPath); - - const existing = raw[SETTINGS_NAMESPACE]; - const section: Record<string, unknown> = - existing && typeof existing === 'object' - ? { ...(existing as Record<string, unknown>) } - : {}; - - // Update only the provided keys - if (partial.browseItemCount !== undefined) section.browseItemCount = partial.browseItemCount; - if (partial.showIcons !== undefined) section.showIcons = partial.showIcons; - if (partial.showActivityIndicator !== undefined) section.showActivityIndicator = partial.showActivityIndicator; - if (partial.showHelpText !== undefined) section.showHelpText = partial.showHelpText; - if (partial.autoInjectEnabled !== undefined) section.autoInjectEnabled = partial.autoInjectEnabled; - if (partial.guardrailsEnabled !== undefined) section.guardrailsEnabled = partial.guardrailsEnabled; - if (partial.autoSyncIntervalSeconds !== undefined) section.autoSyncIntervalSeconds = partial.autoSyncIntervalSeconds; - if (partial.recovery !== undefined) section.recovery = partial.recovery; - if (partial.schedules !== undefined) section.schedules = partial.schedules; - - raw[SETTINGS_NAMESPACE] = section; - - mkdirSync(dirname(settingsPath), { recursive: true }); - writeFileSync(settingsPath, `${JSON.stringify(raw, null, 2)}\n`, 'utf-8'); - } catch (err) { - console.error('[settings-config] Failed to persist settings:', err); - } -} diff --git a/packages/tui/extensions/Worklog/settings-persistence.test.ts b/packages/tui/extensions/Worklog/settings-persistence.test.ts deleted file mode 100644 index 727429ac4..000000000 --- a/packages/tui/extensions/Worklog/settings-persistence.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -/** - * Unit tests for settings persistence to Pi's .pi/settings.json. - * - * Verifies that: - * 1. createDefaultListWorkItems dynamically reads currentSettings.browseItemCount - * on each invocation, not at factory-creation time (fix for stale-capture bug). - * 2. createListWorkItemsWithStage has the same dynamic behavior. - * 3. updateSettings() correctly updates the module-level currentSettings, - * and factory functions pick up the new value on subsequent calls. - * 4. updateSettings() persists changes to .pi/settings.json under the - * context-hub namespace, preserving other keys. - * - * Run: npx vitest run packages/tui/extensions/settings-persistence.test.ts - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -/** - * Mock node:fs to prevent updateSettings() from writing to real - * settings files on disk, which would leak state into other test files - * (especially when tests run in parallel workers). - */ -const mockReadFileSync = vi.hoisted(() => - vi.fn(), -); -const mockWriteFileSync = vi.hoisted(() => vi.fn()); -const mockMkdirSync = vi.hoisted(() => vi.fn()); - -vi.mock('node:fs', () => ({ - readFileSync: mockReadFileSync, - writeFileSync: mockWriteFileSync, - mkdirSync: mockMkdirSync, - realpathSync: vi.fn((p) => p), - existsSync: vi.fn(() => false), -})); - -vi.mock('@earendil-works/pi-coding-agent', () => ({ - getAgentDir: () => '/home/test-user/.pi/agent', -})); - -import { - createDefaultListWorkItems, - createListWorkItemsWithStage, - updateSettings, -} from './index.js'; - -/** - * Reset module-level settings state to defaults before each test. - * Uses updateSettings which modifies currentSettings in memory; the - * mocked writeFileSync prevents filesystem side effects. - */ -beforeEach(() => { - // Default mock: global settings file doesn't exist, project settings file - // exists with basic settings. - mockReadFileSync.mockImplementation((path: string) => { - if (path.endsWith('.pi/settings.json')) { - return JSON.stringify({ - 'context-hub': { browseItemCount: 5, showIcons: true, showActivityIndicator: true, showHelpText: true }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - mockWriteFileSync.mockClear(); - mockMkdirSync.mockClear(); - // Reset to known defaults via updateSettings - updateSettings({ browseItemCount: 5, showIcons: true, showActivityIndicator: true, showHelpText: true }); -}); - -/** - * Create a mock run function that captures args and returns a valid empty - * response compatible with extractJsonObject/normalizeListPayload. - */ -function createMockRun() { - return vi.fn().mockResolvedValue('{"results":[]}'); -} - -describe('createDefaultListWorkItems', () => { - let mockRun: ReturnType<typeof createMockRun>; - - beforeEach(() => { - mockRun = createMockRun(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('uses currentSettings.browseItemCount when no explicit count is given', async () => { - const factory = createDefaultListWorkItems(mockRun); - await factory(); - - expect(mockRun).toHaveBeenCalledWith( - expect.arrayContaining(['-n', '5']), - ); - }); - - it('uses explicit count when provided, ignoring currentSettings', async () => { - const factory = createDefaultListWorkItems(mockRun, 3); - await factory(); - - expect(mockRun).toHaveBeenCalledWith( - expect.arrayContaining(['-n', '3']), - ); - }); - - it('dynamically reads updated currentSettings after factory creation', async () => { - const factory = createDefaultListWorkItems(mockRun); - - updateSettings({ browseItemCount: 10 }); - - await factory(); - - expect(mockRun).toHaveBeenCalledWith( - expect.arrayContaining(['-n', '10']), - ); - }); - - it('dynamically reads updated currentSettings on second call without recreation', async () => { - const factory = createDefaultListWorkItems(mockRun); - - await factory(); - // First call: wl next -n 5 (mandatory subset list queries follow). - expect(mockRun).toHaveBeenNthCalledWith(1, - expect.arrayContaining(['-n', '5']), - ); - - updateSettings({ browseItemCount: 15 }); - - await factory(); - // Second factory call: the wl next call is the 4th invocation overall - // (calls 1-3: next + 2 mandatory list queries from the first fetch). - expect(mockRun).toHaveBeenNthCalledWith(4, - expect.arrayContaining(['-n', '15']), - ); - }); -}); - -describe('createListWorkItemsWithStage', () => { - let mockRun: ReturnType<typeof createMockRun>; - - beforeEach(() => { - mockRun = createMockRun(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('uses currentSettings.browseItemCount when no explicit count is given', async () => { - const factory = createListWorkItemsWithStage(mockRun); - await factory('in_progress'); - - expect(mockRun).toHaveBeenCalledWith( - expect.arrayContaining(['-n', '5']), - ); - }); - - it('uses explicit count when provided', async () => { - const factory = createListWorkItemsWithStage(mockRun, 3); - await factory('in_progress'); - - expect(mockRun).toHaveBeenCalledWith( - expect.arrayContaining(['-n', '3']), - ); - }); - - it('passes stage argument to the run function', async () => { - const factory = createListWorkItemsWithStage(mockRun); - await factory('plan_complete'); - - expect(mockRun).toHaveBeenCalledWith( - expect.arrayContaining(['--stage', 'plan_complete']), - ); - }); - - it('dynamically reads updated currentSettings after factory creation', async () => { - const factory = createListWorkItemsWithStage(mockRun); - - updateSettings({ browseItemCount: 20 }); - - await factory('in_progress'); - - expect(mockRun).toHaveBeenCalledWith( - expect.arrayContaining(['-n', '20']), - ); - }); - - it('dynamically reads updated currentSettings on second call without recreation', async () => { - const factory = createListWorkItemsWithStage(mockRun); - - await factory('intake_complete'); - expect(mockRun).toHaveBeenNthCalledWith(1, - expect.arrayContaining(['-n', '5']), - ); - - updateSettings({ browseItemCount: 8 }); - await factory('in_review'); - expect(mockRun).toHaveBeenNthCalledWith(2, - expect.arrayContaining(['-n', '8']), - ); - }); -}); - -describe('updateSettings', () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - it('returns the updated settings object', () => { - const result = updateSettings({ browseItemCount: 42 }); - expect(result.browseItemCount).toBe(42); - }); - - it('preserves other settings fields when updating one field', () => { - const result = updateSettings({ browseItemCount: 7 }); - expect(result.showIcons).toBe(true); - }); - - it('persists multiple field updates', () => { - const result = updateSettings({ browseItemCount: 12, showIcons: false }); - expect(result.browseItemCount).toBe(12); - expect(result.showIcons).toBe(false); - }); - - it('writes to .pi/settings.json under context-hub namespace', () => { - mockReadFileSync.mockImplementation((path: string) => { - // Project settings file exists with some keys - if (path.endsWith('.pi/settings.json')) { - return JSON.stringify({ - 'llm-wiki': { notices: false }, - 'context-hub': { browseItemCount: 10, showIcons: false }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - mockWriteFileSync.mockClear(); - - updateSettings({ browseItemCount: 7, showActivityIndicator: false }); - - // First readFileSync call during updateSettings reads existing .pi/settings.json - // Then writeFileSync should be called with updated content - expect(mockWriteFileSync).toHaveBeenCalledTimes(1); - - const writeCall = mockWriteFileSync.mock.calls[0]; - const writtenPath = writeCall[0]; - expect(writtenPath).toContain('.pi/settings.json'); - - const writtenContent = JSON.parse(writeCall[1]); - // llm-wiki key should be preserved - expect(writtenContent['llm-wiki']).toEqual({ notices: false }); - // context-hub should have the merged settings - expect(writtenContent['context-hub'].browseItemCount).toBe(7); - expect(writtenContent['context-hub'].showIcons).toBe(false); // preserved from existing file - expect(writtenContent['context-hub'].showActivityIndicator).toBe(false); // newly set - // showHelpText was never set in existing config or partial, so it should not be present - expect(writtenContent['context-hub']).not.toHaveProperty('showHelpText'); - }); - - it('preserves other top-level keys when writing to .pi/settings.json', () => { - mockReadFileSync.mockImplementation((path: string) => { - if (path.endsWith('.pi/settings.json')) { - return JSON.stringify({ - 'llm-wiki': { notices: false, trajectories: true }, - 'context-hub': { browseItemCount: 5, showIcons: true }, - }); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - mockWriteFileSync.mockClear(); - - updateSettings({ showHelpText: false }); - - const writeCall = mockWriteFileSync.mock.calls[0]; - const writtenContent = JSON.parse(writeCall[1]); - // Other namespaces preserved - expect(writtenContent['llm-wiki']).toEqual({ notices: false, trajectories: true }); - expect(writtenContent['context-hub'].showHelpText).toBe(false); - }); - - it('creates the .pi directory if it does not exist', () => { - mockReadFileSync.mockImplementation(() => { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - mockWriteFileSync.mockClear(); - mockMkdirSync.mockClear(); - - updateSettings({ browseItemCount: 5 }); - - expect(mockMkdirSync).toHaveBeenCalled(); - // Should be called with recursive: true - const mkdirCall = mockMkdirSync.mock.calls[0]; - expect(mkdirCall[1]).toEqual({ recursive: true }); - }); - - it('handles write errors gracefully (no crash)', () => { - mockWriteFileSync.mockImplementation(() => { - throw new Error('Permission denied'); - }); - - // Should not throw - expect(() => updateSettings({ browseItemCount: 5 })).not.toThrow(); - }); -}); diff --git a/packages/tui/extensions/Worklog/shortcut-config-edge.test.ts b/packages/tui/extensions/Worklog/shortcut-config-edge.test.ts deleted file mode 100644 index ecf992913..000000000 --- a/packages/tui/extensions/Worklog/shortcut-config-edge.test.ts +++ /dev/null @@ -1,710 +0,0 @@ -/** - * Edge-case tests for shortcut-config.ts - missing file and malformed JSON. - * - * These tests mock fs.readFileSync at the module level so each test can - * provide different file content without the real shortcuts.json being loaded. - * - * Run: npx vitest run packages/tui/extensions/shortcut-config-edge.test.ts - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// Mock the fs module at the top level so loadShortcutConfig uses our mock -let readFileSyncBehavior: { type: 'empty' | 'valid' | 'malformed' | 'invalid'; content?: string } = { - type: 'empty', -}; - -vi.mock('node:fs', () => ({ - readFileSync: vi.fn((path: string, encoding: string) => { - if (readFileSyncBehavior.type === 'empty') { - throw Object.assign(new Error(`ENOENT: no such file or directory, open '${path}'`), { code: 'ENOENT' }); - } - if (readFileSyncBehavior.type === 'valid') { - return readFileSyncBehavior.content || '[]'; - } - if (readFileSyncBehavior.type === 'malformed') { - return readFileSyncBehavior.content || '{ not valid json'; - } - if (readFileSyncBehavior.type === 'invalid') { - return readFileSyncBehavior.content || '[]'; - } - return '[]'; - }), -})); - -import { - ShortcutRegistry, - loadShortcutConfig, - type ShortcutEntry, -} from './shortcut-config.js'; - -describe('loadShortcutConfig edge cases (fs.mocked)', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.restoreAllMocks(); - }); - - it('returns empty registry when shortcuts.json is missing (ENOENT)', () => { - readFileSyncBehavior = { type: 'empty' }; - const registry = loadShortcutConfig(); - expect(registry.getEntries()).toHaveLength(0); - }); - - it('returns empty registry with console.error for malformed JSON', () => { - const mockError = vi.spyOn(console, 'error').mockImplementation(() => {}); - readFileSyncBehavior = { type: 'malformed', content: '{ not valid json' }; - - const registry = loadShortcutConfig(); - - expect(mockError).toHaveBeenCalledWith( - expect.stringContaining('Malformed shortcuts.json'), - ); - expect(registry.getEntries()).toHaveLength(0); - mockError.mockRestore(); - }); - - it('skips entries with missing key field with console.warn', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { command: 'implement <id>', view: 'both' }, - { key: 'p', command: 'plan <id>', view: 'both' }, - ]), - }; - - const registry = loadShortcutConfig(); - - expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining('Skipping entry at index 0'), - ); - expect(registry.getEntries()).toHaveLength(1); - expect(registry.lookup('p', 'list')).toBe('plan <id>'); - mockWarn.mockRestore(); - }); - - it('skips entries with unknown view value with console.warn', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { key: 'i', command: 'implement <id>', view: 'both' }, - { key: 'x', command: 'unknown <id>', view: 'modal' }, - { key: 'p', command: 'plan <id>', view: 'both' }, - ]), - }; - - const registry = loadShortcutConfig(); - - expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining('unknown "view" value "modal"'), - ); - expect(registry.getEntries()).toHaveLength(2); - expect(registry.lookup('i', 'list')).toBe('implement <id>'); - expect(registry.lookup('x', 'list')).toBeUndefined(); - expect(registry.lookup('p', 'list')).toBe('plan <id>'); - mockWarn.mockRestore(); - }); - - it('returns empty registry when JSON array is empty', () => { - readFileSyncBehavior = { type: 'valid', content: '[]' }; - const registry = loadShortcutConfig(); - expect(registry.getEntries()).toHaveLength(0); - }); - - describe('stages field validation', () => { - it('accepts entries with valid stages array', () => { - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { key: 'n', command: 'intake <id>', view: 'both', stages: ['idea'] }, - ]), - }; - - const registry = loadShortcutConfig(); - expect(registry.getEntries()).toHaveLength(1); - expect(registry.getEntries()[0].stages).toEqual(['idea']); - }); - - it('accepts entries with multiple stages', () => { - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { key: 'x', command: 'custom <id>', view: 'both', stages: ['idea', 'in_progress'] }, - ]), - }; - - const registry = loadShortcutConfig(); - expect(registry.getEntries()).toHaveLength(1); - expect(registry.getEntries()[0].stages).toEqual(['idea', 'in_progress']); - }); - - it('skips entry when stages is not an array', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { key: 'n', command: 'intake <id>', view: 'both', stages: 'idea' }, - ]), - }; - - const registry = loadShortcutConfig(); - expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining('"stages" must be an array of strings'), - ); - expect(registry.getEntries()).toHaveLength(0); - mockWarn.mockRestore(); - }); - - it('accepts entry with empty stages array (treated as unconditional)', () => { - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { key: 'x', command: 'test <id>', view: 'both', stages: [] }, - ]), - }; - - const registry = loadShortcutConfig(); - expect(registry.getEntries()).toHaveLength(1); - expect(registry.getEntries()[0].stages).toBeUndefined(); - }); - - it('still loads valid entries alongside entries with invalid stages', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { key: 'i', command: 'implement <id>', view: 'both', stages: ['intake_complete'] }, - { key: 'x', command: 'bad <id>', view: 'both', stages: 'not-an-array' }, - { key: 'p', command: 'plan <id>', view: 'both' }, - ]), - }; - - const registry = loadShortcutConfig(); - expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining('"stages" must be an array of strings'), - ); - expect(registry.getEntries()).toHaveLength(2); - expect(registry.lookup('i', 'list')).toBe('implement <id>'); - expect(registry.lookup('p', 'list')).toBe('plan <id>'); - expect(registry.lookup('x', 'list')).toBeUndefined(); - mockWarn.mockRestore(); - }); - }); -}); - -// ─── Chord validation in loadShortcutConfig ───────────────────────────── -// -// These tests verify that loadShortcutConfig properly validates chord -// entries. They use the same mocked fs pattern as the tests above. -// - -describe('chord validation in loadShortcutConfig', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.restoreAllMocks(); - }); - - it('accepts entries with a valid chord array of 2+ strings', () => { - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { - chord: ['u', 'p'], - command: '!!wl update --priority', - view: 'both', - }, - { - chord: ['u', 't'], - command: '!!wl update --title', - view: 'both', - }, - ]), - }; - - const registry = loadShortcutConfig(); - const entries = registry.getEntries(); - expect(entries).toHaveLength(2); - - const upEntry = entries.find((e: any) => e.chord?.[0] === 'u' && e.chord?.[1] === 'p'); - expect(upEntry).toBeDefined(); - expect(upEntry!.command).toBe('!!wl update --priority'); - - const utEntry = entries.find((e: any) => e.chord?.[0] === 'u' && e.chord?.[1] === 't'); - expect(utEntry).toBeDefined(); - expect(utEntry!.command).toBe('!!wl update --title'); - }); - - it('rejects entries with chord array of fewer than 2 keys', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { - chord: ['u'], - command: '!!wl update --priority', - view: 'both', - }, - ]), - }; - - const registry = loadShortcutConfig(); - expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining('chord'), - ); - expect(registry.getEntries()).toHaveLength(0); - mockWarn.mockRestore(); - }); - - it('rejects entries with empty chord array', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { - chord: [], - command: '!!wl update --priority', - view: 'both', - }, - ]), - }; - - const registry = loadShortcutConfig(); - expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining('chord'), - ); - expect(registry.getEntries()).toHaveLength(0); - mockWarn.mockRestore(); - }); - - it('rejects entries with chord that is not an array', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { - chord: 'up', - command: '!!wl update --priority', - view: 'both', - }, - ]), - }; - - const registry = loadShortcutConfig(); - expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining('chord'), - ); - expect(registry.getEntries()).toHaveLength(0); - mockWarn.mockRestore(); - }); - - it('rejects entries that define both key and chord (mutual exclusivity)', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { - key: 'u', - chord: ['u', 'p'], - command: '!!wl update --priority', - view: 'both', - }, - ]), - }; - - const registry = loadShortcutConfig(); - expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining('key'), - ); - expect(registry.getEntries()).toHaveLength(0); - mockWarn.mockRestore(); - }); - - it('rejects entries with neither key nor chord field', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { - command: '!!wl update --priority', - view: 'both', - }, - ]), - }; - - const registry = loadShortcutConfig(); - expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining('missing'), - ); - expect(registry.getEntries()).toHaveLength(0); - mockWarn.mockRestore(); - }); - - it('accepts chord entries with optional label and description', () => { - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { - chord: ['u', 'p'], - command: '!!wl update --priority', - view: 'both', - label: 'update priority', - description: 'Update the priority of the selected work item', - }, - ]), - }; - - const registry = loadShortcutConfig(); - const entry = registry.getEntries()[0]; - expect(entry).toBeDefined(); - expect((entry as any).label).toBe('update priority'); - expect((entry as any).description).toBe( - 'Update the priority of the selected work item', - ); - }); - - it('accepts chord entries with stages array', () => { - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { - chord: ['u', 'p'], - command: '!!wl update --priority', - view: 'both', - stages: ['intake_complete', 'plan_complete'], - }, - ]), - }; - - const registry = loadShortcutConfig(); - const entry = registry.getEntries()[0]; - expect(entry).toBeDefined(); - expect(entry.stages).toEqual(['intake_complete', 'plan_complete']); - }); - - it('maintains backward compatibility with single-key entries when chord validation is present', () => { - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { key: 'i', command: 'implement <id>', view: 'both' }, - { key: 'p', command: 'plan <id>', view: 'both' }, - { - chord: ['u', 'p'], - command: 'update-priority <id>', - view: 'both', - }, - ]), - }; - - const registry = loadShortcutConfig(); - expect(registry.getEntries()).toHaveLength(3); - expect(registry.lookup('i', 'list')).toBe('implement <id>'); - expect(registry.lookup('p', 'list')).toBe('plan <id>'); - }); - - it('loads valid chord entries alongside valid key entries, skipping invalid ones', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { key: 'i', command: 'implement <id>', view: 'both' }, - { - chord: ['u', 'p'], - command: 'update-priority <id>', - view: 'both', - }, - { - chord: ['u'], - command: 'invalid-chord <id>', - view: 'both', - }, - { - key: 'x', - chord: ['x', 'y'], - command: 'both-fields <id>', - view: 'both', - }, - { key: 'p', command: 'plan <id>', view: 'both' }, - ]), - }; - - const registry = loadShortcutConfig(); - // The two invalid entries should be skipped, leaving 3 valid entries - expect(registry.getEntries()).toHaveLength(3); - expect(registry.lookup('i', 'list')).toBe('implement <id>'); - expect(registry.lookup('p', 'list')).toBe('plan <id>'); - - // The chord entry ('u','p') should have been loaded - const chordEntry = registry - .getEntries() - .find((e: any) => Array.isArray(e.chord)); - expect(chordEntry).toBeDefined(); - expect((chordEntry as any).chord).toEqual(['u', 'p']); - - expect(mockWarn).toHaveBeenCalledTimes(2); - mockWarn.mockRestore(); - }); - - it('chord entries accept view values list, detail, and both', () => { - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { - chord: ['u', 'p'], - command: 'update-priority <id>', - view: 'list', - }, - { - chord: ['u', 't'], - command: 'update-title <id>', - view: 'detail', - }, - { - chord: ['u', 's'], - command: 'update-status <id>', - view: 'both', - }, - ]), - }; - - const registry = loadShortcutConfig(); - expect(registry.getEntries()).toHaveLength(3); - }); - - it('rejects chord entry with invalid view value', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { - chord: ['u', 'p'], - command: 'update-priority <id>', - view: 'modal', - }, - ]), - }; - - const registry = loadShortcutConfig(); - expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining('unknown "view"'), - ); - expect(registry.getEntries()).toHaveLength(0); - mockWarn.mockRestore(); - }); -}); - -describe('duplicate key+view detection in loadShortcutConfig', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.restoreAllMocks(); - }); - - it('warns when two key-based entries share the same key and view', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { key: 'i', command: 'implement <id>', view: 'both' }, - { key: 'i', command: 'implement-again <id>', view: 'both' }, - ]), - }; - - const registry = loadShortcutConfig(); - - expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining('Duplicate shortcut'), - ); - // Both entries are still loaded (first wins at lookup time) - expect(registry.getEntries()).toHaveLength(2); - // First entry still wins (backward compatible) - expect(registry.lookup('i', 'list')).toBe('implement <id>'); - mockWarn.mockRestore(); - }); - - it('warns when two chord-based entries share the same chord and view', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { chord: ['u', 'p'], command: 'update-priority', view: 'both' }, - { chord: ['u', 'p'], command: 'update-priority-alt', view: 'both' }, - ]), - }; - - const registry = loadShortcutConfig(); - - expect(mockWarn).toHaveBeenCalledWith( - expect.stringContaining('Duplicate shortcut'), - ); - expect(registry.getEntries()).toHaveLength(2); - // First entry still wins - expect((registry as any).lookupChord(['u', 'p'], 'list')).toBe('update-priority'); - mockWarn.mockRestore(); - }); - - it('does NOT warn for entries with same key but different views', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { key: 'i', command: 'implement <id>', view: 'list' }, - { key: 'i', command: 'implement-detail <id>', view: 'detail' }, - ]), - }; - - const registry = loadShortcutConfig(); - - expect(mockWarn).not.toHaveBeenCalledWith( - expect.stringContaining('Duplicate shortcut'), - ); - expect(registry.getEntries()).toHaveLength(2); - expect(registry.lookup('i', 'list')).toBe('implement <id>'); - expect(registry.lookup('i', 'detail')).toBe('implement-detail <id>'); - mockWarn.mockRestore(); - }); - - it('does NOT warn for entries with different keys and same view', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { key: 'i', command: 'implement <id>', view: 'both' }, - { key: 'p', command: 'plan <id>', view: 'both' }, - ]), - }; - - const registry = loadShortcutConfig(); - - expect(mockWarn).not.toHaveBeenCalledWith( - expect.stringContaining('Duplicate shortcut'), - ); - expect(registry.getEntries()).toHaveLength(2); - mockWarn.mockRestore(); - }); - - it('warns separately for each duplicate pair', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { key: 'i', command: 'implement <id>', view: 'both' }, - { key: 'i', command: 'implement-alt <id>', view: 'both' }, - { key: 'p', command: 'plan <id>', view: 'both' }, - { key: 'p', command: 'plan-alt <id>', view: 'both' }, - ]), - }; - - const registry = loadShortcutConfig(); - - // Should have warned twice (one for 'i', one for 'p') - const duplicateWarnings = mockWarn.mock.calls.filter( - (call) => typeof call[0] === 'string' && call[0].includes('Duplicate shortcut'), - ); - expect(duplicateWarnings.length).toBe(2); - expect(registry.getEntries()).toHaveLength(4); - // First entries still win - expect(registry.lookup('i', 'list')).toBe('implement <id>'); - expect(registry.lookup('p', 'list')).toBe('plan <id>'); - mockWarn.mockRestore(); - }); - - it('detects mixed duplicates across key and chord entries separately', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { key: 'i', command: 'implement <id>', view: 'both' }, - { key: 'i', command: 'implement-alt <id>', view: 'both' }, - { chord: ['u', 'p'], command: 'update-priority', view: 'list' }, - { chord: ['u', 'p'], command: 'update-priority-alt', view: 'list' }, - ]), - }; - - const registry = loadShortcutConfig(); - - const duplicateWarnings = mockWarn.mock.calls.filter( - (call) => typeof call[0] === 'string' && call[0].includes('Duplicate shortcut'), - ); - expect(duplicateWarnings.length).toBe(2); - expect(registry.getEntries()).toHaveLength(4); - // First entries still win - expect(registry.lookup('i', 'list')).toBe('implement <id>'); - expect((registry as any).lookupChord(['u', 'p'], 'list')).toBe('update-priority'); - mockWarn.mockRestore(); - }); - - it('does NOT warn for unique chord+view combinations', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { chord: ['u', 'p'], command: 'update-priority', view: 'list' }, - { chord: ['u', 't'], command: 'update-title', view: 'list' }, - ]), - }; - - const registry = loadShortcutConfig(); - - expect(mockWarn).not.toHaveBeenCalledWith( - expect.stringContaining('Duplicate shortcut'), - ); - expect(registry.getEntries()).toHaveLength(2); - mockWarn.mockRestore(); - }); - - it('does not emit duplicate warning for the first occurrence of a unique combination', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { key: 'i', command: 'implement <id>', view: 'both' }, - { key: 'p', command: 'plan <id>', view: 'both' }, - { key: 'a', command: 'audit <id>', view: 'both' }, - ]), - }; - - const registry = loadShortcutConfig(); - - expect(mockWarn).not.toHaveBeenCalledWith( - expect.stringContaining('Duplicate shortcut'), - ); - expect(registry.getEntries()).toHaveLength(3); - mockWarn.mockRestore(); - }); - - it('warning message includes the shortcut key and view in the text', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { key: 'i', command: 'implement <id>', view: 'both' }, - { key: 'i', command: 'implement-alt <id>', view: 'both' }, - ]), - }; - - const registry = loadShortcutConfig(); - - expect(mockWarn).toHaveBeenCalledWith( - expect.stringMatching(/Duplicate shortcut.*i:both/i), - ); - mockWarn.mockRestore(); - }); - - it('warning includes the index of the duplicate entry', () => { - const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - readFileSyncBehavior = { - type: 'invalid', - content: JSON.stringify([ - { key: 'i', command: 'implement <id>', view: 'both' }, - { key: 'i', command: 'implement-alt <id>', view: 'both' }, - ]), - }; - - const registry = loadShortcutConfig(); - - expect(mockWarn).toHaveBeenCalledWith( - expect.stringMatching(/index\s+1/i), - ); - mockWarn.mockRestore(); - }); -}); diff --git a/packages/tui/extensions/Worklog/shortcut-config.test.ts b/packages/tui/extensions/Worklog/shortcut-config.test.ts deleted file mode 100644 index 1834b9632..000000000 --- a/packages/tui/extensions/Worklog/shortcut-config.test.ts +++ /dev/null @@ -1,1546 +0,0 @@ -/** - * Unit tests for shortcut-config.ts - config loader, registry, and dispatch. - * - * Run: npx vitest run packages/tui/extensions/shortcut-config.test.ts - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { - ShortcutRegistry, - loadShortcutConfig, - type ShortcutEntry, -} from './shortcut-config.js'; - -vi.mock('@earendil-works/pi-coding-agent', () => ({ - getAgentDir: () => '/home/test-user/.pi/agent', -})); - -describe('ShortcutRegistry', () => { - let registry: ShortcutRegistry; - - beforeEach(() => { - const entries: ShortcutEntry[] = [ - { key: 'i', command: 'implement <id>', view: 'both' }, - { key: 'p', command: 'plan <id>', view: 'list' }, - { key: 'a', command: 'audit <id>', view: 'detail' }, - { key: 'n', command: 'intake <id>', view: 'both' }, - ]; - registry = new ShortcutRegistry(entries); - }); - - describe('lookup(key, view)', () => { - it('returns the command for a matching key in "both" view', () => { - expect(registry.lookup('i', 'list')).toBe('implement <id>'); - expect(registry.lookup('i', 'detail')).toBe('implement <id>'); - }); - - it('returns the command for a matching key in its specific view', () => { - expect(registry.lookup('p', 'list')).toBe('plan <id>'); - expect(registry.lookup('p', 'detail')).toBeUndefined(); - - expect(registry.lookup('a', 'detail')).toBe('audit <id>'); - expect(registry.lookup('a', 'list')).toBeUndefined(); - }); - - it('returns undefined for an unregistered key', () => { - expect(registry.lookup('x', 'list')).toBeUndefined(); - expect(registry.lookup('x', 'detail')).toBeUndefined(); - }); - - it('returns undefined for an empty key', () => { - expect(registry.lookup('', 'list')).toBeUndefined(); - }); - - it('returns all entries via getEntries', () => { - const entries = registry.getEntries(); - expect(entries).toHaveLength(4); - expect(entries[0]).toEqual({ key: 'i', command: 'implement <id>', view: 'both' }); - }); - }); - - describe('lookup(key, view, stage)', () => { - it('returns command when entry has no stages constraint regardless of stage', () => { - expect(registry.lookup('i', 'list')).toBe('implement <id>'); - expect(registry.lookup('i', 'list', 'idea')).toBe('implement <id>'); - expect(registry.lookup('i', 'list', 'intake_complete')).toBe('implement <id>'); - expect(registry.lookup('i', 'list', 'in_progress')).toBe('implement <id>'); - }); - - it('returns command when stage matches an entry with stages allow-list', () => { - const stageEntries: ShortcutEntry[] = [ - { key: 'n', command: 'intake <id>', view: 'both', stages: ['idea'] }, - { key: 'i', command: 'implement <id>', view: 'both', stages: ['intake_complete'] }, - { key: 'a', command: 'audit <id>', view: 'both' }, - ]; - const reg = new ShortcutRegistry(stageEntries); - - // 'n' only works for idea stage (also works when no stage provided) - expect(reg.lookup('n', 'list', 'idea')).toBe('intake <id>'); - expect(reg.lookup('n', 'list', 'intake_complete')).toBeUndefined(); - expect(reg.lookup('n', 'list')).toBe('intake <id>'); // backward compat: no stage filter - - // 'i' only works for intake_complete stage - expect(reg.lookup('i', 'list', 'intake_complete')).toBe('implement <id>'); - expect(reg.lookup('i', 'list', 'idea')).toBeUndefined(); - expect(reg.lookup('i', 'list')).toBe('implement <id>'); // backward compat: no stage filter - - // 'a' (no stages) works unconditionally - expect(reg.lookup('a', 'list', 'idea')).toBe('audit <id>'); - expect(reg.lookup('a', 'list', 'intake_complete')).toBe('audit <id>'); - expect(reg.lookup('a', 'list')).toBe('audit <id>'); - }); - - it('returns command when stage is undefined and entry has stages (backward compat)', () => { - // When stage is explicitly undefined (not known), entries with stages - // still match for backward compatibility — the stage filter is only - // applied when a known stage string is provided. - const stageEntries: ShortcutEntry[] = [ - { key: 'n', command: 'intake <id>', view: 'both', stages: ['idea'] }, - { key: 'a', command: 'audit <id>', view: 'both' }, - ]; - const reg = new ShortcutRegistry(stageEntries); - - // When stage is undefined (unknown), the filter is skipped — backward compat - expect(reg.lookup('n', 'list', undefined)).toBe('intake <id>'); - expect(reg.lookup('a', 'list', undefined)).toBe('audit <id>'); - }); - - it('returns undefined when stage does not match entry with stages allow-list', () => { - const stageEntries: ShortcutEntry[] = [ - { key: 'p', command: 'plan <id>', view: 'both', stages: ['intake_complete'] }, - ]; - const reg = new ShortcutRegistry(stageEntries); - - expect(reg.lookup('p', 'list', 'idea')).toBeUndefined(); - expect(reg.lookup('p', 'list', 'plan_complete')).toBeUndefined(); - expect(reg.lookup('p', 'list', 'in_progress')).toBeUndefined(); - expect(reg.lookup('p', 'list', 'in_review')).toBeUndefined(); - expect(reg.lookup('p', 'list', '')).toBeUndefined(); - }); - - it('matches stage against multiple allowed stages', () => { - const multiStage: ShortcutEntry[] = [ - { key: 'x', command: 'custom <id>', view: 'both', stages: ['idea', 'in_progress'] }, - ]; - const reg = new ShortcutRegistry(multiStage); - - expect(reg.lookup('x', 'list', 'idea')).toBe('custom <id>'); - expect(reg.lookup('x', 'list', 'in_progress')).toBe('custom <id>'); - expect(reg.lookup('x', 'list', 'intake_complete')).toBeUndefined(); - expect(reg.lookup('x', 'list', 'plan_complete')).toBeUndefined(); - expect(reg.lookup('x', 'list', 'in_review')).toBeUndefined(); - }); - - it('still respects view filter combined with stage filter', () => { - const viewStageEntries: ShortcutEntry[] = [ - { key: 'i', command: 'implement <id>', view: 'list', stages: ['intake_complete'] }, - { key: 'i', command: 'implement-detail <id>', view: 'detail', stages: ['intake_complete'] }, - ]; - const reg = new ShortcutRegistry(viewStageEntries); - - expect(reg.lookup('i', 'list', 'intake_complete')).toBe('implement <id>'); - expect(reg.lookup('i', 'detail', 'intake_complete')).toBe('implement-detail <id>'); - expect(reg.lookup('i', 'list', 'idea')).toBeUndefined(); - expect(reg.lookup('i', 'detail', 'idea')).toBeUndefined(); - }); - }); - - describe('getEntriesForStage', () => { - it('returns all entries when no stage constraints and stage is undefined', () => { - const entries = registry.getEntriesForStage(undefined); - expect(entries).toHaveLength(4); - }); - - it('returns only entries matching the given stage', () => { - const stageEntries: ShortcutEntry[] = [ - { key: 'n', command: 'intake <id>', view: 'both', stages: ['idea'] }, - { key: 'i', command: 'implement <id>', view: 'both', stages: ['intake_complete'] }, - { key: 'a', command: 'audit <id>', view: 'both' }, - ]; - const reg = new ShortcutRegistry(stageEntries); - - const ideaEntries = reg.getEntriesForStage('idea'); - expect(ideaEntries).toHaveLength(2); - expect(ideaEntries.find(e => e.key === 'n')).toBeDefined(); - expect(ideaEntries.find(e => e.key === 'a')).toBeDefined(); - - const intakeCompleteEntries = reg.getEntriesForStage('intake_complete'); - expect(intakeCompleteEntries).toHaveLength(2); - expect(intakeCompleteEntries.find(e => e.key === 'i')).toBeDefined(); - expect(intakeCompleteEntries.find(e => e.key === 'a')).toBeDefined(); - - const unknownStageEntries = reg.getEntriesForStage('in_progress'); - expect(unknownStageEntries).toHaveLength(1); - expect(unknownStageEntries.find(e => e.key === 'a')).toBeDefined(); - }); - - it('returns only unconditional entries when stage is undefined and entries have stages constraints', () => { - const stageEntries: ShortcutEntry[] = [ - { key: 'n', command: 'intake <id>', view: 'both', stages: ['idea'] }, - { key: 'a', command: 'audit <id>', view: 'both' }, - ]; - const reg = new ShortcutRegistry(stageEntries); - - const entries = reg.getEntriesForStage(undefined); - expect(entries).toHaveLength(1); - expect(entries[0].key).toBe('a'); - }); - - it('returns entries with empty stages array unconditionally', () => { - const entriesWithEmptyStages: ShortcutEntry[] = [ - { key: 'x', command: 'test <id>', view: 'both', stages: [] }, - ]; - const reg = new ShortcutRegistry(entriesWithEmptyStages); - - expect(reg.getEntriesForStage('idea')).toHaveLength(1); - expect(reg.getEntriesForStage(undefined)).toHaveLength(1); - }); - }); - - describe('empty registry', () => { - it('returns undefined for all lookups', () => { - const empty = new ShortcutRegistry([]); - expect(empty.lookup('i', 'list')).toBeUndefined(); - expect(empty.lookup('i', 'detail')).toBeUndefined(); - }); - }); -}); - -describe('loadShortcutConfig', () => { - it('loads valid entries from shortcuts.json', () => { - const registry = loadShortcutConfig(); - const entries = registry.getEntries(); - expect(entries).toHaveLength(21); - - const createEntry = entries.find(e => e.key === 'c'); - expect(createEntry).toBeDefined(); - expect(createEntry!.command).toBe('/intake\n<desc>\nPriority: medium'); - expect(createEntry!.view).toBe('both'); - expect(createEntry!.stages).toBeUndefined(); - expect(createEntry!.label).toBe('create new'); - expect(createEntry!.description).toBe('Create a new work item with a description and priority.'); - - const implementEntry = entries.find(e => e.key === 'i'); - expect(implementEntry).toBeDefined(); - expect(implementEntry!.command).toBe('/skill:implement <id>'); - expect(implementEntry!.view).toBe('both'); - expect(implementEntry!.stages).toEqual(['intake_complete', 'plan_complete', 'in_progress']); - expect(implementEntry!.label).toBe('implement'); - expect(implementEntry!.description).toBe('Run the implement workflow on the selected work item'); - - const planEntry = entries.find(e => e.key === 'p'); - expect(planEntry).toBeDefined(); - expect(planEntry!.command).toBe('/plan <id>'); - expect(planEntry!.view).toBe('both'); - expect(planEntry!.stages).toEqual(['intake_complete']); - expect(planEntry!.label).toBe('plan'); - expect(planEntry!.description).toBe('Run the plan workflow on the selected work item'); - - const intakeEntry = entries.find(e => e.key === 'n'); - expect(intakeEntry).toBeDefined(); - expect(intakeEntry!.command).toBe('/intake <id>'); - expect(intakeEntry!.view).toBe('both'); - expect(intakeEntry!.stages).toEqual(['idea']); - expect(intakeEntry!.label).toBe('intake'); - expect(intakeEntry!.description).toBe('Ensure that the selected item is reasonably well defined in terms of objectives.'); - - expect(entries.filter(e => e.key === '').length).toBe(15); // 15 chord entries have empty key - }); - - it('has no duplicate key+view or chord+view combinations in shortcuts.json', () => { - const registry = loadShortcutConfig(); - const entries = registry.getEntries(); - - const seen = new Set<string>(); - const duplicates: string[] = []; - - for (const entry of entries) { - const chord = (entry as Record<string, unknown>).chord; - const composite = Array.isArray(chord) - ? `${(chord as string[]).join('+')}:${entry.view}` - : `${entry.key}:${entry.view}`; - - if (seen.has(composite)) { - duplicates.push(composite); - } else { - seen.add(composite); - } - } - - expect(duplicates).toEqual([]); - }); - - - it('resolves Producer Review shortcut from file with key r', () => { - const registry = loadShortcutConfig(); - const entry = registry.getEntries().find(e => e.key === 'r'); - expect(entry).toBeDefined(); - expect(entry!.command).toBe("!!wl reviewed <id> && wl comment add <id> --body '<producer_comment>'"); - expect(entry!.view).toBe('both'); - expect(entry!.label).toBe('Producer Review'); - expect(registry.lookup('r', 'list')).toBe("!!wl reviewed <id> && wl comment add <id> --body '<producer_comment>'"); - expect(registry.lookup('r', 'detail')).toBe("!!wl reviewed <id> && wl comment add <id> --body '<producer_comment>'"); - }); - - - it('lookup resolves shortcuts loaded from file with stage parameter', () => { - const registry = loadShortcutConfig(); - - // 'i' (implement) works for intake_complete, plan_complete, and in_progress stages - expect(registry.lookup('i', 'list', 'plan_complete')).toBe('/skill:implement <id>'); - expect(registry.lookup('i', 'detail', 'plan_complete')).toBe('/skill:implement <id>'); - expect(registry.lookup('i', 'list', 'intake_complete')).toBe('/skill:implement <id>'); - expect(registry.lookup('i', 'detail', 'intake_complete')).toBe('/skill:implement <id>'); - expect(registry.lookup('i', 'list', 'idea')).toBeUndefined(); - expect(registry.lookup('i', 'list', 'in_progress')).toBe('/skill:implement <id>'); - - // 'p' (plan) should only work for intake_complete stage - expect(registry.lookup('p', 'list', 'intake_complete')).toBe('/plan <id>'); - expect(registry.lookup('p', 'list', 'idea')).toBeUndefined(); - - // 'n' (intake) should only work for idea stage - expect(registry.lookup('n', 'detail', 'idea')).toBe('/intake <id>'); - expect(registry.lookup('n', 'detail', 'intake_complete')).toBeUndefined(); - - // 'a' is now a chord leader (a,a for audit, a+y for approve, a+r for reject), - // so single-key lookup for 'a' returns undefined - expect(registry.lookup('a', 'list', 'in_review')).toBeUndefined(); - expect(registry.lookup('a', 'detail', 'in_review')).toBeUndefined(); - expect(registry.lookup('a', 'list', 'in_progress')).toBeUndefined(); - - // Without stage parameter, entries with stages constraint still work - // (backward compatible when calling without stage) - expect(registry.lookup('n', 'detail')).toBe('/intake <id>'); - expect(registry.lookup('i', 'list')).toBe('/skill:implement <id>'); - expect(registry.lookup('p', 'list')).toBe('/plan <id>'); - }); - - it('loads chord entries from shortcuts.json', () => { - const registry = loadShortcutConfig(); - const entries = registry.getEntries(); - - const upChords = registry.getChordEntries(); - expect(upChords).toHaveLength(15); - - const upcEntry = upChords.find((e: any) => - Array.isArray((e as any).chord) && - (e as any).chord[0] === 'u' && - (e as any).chord[1] === 'p' && - (e as any).chord[2] === 'c', - ); - expect(upcEntry).toBeDefined(); - expect((upcEntry as any).chord).toEqual(['u', 'p', 'c']); - expect(upcEntry!.command).toBe('!!wl update <id> --priority critical'); - expect(upcEntry!.view).toBe('both'); - expect(upcEntry!.label).toBe('update priority critical'); - expect(upcEntry!.description).toBe('Update the priority of the selected work item to critical.'); - expect(upcEntry!.stages).toBeUndefined(); - - const uphEntry = upChords.find((e: any) => - Array.isArray((e as any).chord) && - (e as any).chord[0] === 'u' && - (e as any).chord[1] === 'p' && - (e as any).chord[2] === 'h', - ); - expect(uphEntry).toBeDefined(); - expect((uphEntry as any).chord).toEqual(['u', 'p', 'h']); - expect(uphEntry!.command).toBe('!!wl update <id> --priority high'); - expect(uphEntry!.view).toBe('both'); - expect(uphEntry!.label).toBe('update priority high'); - expect(uphEntry!.description).toBe('Update the priority of the selected work item to high.'); - expect(uphEntry!.stages).toBeUndefined(); - - const utEntry = upChords.find((e: any) => - Array.isArray((e as any).chord) && (e as any).chord[0] === 'u' && (e as any).chord[1] === 't', - ); - expect(utEntry).toBeDefined(); - expect((utEntry as any).chord).toEqual(['u', 't']); - expect(utEntry!.command).toBe('!!wl update <id> --title '); - expect(utEntry!.view).toBe('both'); - expect(utEntry!.label).toBe('update title'); - expect(utEntry!.description).toBe('Update the title of the selected work item'); - - // New close/delete chord entries - const xcEntry = upChords.find((e: any) => - Array.isArray((e as any).chord) && (e as any).chord[0] === 'x' && (e as any).chord[1] === 'c', - ); - expect(xcEntry).toBeDefined(); - expect((xcEntry as any).chord).toEqual(['x', 'c']); - expect(xcEntry!.command).toBe('!!wl close <id>'); - expect(xcEntry!.view).toBe('both'); - expect(xcEntry!.label).toBe('close done'); - expect(xcEntry!.description).toBe('Close the work item as done.'); - - const xdEntry = upChords.find((e: any) => - Array.isArray((e as any).chord) && (e as any).chord[0] === 'x' && (e as any).chord[1] === 'd', - ); - expect(xdEntry).toBeDefined(); - expect((xdEntry as any).chord).toEqual(['x', 'd']); - expect(xdEntry!.command).toBe('!!wl delete <id>'); - expect(xdEntry!.view).toBe('both'); - expect(xdEntry!.label).toBe('close deleted'); - expect(xdEntry!.description).toBe('Delete the work item.'); - - // New stage filter chord entries (f-i, f-n, f-p, f-r) - const fiEntry = upChords.find((e: any) => - Array.isArray((e as any).chord) && (e as any).chord[0] === 'f' && (e as any).chord[1] === 'i', - ); - expect(fiEntry).toBeDefined(); - expect((fiEntry as any).chord).toEqual(['f', 'i']); - expect(fiEntry!.command).toBe('/wl idea'); - expect(fiEntry!.view).toBe('both'); - expect(fiEntry!.label).toBe('filter idea'); - - const fnEntry = upChords.find((e: any) => - Array.isArray((e as any).chord) && (e as any).chord[0] === 'f' && (e as any).chord[1] === 'n', - ); - expect(fnEntry).toBeDefined(); - expect((fnEntry as any).chord).toEqual(['f', 'n']); - expect(fnEntry!.command).toBe('/wl intake'); - expect(fnEntry!.view).toBe('both'); - expect(fnEntry!.label).toBe('filter intake'); - - const fpEntry = upChords.find((e: any) => - Array.isArray((e as any).chord) && (e as any).chord[0] === 'f' && (e as any).chord[1] === 'p', - ); - expect(fpEntry).toBeDefined(); - expect((fpEntry as any).chord).toEqual(['f', 'p']); - expect(fpEntry!.command).toBe('/wl plan'); - expect(fpEntry!.view).toBe('both'); - expect(fpEntry!.label).toBe('filter plan'); - - const frEntry = upChords.find((e: any) => - Array.isArray((e as any).chord) && (e as any).chord[0] === 'f' && (e as any).chord[1] === 'r', - ); - expect(frEntry).toBeDefined(); - expect((frEntry as any).chord).toEqual(['f', 'r']); - expect(frEntry!.command).toBe('/wl review'); - expect(frEntry!.view).toBe('both'); - expect(frEntry!.label).toBe('filter in_review'); - }); - - it('returns empty registry for unregistered key', () => { - const registry = loadShortcutConfig(); - expect(registry.lookup('x', 'list')).toBeUndefined(); - }); - - it('getEntriesForStage returns correct subset from file with stage constraints', () => { - const registry = loadShortcutConfig(); - - const ideaEntries = registry.getEntriesForStage('idea'); - expect(ideaEntries.length).toBeGreaterThanOrEqual(2); // c, n - expect(ideaEntries.find(e => e.key === 'c')).toBeDefined(); - expect(ideaEntries.find(e => e.key === 'n')).toBeDefined(); - expect(ideaEntries.find(e => e.key === 'a')).toBeUndefined(); // 'a' requires in_review - expect(ideaEntries.find(e => e.key === 'i')).toBeUndefined(); - expect(ideaEntries.find(e => e.key === 'p')).toBeUndefined(); - - const intakeCompleteEntries = registry.getEntriesForStage('intake_complete'); - expect(intakeCompleteEntries.find(e => e.key === 'p')).toBeDefined(); - expect(intakeCompleteEntries.find(e => e.key === 'a')).toBeUndefined(); // 'a' requires in_review - expect(intakeCompleteEntries.find(e => e.key === 'c')).toBeDefined(); - expect(intakeCompleteEntries.find(e => e.key === 'n')).toBeUndefined(); - expect(intakeCompleteEntries.find(e => e.key === 'i')).toBeDefined(); // 'i' now includes intake_complete - - const planCompleteEntries = registry.getEntriesForStage('plan_complete'); - expect(planCompleteEntries.find(e => e.key === 'i')).toBeDefined(); - expect(planCompleteEntries.find(e => e.key === 'p')).toBeUndefined(); // 'p' requires intake_complete only - expect(planCompleteEntries.find(e => e.key === 'a')).toBeUndefined(); // 'a' requires in_review - expect(planCompleteEntries.find(e => e.key === 'c')).toBeDefined(); - - const inReviewEntries = registry.getEntriesForStage('in_review'); - expect(inReviewEntries.length).toBeGreaterThanOrEqual(2); // c (unconditional) + a - }); -}); - -describe('ShortcutRegistry unregistered keys (dispatcher)', () => { - it('returns undefined for unregistered key', () => { - const entries: ShortcutEntry[] = [ - { key: 'i', command: 'implement <id>', view: 'both' }, - { key: 'p', command: 'plan <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(entries); - - expect(registry.lookup('x', 'list')).toBeUndefined(); - expect(registry.lookup('y', 'detail')).toBeUndefined(); - expect(registry.lookup('zz', 'both')).toBeUndefined(); - }); -}); - -// ─── Chord schema, registry, and dispatch tests ───────────────────────── -// -// These tests describe the expected behaviour of chord shortcuts that will -// be implemented in F2 (schema), F3 (registry) and F4 (dispatch). Until -// those work items are complete, some tests use `as any` type assertions to -// permit referencing the `chord` property and chord methods that do not yet -// exist on the production types. Once F2/F3/F4 are landed the casts can be -// removed — the tests themselves act as the acceptance specification. -// - -describe('ShortcutEntry chord field', () => { - it('accepts entries with a chord field alongside existing fields', () => { - // Simulate a chord entry using `as any` until ShortcutEntry gains the - // optional `chord` field (F2). - const entry = { - chord: ['u', 'p'], - command: 'update-priority <id>', - view: 'both', - } as any; - - const registry = new ShortcutRegistry([entry]); - const entries = registry.getEntries(); - expect(entries).toHaveLength(1); - expect((entries[0] as any).chord).toEqual(['u', 'p']); - expect((entries[0] as any).command).toBe('update-priority <id>'); - expect((entries[0] as any).view).toBe('both'); - }); - - it('supports both key-based and chord-based entries in the same registry', () => { - const entries: any[] = [ - { key: 'i', command: 'implement <id>', view: 'both' }, - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - { chord: ['u', 't'], command: 'update-title <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(entries); - - // Single-key lookup still works for key-based entries - expect(registry.lookup('i', 'list')).toBe('implement <id>'); - // Single-key lookup should NOT match chord entries - expect(registry.lookup('u', 'list')).toBeUndefined(); - expect(registry.lookup('u', 'detail')).toBeUndefined(); - }); - - it('lookup returns undefined for the leader key of a chord entry', () => { - // A chord leader key (e.g. 'u') should NOT dispatch a command when - // pressed — it enters the pending-chord state instead. - const entries: any[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - { chord: ['u', 't'], command: 'update-title <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(entries); - - // Pressing 'u' alone must NOT trigger dispatch (no single-key 'u' entry) - expect(registry.lookup('u', 'list')).toBeUndefined(); - expect(registry.lookup('u', 'detail')).toBeUndefined(); - }); - - it('chord entries support optional fields (label, description, stages)', () => { - const entry: any = { - chord: ['u', 'p'], - command: 'update-priority <id>', - view: 'both', - label: 'update priority', - description: 'Update the priority of the selected work item', - stages: ['intake_complete', 'plan_complete'], - }; - const registry = new ShortcutRegistry([entry]); - const entries = registry.getEntries(); - expect((entries[0] as any).label).toBe('update priority'); - expect((entries[0] as any).description).toBe( - 'Update the priority of the selected work item', - ); - expect((entries[0] as any).stages).toEqual([ - 'intake_complete', - 'plan_complete', - ]); - }); -}); - -describe('getChordByLeader', () => { - it('returns chord entries whose first key matches the leader', () => { - const entries: any[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - { chord: ['u', 't'], command: 'update-title <id>', view: 'both' }, - { chord: ['w', 's'], command: 'workflow-status <id>', view: 'list' }, - ]; - const registry = new ShortcutRegistry(entries); - - const uChords = (registry as any).getChordByLeader('u'); - expect(uChords).toHaveLength(2); - expect(uChords[0].chord).toEqual(['u', 'p']); - expect(uChords[1].chord).toEqual(['u', 't']); - - const wChords = (registry as any).getChordByLeader('w'); - expect(wChords).toHaveLength(1); - expect(wChords[0].chord).toEqual(['w', 's']); - }); - - it('returns empty array for a leader key with no matching chords', () => { - const entries: any[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(entries); - - expect((registry as any).getChordByLeader('x')).toEqual([]); - expect((registry as any).getChordByLeader('')).toEqual([]); - }); - - it('returns empty array when no chord entries exist', () => { - const registry = new ShortcutRegistry([ - { key: 'i', command: 'implement <id>', view: 'both' }, - ]); - - expect((registry as any).getChordByLeader('u')).toEqual([]); - }); - - it('returns empty array from empty registry', () => { - const registry = new ShortcutRegistry([]); - expect((registry as any).getChordByLeader('u')).toEqual([]); - }); - - it('filters chord entries by view when view argument is provided', () => { - const entries: any[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'list' }, - { chord: ['u', 't'], command: 'update-title <id>', view: 'detail' }, - { chord: ['u', 's'], command: 'update-status <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(entries); - - // Call with both view and list — should only return list + both entries - const listChords = (registry as any).getChordByLeader('u', 'list'); - expect(listChords).toHaveLength(2); - expect(listChords[0].chord).toEqual(['u', 'p']); - expect(listChords[1].chord).toEqual(['u', 's']); - - const detailChords = (registry as any).getChordByLeader('u', 'detail'); - expect(detailChords).toHaveLength(2); - expect(detailChords[0].chord).toEqual(['u', 't']); - expect(detailChords[1].chord).toEqual(['u', 's']); - }); - - it('getChordByLeader without view argument returns all matching chords regardless of view', () => { - const entries: any[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'list' }, - { chord: ['u', 't'], command: 'update-title <id>', view: 'detail' }, - ]; - const registry = new ShortcutRegistry(entries); - - const allChords = (registry as any).getChordByLeader('u'); - expect(allChords).toHaveLength(2); - }); -}); - -describe('lookupChord', () => { - it('returns the command for an exact chord match', () => { - const entries: any[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - { chord: ['u', 't'], command: 'update-title <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(entries); - - expect((registry as any).lookupChord(['u', 'p'], 'list')).toBe( - 'update-priority <id>', - ); - expect((registry as any).lookupChord(['u', 't'], 'detail')).toBe( - 'update-title <id>', - ); - }); - - it('respects view filter', () => { - const entries: any[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'list' }, - { chord: ['u', 't'], command: 'update-title <id>', view: 'detail' }, - { chord: ['u', 's'], command: 'update-status <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(entries); - - // list view should only match 'list' and 'both' entries - expect((registry as any).lookupChord(['u', 'p'], 'list')).toBe( - 'update-priority <id>', - ); - expect((registry as any).lookupChord(['u', 'p'], 'detail')).toBeUndefined(); - - // detail view should only match 'detail' and 'both' entries - expect((registry as any).lookupChord(['u', 't'], 'detail')).toBe( - 'update-title <id>', - ); - expect((registry as any).lookupChord(['u', 't'], 'list')).toBeUndefined(); - - // 'both' entries should match either view - expect((registry as any).lookupChord(['u', 's'], 'list')).toBe( - 'update-status <id>', - ); - expect((registry as any).lookupChord(['u', 's'], 'detail')).toBe( - 'update-status <id>', - ); - }); - - it('respects stage filter', () => { - const entries: any[] = [ - { - chord: ['u', 'p'], - command: 'update-priority <id>', - view: 'both', - stages: ['intake_complete', 'plan_complete'], - }, - ]; - const registry = new ShortcutRegistry(entries); - - // Matching stages - expect( - (registry as any).lookupChord(['u', 'p'], 'list', 'intake_complete'), - ).toBe('update-priority <id>'); - expect( - (registry as any).lookupChord(['u', 'p'], 'list', 'plan_complete'), - ).toBe('update-priority <id>'); - - // Non-matching stage - expect( - (registry as any).lookupChord(['u', 'p'], 'list', 'idea'), - ).toBeUndefined(); - }); - - it('returns undefined when stage is provided but chord entry has no stages constraint', () => { - // If a chord entry has no stages constraint, it should still match - // when a stage is provided (backward compatible). - const entries: any[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(entries); - - expect( - (registry as any).lookupChord(['u', 'p'], 'list', 'idea'), - ).toBe('update-priority <id>'); - expect( - (registry as any).lookupChord(['u', 'p'], 'list', 'intake_complete'), - ).toBe('update-priority <id>'); - }); - - it('returns undefined for chords that do not match any entry', () => { - const entries: any[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(entries); - - expect((registry as any).lookupChord(['x', 'y'], 'list')).toBeUndefined(); - expect((registry as any).lookupChord(['u', 'x'], 'list')).toBeUndefined(); - expect((registry as any).lookupChord(['a', 'b', 'c'], 'list')).toBeUndefined(); - }); - - it('returns undefined for chords with wrong number of keys', () => { - const entries: any[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(entries); - - // Single key (should not match a 2-key chord) - expect((registry as any).lookupChord(['u'], 'list')).toBeUndefined(); - // Three keys (over-long) - expect((registry as any).lookupChord(['u', 'p', 'x'], 'list')).toBeUndefined(); - }); - - it('returns undefined when no chord entries exist', () => { - const registry = new ShortcutRegistry([ - { key: 'i', command: 'implement <id>', view: 'both' }, - ]); - - expect((registry as any).lookupChord(['u', 'p'], 'list')).toBeUndefined(); - }); - - it('combines view and stage filters together', () => { - const entries: any[] = [ - { - chord: ['u', 'p'], - command: 'update-priority <id>', - view: 'list', - stages: ['intake_complete'], - }, - { - chord: ['u', 'p'], - command: 'update-priority-detail <id>', - view: 'detail', - stages: ['intake_complete'], - }, - ]; - const registry = new ShortcutRegistry(entries); - - // Match both view and stage - expect( - (registry as any).lookupChord(['u', 'p'], 'list', 'intake_complete'), - ).toBe('update-priority <id>'); - expect( - (registry as any).lookupChord(['u', 'p'], 'detail', 'intake_complete'), - ).toBe('update-priority-detail <id>'); - - // Wrong view - expect( - (registry as any).lookupChord(['u', 'p'], 'list', 'idea'), - ).toBeUndefined(); - - // Wrong stage - expect( - (registry as any).lookupChord(['u', 'p'], 'detail', 'idea'), - ).toBeUndefined(); - }); -}); - -describe('getChordByPrefix', () => { - it('returns chord entries whose chord array starts with the given prefix', () => { - const entries: any[] = [ - { chord: ['u', 'p', 'c'], command: 'priority-critical <id>', view: 'both' }, - { chord: ['u', 'p', 'h'], command: 'priority-high <id>', view: 'both' }, - { chord: ['u', 's'], command: 'status <id>', view: 'both' }, - { chord: ['u', 't'], command: 'title <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(entries); - - const upPrefix = (registry as any).getChordByPrefix(['u', 'p']); - expect(upPrefix).toHaveLength(2); - expect(upPrefix[0].chord).toEqual(['u', 'p', 'c']); - expect(upPrefix[1].chord).toEqual(['u', 'p', 'h']); - - const uPrefix = (registry as any).getChordByPrefix(['u']); - expect(uPrefix).toHaveLength(4); - - const upcExact = (registry as any).getChordByPrefix(['u', 'p', 'c']); - expect(upcExact).toHaveLength(1); - expect(upcExact[0].chord).toEqual(['u', 'p', 'c']); - }); - - it('returns empty array when no chord entries start with the given prefix', () => { - const entries: any[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(entries); - - expect((registry as any).getChordByPrefix(['x'])).toEqual([]); - expect((registry as any).getChordByPrefix(['u', 'x'])).toEqual([]); - expect((registry as any).getChordByPrefix(['a', 'b', 'c'])).toEqual([]); - }); - - it('returns empty array when no chord entries exist', () => { - const registry = new ShortcutRegistry([ - { key: 'i', command: 'implement <id>', view: 'both' }, - ]); - - expect((registry as any).getChordByPrefix(['u'])).toEqual([]); - }); - - it('returns empty array on empty registry', () => { - const registry = new ShortcutRegistry([]); - expect((registry as any).getChordByPrefix([])).toEqual([]); - }); - - it('filters by view when view argument is provided', () => { - const entries: any[] = [ - { chord: ['u', 'p', 'c'], command: 'priority-critical <id>', view: 'list' }, - { chord: ['u', 'p', 'h'], command: 'priority-high <id>', view: 'detail' }, - { chord: ['u', 'p', 'm'], command: 'priority-medium <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(entries); - - const listChords = (registry as any).getChordByPrefix(['u', 'p'], 'list'); - expect(listChords).toHaveLength(2); - expect(listChords[0].chord).toEqual(['u', 'p', 'c']); - expect(listChords[1].chord).toEqual(['u', 'p', 'm']); - - const detailChords = (registry as any).getChordByPrefix(['u', 'p'], 'detail'); - expect(detailChords).toHaveLength(2); - expect(detailChords[0].chord).toEqual(['u', 'p', 'h']); - expect(detailChords[1].chord).toEqual(['u', 'p', 'm']); - }); - - it('getChordByPrefix without view argument returns all matches regardless of view', () => { - const entries: any[] = [ - { chord: ['u', 'p', 'c'], command: 'priority-critical <id>', view: 'list' }, - { chord: ['u', 'p', 'h'], command: 'priority-high <id>', view: 'detail' }, - ]; - const registry = new ShortcutRegistry(entries); - - const allChords = (registry as any).getChordByPrefix(['u', 'p']); - expect(allChords).toHaveLength(2); - }); - - it('respects stage filter', () => { - const entries: any[] = [ - { - chord: ['u', 'p', 'c'], - command: 'priority-critical <id>', - view: 'both', - stages: ['intake_complete', 'plan_complete'], - }, - { - chord: ['u', 'p', 'h'], - command: 'priority-high <id>', - view: 'both', - stages: ['intake_complete'], - }, - ]; - const registry = new ShortcutRegistry(entries); - - const allForPrefix = (registry as any).getChordByPrefix(['u', 'p']); - expect(allForPrefix).toHaveLength(2); - - const filtered = (registry as any).getChordByPrefix(['u', 'p'], undefined, 'intake_complete'); - expect(filtered).toHaveLength(2); - - const planFiltered = (registry as any).getChordByPrefix(['u', 'p'], undefined, 'plan_complete'); - expect(planFiltered).toHaveLength(1); - expect(planFiltered[0].chord).toEqual(['u', 'p', 'c']); - }); -}); - -describe('chord backward compatibility', () => { - it('single-key shortcuts work identically when chords are present', () => { - const entries: any[] = [ - { key: 'i', command: 'implement <id>', view: 'both' }, - { key: 'p', command: 'plan <id>', view: 'both' }, - { key: 'a', command: 'audit <id>', view: 'both' }, - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - { chord: ['u', 't'], command: 'update-title <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(entries); - - // All single-key lookups must still work - expect(registry.lookup('i', 'list')).toBe('implement <id>'); - expect(registry.lookup('p', 'list')).toBe('plan <id>'); - expect(registry.lookup('a', 'list')).toBe('audit <id>'); - expect(registry.lookup('i', 'detail')).toBe('implement <id>'); - }); - - it('stage-filtered lookups still work when chords are present', () => { - const entries: any[] = [ - { - key: 'n', - command: 'intake <id>', - view: 'both', - stages: ['idea'], - }, - { - key: 'i', - command: 'implement <id>', - view: 'both', - stages: ['intake_complete'], - }, - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(entries); - - expect(registry.lookup('n', 'list', 'idea')).toBe('intake <id>'); - expect(registry.lookup('n', 'list', 'intake_complete')).toBeUndefined(); - expect(registry.lookup('i', 'list', 'intake_complete')).toBe( - 'implement <id>', - ); - expect(registry.lookup('i', 'list', 'idea')).toBeUndefined(); - }); - - it('view filters still work for single-key entries when chords are present', () => { - const entries: any[] = [ - { key: 'p', command: 'plan <id>', view: 'list' }, - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(entries); - - expect(registry.lookup('p', 'list')).toBe('plan <id>'); - expect(registry.lookup('p', 'detail')).toBeUndefined(); - }); - - it('getEntriesForStage still works correctly when chords are present', () => { - const entries: any[] = [ - { key: 'a', command: 'audit <id>', view: 'both' }, - { - key: 'n', - command: 'intake <id>', - view: 'both', - stages: ['idea'], - }, - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(entries); - - // getEntriesForStage should include all entries - const allEntries = registry.getEntriesForStage('idea'); - expect(allEntries).toHaveLength(3); - }); -}); - -describe('chord dispatch integration (browse view)', () => { - /** - * Helper: create a mock BrowseContext with a custom() implementation that - * captures the widget factory and exposes it for test-driven interaction. - * The test can then call widget.handleInput() and inspect widget.render(). - */ - function createChordMockContext() { - type DoneFn = (value: any) => void; - - let capturedFactory: (( - tui: any, - theme: any, - _keybindings: unknown, - done: DoneFn, - ) => { - render: (width: number) => string[]; - invalidate: () => void; - handleInput?: (data: string) => void; - }) | null = null; - - let capturedDone: DoneFn | null = null; - let doneResult: any = undefined; - let doneCalled = false; - - const tui = { - requestRender: vi.fn(), - }; - const theme = { - fg: vi.fn((_color: string, text: string) => text), - bold: vi.fn((text: string) => text), - }; - - const mockUi = { - custom: vi.fn(<T>( - factory: ( - tui: any, - theme: any, - _keybindings: unknown, - done: DoneFn, - ) => { - render: (width: number) => string[]; - invalidate: () => void; - handleInput?: (data: string) => void; - }, - ) => { - capturedFactory = factory; - capturedDone = vi.fn((value: T) => { - doneCalled = true; - doneResult = value; - }); - - // Invoke factory synchronously to capture the widget - const widget = factory(tui, theme, undefined, capturedDone as unknown as DoneFn); - - // Store widget for test interaction - (mockUi as any)._widget = widget; - - // Return a never-resolving promise so tests can inspect synchronously - return new Promise<T>(() => {}); - }), - notify: vi.fn(), - setEditorText: vi.fn(), - setWidget: vi.fn(), - }; - - return { - ctx: { ui: mockUi }, - tui, - theme, - getWidget: () => (mockUi as any)._widget as { - render: (width: number) => string[]; - invalidate: () => void; - handleInput?: (data: string) => void; - } | null, - getHelpLine: () => { - const widget = (mockUi as any)._widget; - if (!widget) return null; - const lines = widget.render(80); - return lines[lines.length - 1] ?? null; - }, - getRenderLines: () => { - const widget = (mockUi as any)._widget; - if (!widget) return []; - return widget.render(80); - }, - dispatchResult: () => doneResult, - wasDispatched: () => doneCalled, - resetDispatch: () => { - doneCalled = false; - doneResult = undefined; - }, - }; - } - - it('pending chord state: pressing chord leader updates help line with completions', async () => { - const { ctx, getWidget, getHelpLine } = createChordMockContext(); - - const items = [{ id: 'WL-001', title: 'Test item', status: 'open' }]; - const chordEntries: any[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - { chord: ['u', 't'], command: 'update-title <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(chordEntries); - - // Start the browse widget - const { defaultChooseWorkItem } = await import('./index.js'); - defaultChooseWorkItem(items, ctx, vi.fn(), registry); - await new Promise(process.nextTick); - - const widget = getWidget(); - expect(widget).not.toBeNull(); - - // The 'u' key is a chord leader but has no single-key shortcut. - // The registry should report undefined for single-key lookup of 'u'. - expect(registry.lookup('u', 'list')).toBeUndefined(); - - // Start: help line shows all available shortcuts - const initialHelp = getHelpLine(); - expect(initialHelp).not.toBeNull(); - }); - - it('pressing a valid second key after chord leader dispatches via done()', async () => { - const { - ctx, - getWidget, - getHelpLine, - dispatchResult, - wasDispatched, - resetDispatch, - } = createChordMockContext(); - - const items = [{ id: 'WL-001', title: 'Test item', status: 'open' }]; - const chordEntries: any[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(chordEntries); - - const { defaultChooseWorkItem } = await import('./index.js'); - defaultChooseWorkItem(items, ctx, vi.fn(), registry); - await new Promise(process.nextTick); - - const widget = getWidget(); - expect(widget).not.toBeNull(); - - // Press the chord leader 'u' - // This should enter pending-chord state (no dispatch yet) - widget!.handleInput!('u'); - - // The pending chord's help line should show available completions - const pendingHelp = getHelpLine(); - expect(pendingHelp).not.toBeNull(); - - // Press the completion key 'p' to finish the chord - widget!.handleInput!('p'); - - // The chord should have dispatched via done() with a ShortcutResult - expect(wasDispatched()).toBe(true); - expect(dispatchResult()).toEqual( - expect.objectContaining({ - type: 'shortcut', - command: expect.stringContaining('update-priority'), - }), - ); - }); - - it('chord cancellation: pressing unrecognised key cancels pending chord', async () => { - const { - ctx, - getWidget, - dispatchResult, - wasDispatched, - } = createChordMockContext(); - - const items = [{ id: 'WL-001', title: 'Test item', status: 'open' }]; - const chordEntries: any[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(chordEntries); - - const { defaultChooseWorkItem } = await import('./index.js'); - defaultChooseWorkItem(items, ctx, vi.fn(), registry); - await new Promise(process.nextTick); - - const widget = getWidget(); - expect(widget).not.toBeNull(); - - // Press the chord leader 'u' to enter pending state - widget!.handleInput!('u'); - - // Press an unrecognised key (not a valid chord completion) - widget!.handleInput!('z'); - - // No dispatch should have occurred (invalid chord cancelled) - expect(wasDispatched()).toBe(false); - }); - - it('chord cancellation: Escape cancels pending chord', async () => { - const { ctx, getWidget, dispatchResult, wasDispatched } = - createChordMockContext(); - - const items = [{ id: 'WL-001', title: 'Test item', status: 'open' }]; - const chordEntries: any[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(chordEntries); - - const { defaultChooseWorkItem } = await import('./index.js'); - defaultChooseWorkItem(items, ctx, vi.fn(), registry); - await new Promise(process.nextTick); - - const widget = getWidget(); - expect(widget).not.toBeNull(); - - // Press the chord leader 'u' to enter pending state - widget!.handleInput!('u'); - - // Press Escape to cancel - widget!.handleInput!('\u001b'); - - // No dispatch should have occurred (cancelled) - expect(wasDispatched()).toBe(false); - }); - - it('u-p dispatches with stage-filtered chord entry', async () => { - const { ctx, getWidget, dispatchResult, wasDispatched } = - createChordMockContext(); - - // Item with a stage that matches the chord's stage restriction - const items = [ - { - id: 'WL-001', - title: 'Test item', - status: 'open', - stage: 'intake_complete', - }, - ]; - const chordEntries: any[] = [ - { - chord: ['u', 'p'], - command: '!!wl update --priority <id>', - view: 'both', - stages: ['intake_complete'], - }, - { - chord: ['u', 't'], - command: '!!wl update --title <id>', - view: 'both', - stages: ['intake_complete'], - }, - ]; - const registry = new ShortcutRegistry(chordEntries); - - const { defaultChooseWorkItem } = await import('./index.js'); - defaultChooseWorkItem(items, ctx, vi.fn(), registry); - await new Promise(process.nextTick); - - const widget = getWidget(); - expect(widget).not.toBeNull(); - - // Press 'u' then 'p' - widget!.handleInput!('u'); - widget!.handleInput!('p'); - - expect(wasDispatched()).toBe(true); - expect(dispatchResult()).toEqual( - expect.objectContaining({ - type: 'shortcut', - command: '!!wl update --priority WL-001', - }), - ); - }); - - it('u-p and u-t dispatch correctly with stage filtering', async () => { - const { ctx, getWidget, dispatchResult, wasDispatched, resetDispatch } = - createChordMockContext(); - - const items = [ - { - id: 'WL-002', - title: 'Another item', - status: 'open', - stage: 'intake_complete', - }, - ]; - const chordEntries: any[] = [ - { - chord: ['u', 'p'], - command: '!!wl update --priority <id>', - view: 'both', - stages: ['intake_complete', 'plan_complete'], - }, - { - chord: ['u', 't'], - command: '!!wl update --title <id>', - view: 'both', - stages: ['intake_complete', 'plan_complete'], - }, - ]; - const registry = new ShortcutRegistry(chordEntries); - - const { defaultChooseWorkItem } = await import('./index.js'); - defaultChooseWorkItem(items, ctx, vi.fn(), registry); - await new Promise(process.nextTick); - - const widget = getWidget(); - expect(widget).not.toBeNull(); - - // Press 'u' then 'p' — should dispatch u-p - widget!.handleInput!('u'); - widget!.handleInput!('p'); - - expect(wasDispatched()).toBe(true); - expect(dispatchResult()).toEqual( - expect.objectContaining({ - type: 'shortcut', - command: '!!wl update --priority WL-002', - }), - ); - - // Reset and test u-t - resetDispatch(); - - // Re-create widget for second test - const ctx2 = createChordMockContext(); - defaultChooseWorkItem(items, ctx2.ctx, vi.fn(), registry); - await new Promise(process.nextTick); - - const widget2 = ctx2.getWidget(); - expect(widget2).not.toBeNull(); - - widget2!.handleInput!('u'); - widget2!.handleInput!('t'); - - expect(ctx2.wasDispatched()).toBe(true); - expect(ctx2.dispatchResult()).toEqual( - expect.objectContaining({ - type: 'shortcut', - command: '!!wl update --title WL-002', - }), - ); - }); - - it('detail-only chord entries do not dispatch from list view', async () => { - const { ctx, getWidget, wasDispatched } = - createChordMockContext(); - - const items = [{ id: 'WL-001', title: 'Test item', status: 'open' }]; - const chordEntries: any[] = [ - { - chord: ['u', 'p'], - command: '!!wl update --priority <id>', - view: 'detail', - }, - ]; - const registry = new ShortcutRegistry(chordEntries); - - const { defaultChooseWorkItem } = await import('./index.js'); - defaultChooseWorkItem(items, ctx, vi.fn(), registry); - await new Promise(process.nextTick); - - const widget = getWidget(); - expect(widget).not.toBeNull(); - - // Press 'u' (chord leader) — in list view, 'u-p' is detail-only, - // so it should NOT enter pending chord state - widget!.handleInput!('u'); - // Press 'p' — no pending chord, so this is just a normal key - widget!.handleInput!('p'); - - // No dispatch should have occurred - expect(wasDispatched()).toBe(false); - }); - - it('dispatch respects stage filter via selected item stage', async () => { - const { ctx, getWidget, wasDispatched } = - createChordMockContext(); - - const items = [ - { - id: 'WL-001', - title: 'Idea item', - status: 'open', - stage: 'idea', - }, - ]; - const chordEntries: any[] = [ - { - chord: ['u', 'p'], - command: '!!wl update --priority <id>', - view: 'both', - stages: ['intake_complete', 'plan_complete'], - }, - ]; - const registry = new ShortcutRegistry(chordEntries); - - const { defaultChooseWorkItem } = await import('./index.js'); - defaultChooseWorkItem(items, ctx, vi.fn(), registry); - await new Promise(process.nextTick); - - const widget = getWidget(); - expect(widget).not.toBeNull(); - - // Press 'u' then 'p' - widget!.handleInput!('u'); - widget!.handleInput!('p'); - - // The selected item has stage 'idea', but u-p requires intake_complete - // or plan_complete. So dispatch should not happen for this item. - expect(wasDispatched()).toBe(false); - }); - - it('reserved navigation keys (g) take precedence over chord leaders', async () => { - const { ctx, getWidget, dispatchResult, wasDispatched } = - createChordMockContext(); - - const items = [{ id: 'WL-001', title: 'Test item', status: 'open' }]; - // 'g' is a reserved navigation key - even if a chord entry starts with - // 'g', it must NOT trigger chord pending state. - const chordEntries: any[] = [ - { chord: ['g', 't'], command: 'go-top <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(chordEntries); - - const { defaultChooseWorkItem } = await import('./index.js'); - defaultChooseWorkItem(items, ctx, vi.fn(), registry); - await new Promise(process.nextTick); - - const widget = getWidget(); - expect(widget).not.toBeNull(); - - // Press 'g' — this is a reserved navigation key and should NOT enter - // chord pending state. No dispatch should occur. - widget!.handleInput!('g'); - expect(wasDispatched()).toBe(false); - }); - - it('reserved navigation keys (G) take precedence over chord leaders', async () => { - const { ctx, getWidget, dispatchResult, wasDispatched } = - createChordMockContext(); - - const items = [{ id: 'WL-001', title: 'Test item', status: 'open' }]; - const chordEntries: any[] = [ - { chord: ['G', 't'], command: 'go-bottom <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(chordEntries); - - const { defaultChooseWorkItem } = await import('./index.js'); - defaultChooseWorkItem(items, ctx, vi.fn(), registry); - await new Promise(process.nextTick); - - const widget = getWidget(); - expect(widget).not.toBeNull(); - - // Press 'G' — reserved navigation key, must not enter chord state - widget!.handleInput!('G'); - expect(wasDispatched()).toBe(false); - }); - - it('reserved navigation keys (space) take precedence over chord leaders', async () => { - const { ctx, getWidget, dispatchResult, wasDispatched } = - createChordMockContext(); - - const items = [{ id: 'WL-001', title: 'Test item', status: 'open' }]; - const chordEntries: any[] = [ - { chord: [' ', 'n'], command: 'next-page <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(chordEntries); - - const { defaultChooseWorkItem } = await import('./index.js'); - defaultChooseWorkItem(items, ctx, vi.fn(), registry); - await new Promise(process.nextTick); - - const widget = getWidget(); - expect(widget).not.toBeNull(); - - // Press space — reserved navigation key, must not enter chord state - widget!.handleInput!(' '); - expect(wasDispatched()).toBe(false); - }); - - it('chord pending state help line shows completion hints', async () => { - const { ctx, getWidget, getHelpLine } = createChordMockContext(); - - const items = [{ id: 'WL-001', title: 'Test item', status: 'open' }]; - const chordEntries: any[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - { chord: ['u', 't'], command: 'update-title <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(chordEntries); - - const { defaultChooseWorkItem } = await import('./index.js'); - defaultChooseWorkItem(items, ctx, vi.fn(), registry); - await new Promise(process.nextTick); - - const widget = getWidget(); - expect(widget).not.toBeNull(); - - // Get the initial help line (before chord) - const initialHelp = getHelpLine(); - - // Press 'u' to enter pending chord state - widget!.handleInput!('u'); - - // After entering pending chord state, the help line should update - // to show available completions (u-p and u-t hints). - const pendingHelp = getHelpLine(); - expect(pendingHelp).not.toBeNull(); - }); - - - - it('normal single-key shortcuts still dispatch when chords are present', async () => { - const { ctx, getWidget, dispatchResult, wasDispatched } = - createChordMockContext(); - - const items = [{ id: 'WL-001', title: 'Test item', status: 'open' }]; - const entries: any[] = [ - { key: 'i', command: 'implement <id>', view: 'both' }, - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(entries); - - const { defaultChooseWorkItem } = await import('./index.js'); - defaultChooseWorkItem(items, ctx, vi.fn(), registry); - await new Promise(process.nextTick); - - const widget = getWidget(); - expect(widget).not.toBeNull(); - - // Press 'i' (single-key shortcut) — should dispatch immediately - widget!.handleInput!('i'); - expect(wasDispatched()).toBe(true); - expect(dispatchResult()).toEqual( - expect.objectContaining({ - type: 'shortcut', - command: 'implement WL-001', - }), - ); - }); - - it('chord pending state is per-view (independent list and detail state)', async () => { - // Since defaultChooseWorkItem creates a single widget for the list view, - // and the detail view is separate, we verify that each widget manages - // its own chord state independently. - const { ctx, getWidget, getHelpLine, dispatchResult, wasDispatched } = - createChordMockContext(); - - const items = [{ id: 'WL-001', title: 'Test item', status: 'open' }]; - const chordEntries: any[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'both' }, - ]; - const registry = new ShortcutRegistry(chordEntries); - - const { defaultChooseWorkItem } = await import('./index.js'); - defaultChooseWorkItem(items, ctx, vi.fn(), registry); - await new Promise(process.nextTick); - - const widget = getWidget(); - expect(widget).not.toBeNull(); - - // Press chord leader 'u' on the list widget - widget!.handleInput!('u'); - - // Create a second (detail) widget with the same registry - // The detail widget should have its own independent state - const detailCtx = createChordMockContext(); - defaultChooseWorkItem(items, detailCtx.ctx, vi.fn(), registry); - await new Promise(process.nextTick); - - const detailWidget = detailCtx.getWidget(); - expect(detailWidget).not.toBeNull(); - - // The detail widget starts in non-pending state - // Pressing 'p' without first pressing 'u' should not dispatch - detailWidget!.handleInput!('p'); - expect(detailCtx.wasDispatched()).toBe(false); - - // Now press 'u' then 'p' on the detail widget - detailWidget!.handleInput!('u'); - detailWidget!.handleInput!('p'); - - expect(detailCtx.wasDispatched()).toBe(true); - expect(detailCtx.dispatchResult()).toEqual( - expect.objectContaining({ - type: 'shortcut', - command: expect.stringContaining('update-priority'), - }), - ); - }); -}); diff --git a/packages/tui/extensions/Worklog/shortcut-config.ts b/packages/tui/extensions/Worklog/shortcut-config.ts deleted file mode 100644 index c51176823..000000000 --- a/packages/tui/extensions/Worklog/shortcut-config.ts +++ /dev/null @@ -1,506 +0,0 @@ -/** - * Config-driven shortcut key system for the worklog browse extension. - * - * Reads `shortcuts.json` from the extension directory at initialization, - * builds a lookup registry, and provides a `lookup(key, view)` API used by - * the dynamic dispatchers in the browse list and detail view. - * - * The shortcut system replaces the need for hardcoded `handleInput()` key - * handlers. Each shortcut is defined in `shortcuts.json` with a key, command - * template, and view scope. When a key is pressed, the registry looks up the - * matching command and inserts it into the editor via `ctx.ui.setEditorText()`. - * - * Config entry schema: - * - key (string): single key (e.g. "i", "p") — mutually exclusive with `chord` - * - chord (string[]): multi-key chord (e.g. ["u", "p"]) — mutually exclusive with `key` - * - command (string): text to insert into editor (e.g. "implement <id>") - * - view ("list" | "detail" | "both"): which view the shortcut applies in - * - stages (string[]): optional allow-list of item stages for which the shortcut - * is available. When undefined or empty, the shortcut is unconditionally - * available (backward compatible). - * - <id> placeholder: replaced at dispatch time with the selected work item ID - */ - -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -/** - * A single shortcut entry as defined in shortcuts.json. - */ -export interface ShortcutEntry { - /** - * Single key for immediate dispatch (e.g. "i", "p"). - * Mutually exclusive with `chord` — exactly one of `key` or `chord` must be set. - */ - key: string; - command: string; - view: 'list' | 'detail' | 'both'; - /** - * Optional chord sequence (array of 2+ keys, e.g. ["u", "p"]). - * Mutually exclusive with `key` — only one should be defined per entry. - * When a chord is defined, the entry is matched via `lookupChord()` - * rather than `lookup()`. - */ - chord?: string[]; - /** - * Optional short label displayed in the browse help line (e.g. "implement", "plan"). - * When provided, this is used instead of deriving a label from the command string. - */ - label?: string; - /** - * Optional one-sentence description of the command for use in help screens. - */ - description?: string; - /** - * Optional allow-list of item stages for which the shortcut is available. - * When undefined or empty, the shortcut is unconditionally available. - * Example: ["idea"] means the shortcut only appears for items in the "idea" stage. - */ - stages?: string[]; -} - -/** - * Registry of loaded shortcut entries with lookup capability. - * - * The registry stores entries by key and provides `lookup(key, view)` and - * `lookupChord(chordKeys, view, stage)` methods that return the matching - * command string (with `<id>` replaced) or `undefined` if no entry matches. - * - * Chord entries are tracked separately for efficient leader-key lookup via - * `getChordByLeader()`. - */ -export class ShortcutRegistry { - private entries: ShortcutEntry[]; - private chordEntries: Map<string, ShortcutEntry[]>; - - constructor(entries: ShortcutEntry[]) { - this.entries = entries; - - // Index chord entries by leader key for fast lookup - this.chordEntries = new Map(); - for (const entry of entries) { - const chord = (entry as Record<string, unknown>).chord; - if (Array.isArray(chord) && chord.length >= 2) { - const [leader] = chord as [string, ...string[]]; - const existing = this.chordEntries.get(leader) ?? []; - existing.push(entry); - this.chordEntries.set(leader, existing); - } - } - } - - /** - * Look up a shortcut by key, view, and optional stage. - * - * Returns the command string for the first matching entry, or `undefined` - * if no entry matches. An entry matches when: - * - its `key` equals the given key - * - its `view` is either `"both"` or exactly matches the given view string - * - if `stage` is provided, the entry's `stages` allow-list is either - * undefined/empty, or includes the given stage value - * - * NOTE: Only key-based entries (those with a `key` field) are matched by - * this method. Chord entries must be looked up via `lookupChord()`. - * - * @param key - The pressed key (e.g. "i") - * @param view - The current view ("list" or "detail") - * @param stage - Optional item stage to filter by (e.g. "idea", "intake_complete") - * @returns The command string or undefined - */ - lookup(key: string, view: string, stage?: string): string | undefined { - const match = this.entries.find(entry => { - // Only match key-based entries — chord entries are handled by lookupChord - if (entry.key !== key) return false; - if (entry.view !== 'both' && entry.view !== view) return false; - // If stage is provided, check the stages allow-list - if (stage !== undefined && entry.stages !== undefined && entry.stages.length > 0) { - if (!entry.stages.includes(stage)) return false; - } - return true; - }); - return match?.command; - } - - /** - * Return all entries that should be visible for the given stage. - * - * Entries with no `stages` constraint (or empty array) are always included. - * Entries with a `stages` array are only included if it contains the given stage. - * - * @param stage - The item stage to filter by - * @returns Entries applicable for the given stage - */ - getEntriesForStage(stage?: string): ShortcutEntry[] { - return this.entries.filter(entry => { - if (entry.stages === undefined || entry.stages.length === 0) return true; - if (stage === undefined) return false; - return entry.stages.includes(stage); - }); - } - - /** - * Return all entries in the registry (for testing / introspection). - */ - getEntries(): ReadonlyArray<ShortcutEntry> { - return this.entries; - } - - // ── Chord methods ───────────────────────────────────────────────────── - - /** - * Get all chord entries whose first key (leader) matches the given key. - * - * When `view` is provided, only chord entries that are visible in that view - * (view === "both" or view === the provided view) are returned. - * - * @param leaderKey - The first key of the chord (e.g. "u") - * @param view - Optional view filter ("list" | "detail") - * @returns Array of matching chord ShortcutEntry objects (may be empty) - */ - getChordByLeader(leaderKey: string, view?: string): ShortcutEntry[] { - const chords = this.chordEntries.get(leaderKey); - if (!chords || chords.length === 0) return []; - - if (view === undefined) { - return chords; - } - - return chords.filter(entry => entry.view === 'both' || entry.view === view); - } - - /** - * Return all chord entries whose chord array starts with the given prefix. - * - * For example, given chords `["u", "p", "c"]` and `["u", "p", "h"]`, - * `getChordByPrefix(["u", "p"])` returns both entries. - * - * When `view` is provided, only chord entries visible in that view - * (view === "both" or view === the provided view) are returned. - * When `stage` is provided, only chord entries whose stages allow-list - * includes the given stage (or entries without a stages constraint) - * are returned. - * - * @param prefix - The prefix key sequence to match (e.g. ["u", "p"]) - * @param view - Optional view filter ("list" | "detail") - * @param stage - Optional item stage to filter by - * @returns Array of matching chord ShortcutEntry objects (may be empty) - */ - getChordByPrefix(prefix: string[], view?: string, stage?: string): ShortcutEntry[] { - const result: ShortcutEntry[] = []; - - for (const entry of this.entries) { - const chord = (entry as Record<string, unknown>).chord; - if (!Array.isArray(chord)) continue; - if (chord.length < prefix.length) continue; - - // Check if chord starts with the given prefix - let matches = true; - for (let i = 0; i < prefix.length; i++) { - if (chord[i] !== prefix[i]) { - matches = false; - break; - } - } - if (!matches) continue; - - // View filter - if (view !== undefined && entry.view !== 'both' && entry.view !== view) continue; - - // Stage filter - if (stage !== undefined && entry.stages !== undefined && entry.stages.length > 0) { - if (!entry.stages.includes(stage)) continue; - } - - result.push(entry); - } - - return result; - } - - /** - * Look up a chord by its full key sequence, view, and optional stage. - * - * Returns the command string for the first matching entry, or `undefined` - * if no entry matches. An entry matches when: - * - its `chord` array exactly equals the given `chordKeys` array - * - its `view` is either `"both"` or exactly matches the given view string - * - if `stage` is provided, the entry's `stages` allow-list is either - * undefined/empty, or includes the given stage value - * - * @param chordKeys - The full chord key sequence (e.g. ["u", "p"]) - * @param view - The current view ("list" or "detail") - * @param stage - Optional item stage to filter by - * @returns The command string or undefined - */ - lookupChord(chordKeys: string[], view: string, stage?: string): string | undefined { - // chordKeys must match the entry's chord array exactly - const match = this.entries.find(entry => { - const chord = (entry as Record<string, unknown>).chord; - if (!Array.isArray(chord)) return false; - if (chord.length !== chordKeys.length) return false; - - // Compare chord arrays element-by-element - for (let i = 0; i < chord.length; i++) { - if (chord[i] !== chordKeys[i]) return false; - } - - // View filter - if (entry.view !== 'both' && entry.view !== view) return false; - - // Stage filter - if (stage !== undefined && entry.stages !== undefined && entry.stages.length > 0) { - if (!entry.stages.includes(stage)) return false; - } - - return true; - }); - - return match?.command; - } - - /** - * Return all chord entries (for help text rendering / introspection). - */ - getChordEntries(): ShortcutEntry[] { - const result: ShortcutEntry[] = []; - for (const entry of this.entries) { - const chord = (entry as Record<string, unknown>).chord; - if (Array.isArray(chord) && chord.length >= 2) { - result.push(entry); - } - } - return result; - } -} - -/** - * Load and validate shortcut config from shortcuts.json. - * - * - Missing file → returns empty registry (no shortcuts, graceful degradation) - * - Malformed JSON → returns empty registry with console.error (no crash) - * - Invalid entries → skipped with console.warn; valid entries are kept - * - * @returns A ShortcutRegistry instance (may be empty) - */ -export function loadShortcutConfig(): ShortcutRegistry { - const configPath = join(__dirname, 'shortcuts.json'); - - let raw: string; - try { - raw = readFileSync(configPath, 'utf-8'); - } catch { - // File not found — graceful degradation, no error - return new ShortcutRegistry([]); - } - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - console.error(`[shortcut-config] Malformed shortcuts.json: unable to parse JSON`); - return new ShortcutRegistry([]); - } - - if (!Array.isArray(parsed)) { - console.error('[shortcut-config] shortcuts.json must be a JSON array'); - return new ShortcutRegistry([]); - } - - const validEntries: ShortcutEntry[] = []; - const validViews = new Set(['list', 'detail', 'both']); - const seenKeys = new Set<string>(); - - // Collect skipped-entry details for batched warnings - const skippedDetails: { index: number; category: string; detail: string }[] = []; - - for (let i = 0; i < parsed.length; i++) { - const entry = parsed[i] as Record<string, unknown>; - - // Validate required fields - if (!entry || typeof entry !== 'object') { - skippedDetails.push({ index: i, category: 'not-an-object', detail: 'not an object' }); - continue; - } - - const rawKey = entry.key; - const rawChord = entry.chord; - const command = entry.command; - const view = entry.view; - - // Validate key/chord mutual exclusivity and presence - const hasKey = rawKey !== undefined && typeof rawKey === 'string' && (rawKey as string).length > 0; - const hasChord = Array.isArray(rawChord) && (rawChord as unknown[]).length > 0; - - if (hasKey && hasChord) { - skippedDetails.push({ - index: i, - category: 'both-fields', - detail: 'entry has both "key" and "chord" fields — they are mutually exclusive', - }); - continue; - } - - if (!hasKey && !hasChord) { - skippedDetails.push({ - index: i, - category: 'missing-key-or-chord', - detail: 'missing or invalid "key" or "chord" field — exactly one is required', - }); - continue; - } - - // If chord entry, validate chord is an array of 2+ strings - if (hasChord) { - const chordArr = rawChord as unknown[]; - if (chordArr.length < 2) { - skippedDetails.push({ - index: i, - category: 'chord-too-short', - detail: '"chord" must be an array of at least 2 strings', - }); - continue; - } - for (let j = 0; j < chordArr.length; j++) { - if (typeof chordArr[j] !== 'string') { - skippedDetails.push({ - index: i, - category: 'chord-element-not-string', - detail: `"chord" entry at index ${j} is not a string`, - }); - } - } - } - - if (!command || typeof command !== 'string') { - skippedDetails.push({ - index: i, - category: 'missing-command', - detail: 'missing or invalid "command" field', - }); - continue; - } - - if (!view || typeof view !== 'string') { - skippedDetails.push({ - index: i, - category: 'missing-view', - detail: 'missing or invalid "view" field', - }); - continue; - } - - if (!validViews.has(view)) { - skippedDetails.push({ - index: i, - category: 'invalid-view', - detail: `unknown "view" value "${view}"`, - }); - continue; - } - - // Validate optional stages field - const stages = entry.stages; - if (stages !== undefined) { - if (!Array.isArray(stages)) { - skippedDetails.push({ - index: i, - category: 'invalid-stages-type', - detail: '"stages" must be an array of strings', - }); - continue; - } - for (let j = 0; j < stages.length; j++) { - if (typeof stages[j] !== 'string') { - skippedDetails.push({ - index: i, - category: 'stages-element-not-string', - detail: `"stages" entry at index ${j} is not a string`, - }); - } - } - } - - // Build the shortcut entry with either key or chord - const shortcutEntry: ShortcutEntry = { - key: rawChord !== undefined ? '' : (entry.key as string), - command, - view: view as 'list' | 'detail' | 'both', - }; - - // If it's a chord entry, set the chord field on the entry - // We use a spread to add chord since the interface type doesn't require it - if (hasChord) { - (shortcutEntry as Record<string, unknown>).chord = rawChord as string[]; - } - - // Only include stages if it is a non-empty array of strings - if ( - Array.isArray(stages) && - stages.length > 0 && - stages.every((s: unknown) => typeof s === 'string') - ) { - shortcutEntry.stages = stages as string[]; - } - - // Include optional label if present and non-empty - const label = entry.label; - if (typeof label === 'string' && label.trim().length > 0) { - shortcutEntry.label = label.trim(); - } - - // Include optional description if present and non-empty - const description = entry.description; - if (typeof description === 'string' && description.trim().length > 0) { - shortcutEntry.description = description.trim(); - } - - // Check for duplicate key+view or chord+view combinations - const compositeKey = hasChord - ? `${(rawChord as string[]).join('+')}:${view}` - : `${rawKey}:${view}`; - if (seenKeys.has(compositeKey)) { - console.warn( - `[shortcut-config] Duplicate shortcut at index ${i}: key/chord "${compositeKey}" is already registered — the second entry will be shadowed`, - ); - } else { - seenKeys.add(compositeKey); - } - - validEntries.push(shortcutEntry); - } - - // Emit batched warnings per structural-issue category - if (skippedDetails.length > 0) { - const byCategory = new Map<string, { indices: number[]; details: string[] }>(); - for (const { index, category, detail } of skippedDetails) { - if (!byCategory.has(category)) { - byCategory.set(category, { indices: [], details: [] }); - } - byCategory.get(category)!.indices.push(index); - byCategory.get(category)!.details.push(detail); - } - - for (const [, { indices, details }] of byCategory) { - if (indices.length === 1) { - console.warn(`[shortcut-config] Skipping entry at index ${indices[0]}: ${details[0]}`); - } else { - console.warn( - `[shortcut-config] Skipped ${indices.length} entries at indices [${indices.join(', ')}]: ${details[0]}`, - ); - } - // Individual details available via console.debug for debugging - for (let j = 0; j < indices.length; j++) { - console.debug(`[shortcut-config] Entry at index ${indices[j]}: ${details[j]}`); - } - } - } - - if (validEntries.length === 0 && parsed.length > 0) { - console.warn(`[shortcut-config] No valid entries in shortcuts.json; all entries were invalid`); - } - - return new ShortcutRegistry(validEntries); -} diff --git a/packages/tui/extensions/Worklog/shortcuts.json b/packages/tui/extensions/Worklog/shortcuts.json deleted file mode 100644 index 237bba994..000000000 --- a/packages/tui/extensions/Worklog/shortcuts.json +++ /dev/null @@ -1,154 +0,0 @@ -[ - { - "key": "c", - "command": "/intake\n<desc>\nPriority: medium", - "view": "both", - "label": "create new", - "description": "Create a new work item with a description and priority." - }, - { - "key": "n", - "command": "/intake <id>", - "view": "both", - "stages": ["idea"], - "label": "intake", - "description": "Ensure that the selected item is reasonably well defined in terms of objectives." - }, - { - "key": "p", - "command": "/plan <id>", - "view": "both", - "stages": ["intake_complete"], - "label": "plan", - "description": "Run the plan workflow on the selected work item" - }, - { - "key": "i", - "command": "/skill:implement <id>", - "view": "both", - "stages": ["intake_complete", "plan_complete", "in_progress"], - "label": "implement", - "description": "Run the implement workflow on the selected work item" - }, - { - "chord": ["a", "a"], - "command": "/skill:audit <id>", - "view": "both", - "stages": ["in_review"], - "label": "audit automatic" - }, - { - "chord": ["a", "y"], - "command": "!!wl reviewed <id> false && wl audit-set <id> --ready-to-close yes --summary 'Approved by manual review'", - "view": "both", - "stages": ["in_review"], - "label": "audit approve", - "description": "Approve the in_review item by manual audit." - }, - { - "chord": ["a", "r"], - "command": "!!wl reviewed <id> false && wl audit-set <id> --ready-to-close no --summary 'Rejected by manual review. <reason>'", - "view": "both", - "stages": ["in_review"], - "label": "audit reject", - "description": "Reject the in_review item by manual audit." - }, - { - "key": "r", - "command": "!!wl reviewed <id> && wl comment add <id> --body '<producer_comment>'", - "view": "both", - "label": "Producer Review", - "description": "Toggle the 'Needs Producer Review' flag." - }, - { - "chord": ["u", "p", "l"], - "command": "!!wl update <id> --priority low", - "view": "both", - "label": "update priority low", - "description": "Update the priority of the selected work item to low." - }, - { - "chord": ["u", "p", "m"], - "command": "!!wl update <id> --priority medium", - "view": "both", - "label": "update priority medium", - "description": "Update the priority of the selected work item to medium." - }, - { - "chord": ["u", "p", "h"], - "command": "!!wl update <id> --priority high", - "view": "both", - "label": "update priority high", - "description": "Update the priority of the selected work item to high." - }, - { - "chord": ["u", "p", "c"], - "command": "!!wl update <id> --priority critical", - "view": "both", - "label": "update priority critical", - "description": "Update the priority of the selected work item to critical." - }, - { - "chord": ["u", "s"], - "command": "!!wl update <id> --status <status> --stage <stage> ", - "view": "both", - "label": "update stage/status", - "description": "Update the stage of the selected work item" - }, - { - "chord": ["u", "t"], - "command": "!!wl update <id> --title ", - "view": "both", - "label": "update title", - "description": "Update the title of the selected work item" - }, - { - "chord": ["x", "c"], - "command": "!!wl close <id>", - "view": "both", - "label": "close done", - "description": "Close the work item as done." - }, - { - "chord": ["x", "d"], - "command": "!!wl delete <id>", - "view": "both", - "label": "close deleted", - "description": "Delete the work item." - }, - { - "key": "s", - "command": "!!wl search ", - "view": "both", - "label": "Search", - "description": "Search all workitems for keyword(s)." - }, - { - "chord": ["f", "i"], - "command": "/wl idea", - "view": "both", - "label": "filter idea", - "description": "Filter browse list to items in the idea stage." - }, - { - "chord": ["f", "n"], - "command": "/wl intake", - "view": "both", - "label": "filter intake", - "description": "Filter browse list to items in the intake_complete stage." - }, - { - "chord": ["f", "p"], - "command": "/wl plan", - "view": "both", - "label": "filter plan", - "description": "Filter browse list to items in the plan_complete stage." - }, - { - "chord": ["f", "r"], - "command": "/wl review", - "view": "both", - "label": "filter in_review", - "description": "Filter browse list to items in the in_review stage." - } -] diff --git a/packages/tui/extensions/Worklog/worklog-helpers.ts b/packages/tui/extensions/Worklog/worklog-helpers.ts deleted file mode 100644 index b77a9e344..000000000 --- a/packages/tui/extensions/Worklog/worklog-helpers.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Shared widget helper functions for the worklog widgets. - * - * These are pure functions that can be tested independently of the Pi runtime. - * Both the Pi extension and the unit tests import from this module. - */ - -import { truncateToTerminalWidth } from './terminal-utils.js'; - -// ─── Work Item Interface ─────────────────────────────────────────────── - -export interface WorkItem { - id: string; - title: string; - status: string; - priority: string; - assignee?: string; - stage?: string; - issueType?: string; - description?: string; -} - -/** - * Theme interface matching the Pi TUI theme.fg() API. - */ -export interface PiTheme { - fg: (color: string, text: string) => string; - bold: (text: string) => string; -} - -/** - * Get the theme colour token for a given work item stage. - * - * Stage progression maps to Pi TUI theme tokens: - * - idea → dim (muted/low priority) - * - intake_complete → mdLink (blue-like) - * - plan_complete → accent (cyan-like) - * - in_progress → warning (yellow) - * - in_review → success (green) - * - done → text (default/white) - * - * @param stage - The work item stage (lowercase with underscores) - * @returns The Pi TUI theme colour token name - */ -export function stageColourToken(stage?: string): string { - const s = (stage || '').toLowerCase().trim(); - switch (s) { - case 'idea': - return 'dim'; - case 'intake_complete': - return 'mdLink'; // blue-like (#81a2be) - case 'plan_complete': - return 'accent'; // cyan-like (#8abeb7) - case 'in_progress': - return 'warning'; - case 'in_review': - return 'success'; - case 'done': - return 'text'; - default: - return 'dim'; // default to dim for unknown/undefined stages - } -} - -/** - * Apply stage-based colour to text using the Pi TUI theme. - * - * Blocked work items appear in red regardless of stage. - * Otherwise, stage-based colours apply. - * - * @param text - The text to colour - * @param stage - The work item stage - * @param status - The work item status - * @param theme - The Pi TUI theme object - * @returns The coloured text string - */ -export function applyStageColour( - text: string, - stage?: string, - status?: string, - theme?: PiTheme, -): string { - if (!theme) return text; - // Blocked status overrides everything - if (status === 'blocked') { - return theme.fg('error', text); - } - const token = stageColourToken(stage); - return theme.fg(token, text); -} - -/** - * Get a status icon character for the given status. - */ -export function getStatusIcon(status: string): string { - switch (status) { - case 'open': return '🔓'; - case 'in_progress': return '🔄'; - case 'completed': return '✔️'; - case 'blocked': return '⛔'; - case 'deleted': return '🗑️'; - default: return '○'; - } -} - -/** - * Truncate text to fit within maxLen visible characters. - * Handles emoji (2 columns each) and ANSI escape codes. - */ -export function truncate(text: string, maxLen: number): string { - return truncateToTerminalWidth(text, maxLen, { ellipsis: '...' }); -} - -/** - * Build the numbered work item list widget lines. - * - * @param width - Available width in characters - * @param items - Work items to display - * @param selectedIndex - Index of the currently selected item (0-based) - * @returns Array of line strings for rendering - */ -export function buildWorklogWidgetLines( - width: number, - items: WorkItem[], - selectedIndex: number -): string[] { - const maxIndex = Math.min(items.length, 9); - if (maxIndex === 0) return [' No work items found']; - - const lines: string[] = []; - lines.push(' Work Items (Ctrl+1-9 select, Ctrl+Up/Down cycle):'); - - for (let i = 0; i < maxIndex; i++) { - const item = items[i]; - const marker = i === selectedIndex ? '▸' : ' '; - const num = i + 1; - const statusIcon = getStatusIcon(item.status); - // Prefix: " marker num: icon " = 9 chars + 2 cols for emoji icon - const prefixCols = 9 + (statusIcon ? 2 : 0); - const title = truncate(item.title, width - prefixCols); - lines.push(` ${marker} ${num}: ${statusIcon} ${title}`); - } - - if (items.length > 9) { - lines.push(` ... and ${items.length - 9} more (/worklog-select for full access)`); - } - - return lines; -} - -/** - * Build the details widget lines for the selected item. - * - * @param width - Available width in characters - * @param item - The selected work item (or null) - * @returns Array of line strings for rendering - */ -export function buildWorklogDetailsLines( - width: number, - item: WorkItem | null -): string[] { - if (!item) return [' No item selected']; - - // Emoji icons (no blessed tags - Pi handles styling) - const statusIcon = getStatusIcon(item.status); - const priorityIcon = getPriorityIcon(item.priority); - - const lines: string[] = []; - lines.push(` ${item.id}`); - lines.push(` Title: ${truncate(item.title, width - 12)}`); - lines.push(` Status: ${statusIcon} ${item.status}`); - lines.push(` Priority: ${priorityIcon} ${item.priority || '—'}`); - if (item.assignee) lines.push(` Assignee: ${item.assignee}`); - if (item.stage) lines.push(` Stage: ${item.stage}`); - if (item.issueType) lines.push(` Type: ${item.issueType}`); - - // Description excerpt - if (item.description) { - const excerpt = truncate( - item.description.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim(), - width - 12 - ); - lines.push(` Summary: ${excerpt}`); - } - - return lines; -} - -/** - * Get a priority icon character for the given priority. - */ -function getPriorityIcon(priority: string | undefined): string { - if (!priority) return ''; - switch (priority.toLowerCase()) { - case 'critical': return '🚨'; - case 'high': return '⭐'; - case 'medium': return '📋'; - case 'low': return '🐢'; - default: return ''; - } -} diff --git a/packages/tui/extensions/package.json b/packages/tui/extensions/package.json index dded3f5d8..0f3ece04b 100644 --- a/packages/tui/extensions/package.json +++ b/packages/tui/extensions/package.json @@ -3,7 +3,7 @@ "private": true, "version": "1.0.0", "type": "module", - "description": "Worklog Pi TUI extension — Work item browser for Pi coding agent", + "description": "Worklog Pi extension — agent-side plugin (activity indicator, session health, model display, guardrails, skill-path tool, error recovery, lease release)", "pi": { "extensions": [ "./Worklog/index.ts" diff --git a/packages/tui/extensions/settings.json b/packages/tui/extensions/settings.json index d777465ad..e9b53431f 100644 --- a/packages/tui/extensions/settings.json +++ b/packages/tui/extensions/settings.json @@ -1,6 +1,3 @@ { - "browseItemCount": 15, - "showIcons": true, - "showActivityIndicator": true, - "showHelpText": true + "showActivityIndicator": true } diff --git a/packages/tui/pi.json b/packages/tui/pi.json deleted file mode 100644 index 899529222..000000000 --- a/packages/tui/pi.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "tui", - "version": "0.2.0", - "description": "Pi-based TUI for the Worklog CLI with agent chat, action palette, and work item management", - "main": "../dist/index.js", - "bin": { - "wl-piman": "../dist/commands/piman.js" - }, - "scripts": { - "build": "cd .. && npm run build", - "test": "cd .. && npm test", - "smoke": "node --import tsx -e \"import('../extensions/Worklog/chatPane.js'); import('../extensions/Worklog/actionPalette.js'); console.log('OK')\"" - }, - "keywords": [ - "worklog", - "tui", - "agent", - "pi", - "cli" - ], - "author": "ContextHub Team", - "license": "MIT", - "peerDependencies": { - "worklog": "*" - }, - "pi": { - "extensions": [ - "./extensions/Worklog/chatPane.ts", - "./extensions/Worklog/actionPalette.ts" - ], - "commands": [ - "wl-piman" - ] - } -} diff --git a/packages/tui/tests/activity-indicator.test.ts b/packages/tui/tests/activity-indicator.test.ts deleted file mode 100644 index 5ab92ced1..000000000 --- a/packages/tui/tests/activity-indicator.test.ts +++ /dev/null @@ -1,910 +0,0 @@ -/** - * Unit tests for the activity-indicator module. - * - * Verifies that: - * - Built-in Pi commands are correctly identified and excluded - * - Skill commands are properly extracted - * - Input events correctly set/clear the indicator - * - Session lifecycle events (startup, new, resume) handle the indicator correctly - * - Terminal width truncation works - * - Command extraction from input text works - * - * Run: npx vitest run packages/tui/tests/activity-indicator.test.ts - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { ExtensionAPI, ExtensionContext, ExtensionUIContext } from '@earendil-works/pi-coding-agent'; - -// We import the module but mock the dependencies for unit testing -// Since activity-indicator.ts exports functions that operate on ctx.ui, -// we test the core logic by creating mock contexts and calling the exported functions. - -// Mock the wl-integration module before importing the module under test -vi.mock('../extensions/wl-integration.js', () => ({ - runWl: vi.fn(), - wlEvents: { on: vi.fn(), emit: vi.fn(), removeListener: vi.fn() }, -})); - -// Import the module under test -import { - registerActivityIndicator, - showActivity, - clearActivity, - detectWorkItemId, - BUILTIN_COMMANDS, - ACTIVITY_STATUS_KEY, -} from '../extensions/Worklog/activity-indicator.js'; - -// Import the mocked module for controlling test behavior -import { runWl } from '../extensions/wl-integration.js'; -const mockRunWl = runWl as ReturnType<typeof vi.fn>; - -// Re-import for type use -import type { InputEvent, SessionStartEvent } from '@earendil-works/pi-coding-agent'; - -describe('BUILTIN_COMMANDS', () => { - it('contains all expected built-in Pi commands', () => { - const expectedCommands = [ - '/login', '/logout', '/model', '/scoped-models', '/settings', - '/resume', '/new', '/name', '/session', '/tree', '/trust', - '/fork', '/clone', '/compact', '/copy', '/export', '/share', - '/reload', '/hotkeys', '/changelog', '/quit', - ]; - for (const cmd of expectedCommands) { - expect(BUILTIN_COMMANDS.has(cmd)).toBe(true); - } - }); - - it('does NOT contain extension commands', () => { - expect(BUILTIN_COMMANDS.has('/wl')).toBe(false); - expect(BUILTIN_COMMANDS.has('/skill:audit')).toBe(false); - expect(BUILTIN_COMMANDS.has('/custom-cmd')).toBe(false); - }); - - it('does NOT contain skill commands', () => { - expect(BUILTIN_COMMANDS.has('/skill:implement')).toBe(false); - expect(BUILTIN_COMMANDS.has('/skill:audit')).toBe(false); - }); -}); - -describe('ACTIVITY_STATUS_KEY', () => { - it('uses a descriptive key for the footer status', () => { - expect(ACTIVITY_STATUS_KEY).toBe('worklog-activity'); - }); -}); - -describe('showActivity', () => { - it('sets the activity status with a prefix indicator', () => { - const setStatus = vi.fn(); - const theme = { fg: vi.fn((_color: string, text: string) => text) }; - const ctx = { ui: { setStatus, theme } }; - - showActivity(ctx as any, '/wl'); - - expect(setStatus).toHaveBeenCalledWith( - ACTIVITY_STATUS_KEY, - expect.stringContaining('⏵') - ); - expect(setStatus).toHaveBeenCalledWith( - ACTIVITY_STATUS_KEY, - expect.stringContaining('/wl') - ); - }); - - it('truncates long activity text to fit terminal width', () => { - const setStatus = vi.fn(); - const theme = { fg: vi.fn((_color: string, text: string) => text) }; - const ctx = { ui: { setStatus, theme } }; - - const longText = '/wl ' + 'a'.repeat(500); - showActivity(ctx as any, longText); - - expect(setStatus).toHaveBeenCalledOnce(); - const calledWith = setStatus.mock.calls[0][1] as string; - // Should not include the full 500 'a's - expect(calledWith.length).toBeLessThan(500); - // Should have the prefix - expect(calledWith).toContain('⏵'); - }); - - it('applies theme accent color to the activity text', () => { - const setStatus = vi.fn(); - const theme = { fg: vi.fn((_color: string, text: string) => text) }; - const ctx = { ui: { setStatus, theme } }; - - showActivity(ctx as any, '/wl list'); - - expect(theme.fg).toHaveBeenCalledWith('accent', expect.any(String)); - }); - - it('is a no-op when showIndicator is false', () => { - const setStatus = vi.fn(); - const theme = { fg: vi.fn((_color: string, text: string) => text) }; - const ctx = { ui: { setStatus, theme } }; - - showActivity(ctx as any, '/wl', false); - - expect(setStatus).not.toHaveBeenCalled(); - expect(theme.fg).not.toHaveBeenCalled(); - }); - - it('sets the indicator when showIndicator is true (explicit)', () => { - const setStatus = vi.fn(); - const theme = { fg: vi.fn((_color: string, text: string) => text) }; - const ctx = { ui: { setStatus, theme } }; - - showActivity(ctx as any, '/wl', true); - - expect(setStatus).toHaveBeenCalledWith( - ACTIVITY_STATUS_KEY, - expect.stringContaining('/wl') - ); - }); - - it('defaults to enabled when showIndicator is not provided', () => { - const setStatus = vi.fn(); - const theme = { fg: vi.fn((_color: string, text: string) => text) }; - const ctx = { ui: { setStatus, theme } }; - - showActivity(ctx as any, '/wl'); - - expect(setStatus).toHaveBeenCalledWith( - ACTIVITY_STATUS_KEY, - expect.stringContaining('/wl') - ); - }); -}); - -describe('clearActivity', () => { - it('clears the activity status with undefined', () => { - const setStatus = vi.fn(); - const ctx = { ui: { setStatus } }; - - clearActivity(ctx as any); - - expect(setStatus).toHaveBeenCalledWith(ACTIVITY_STATUS_KEY, undefined); - }); -}); - -describe('detectWorkItemId', () => { - it('detects a standard WL- prefixed work item ID', () => { - const result = detectWorkItemId('/intake WL-0MQL0T5TR0060AEH'); - expect(result).toBe('WL-0MQL0T5TR0060AEH'); - }); - - it('detects a SA- prefixed work item ID', () => { - const result = detectWorkItemId('/implement SA-0MPYMFZXO0004ZU4'); - expect(result).toBe('SA-0MPYMFZXO0004ZU4'); - }); - - it('returns null for text without a work item ID', () => { - expect(detectWorkItemId('/wl list')).toBeNull(); - expect(detectWorkItemId('/model')).toBeNull(); - expect(detectWorkItemId('Hello world')).toBeNull(); - expect(detectWorkItemId('')).toBeNull(); - }); - - it('returns null for short ID-like patterns (under 15 chars after dash)', () => { - expect(detectWorkItemId('/intake WL-1234')).toBeNull(); - expect(detectWorkItemId('WL-abc')).toBeNull(); - }); - - it('returns the first ID when multiple IDs are present', () => { - const text = '/implement WL-0MQL0T5TR0060AEH and WL-0MQLG8PK80041FM3'; - const result = detectWorkItemId(text); - expect(result).toBe('WL-0MQL0T5TR0060AEH'); - }); - - it('detects an ID at the start of the text', () => { - expect(detectWorkItemId('WL-0MQL0T5TR0060AEH is the ID')).toBe('WL-0MQL0T5TR0060AEH'); - }); - - it('detects an ID at the end of the text', () => { - expect(detectWorkItemId('Process item WL-0MQL0T5TR0060AEH')).toBe('WL-0MQL0T5TR0060AEH'); - }); - - it('returns null for ID-like patterns that are part of longer words', () => { - // The regex uses \b word boundary to ensure the prefix starts on a - // word boundary, preventing false positives when text like - // "PREFIXWL-..." is encountered - expect(detectWorkItemId('PREFIXWL-0MQL0T5TR0060AEH')).toBeNull(); - }); -}); - -describe('registerActivityIndicator - input events', () => { - let pi: Partial<ExtensionAPI>; - let inputHandlers: Array<(event: InputEvent, ctx: ExtensionContext) => Promise<any>>; - let sessionStartHandlers: Array<(event: SessionStartEvent, ctx: ExtensionContext) => Promise<any>>; - - beforeEach(() => { - inputHandlers = []; - sessionStartHandlers = []; - pi = { - on: vi.fn((event: string, handler: any) => { - if (event === 'input') { - inputHandlers.push(handler); - } else if (event === 'session_start') { - sessionStartHandlers.push(handler); - } - }) as any, - }; - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - function createMockContext(): ExtensionContext { - const setStatus = vi.fn(); - const theme = { fg: vi.fn((_color: string, text: string) => text) }; - return { - ui: { setStatus, theme } as unknown as ExtensionUIContext, - mode: 'tui', - hasUI: true, - cwd: '/test', - sessionManager: { - getBranch: vi.fn().mockReturnValue([]), - getEntries: vi.fn().mockReturnValue([]), - } as any, - model: undefined, - modelRegistry: {} as any, - isIdle: vi.fn().mockReturnValue(true), - isProjectTrusted: vi.fn().mockReturnValue(true), - signal: undefined, - abort: vi.fn(), - hasPendingMessages: vi.fn().mockReturnValue(false), - shutdown: vi.fn(), - getContextUsage: vi.fn(), - compact: vi.fn(), - getSystemPrompt: vi.fn().mockReturnValue(''), - }; - } - - it('sets indicator for /skill:name commands', async () => { - registerActivityIndicator(pi as ExtensionAPI); - expect(inputHandlers.length).toBe(1); - - const ctx = createMockContext(); - const event: InputEvent = { - type: 'input', - text: '/skill:audit', - source: 'interactive', - }; - - const result = await inputHandlers[0](event, ctx); - - expect(result).toEqual({ action: 'continue' }); - expect(ctx.ui.setStatus).toHaveBeenCalledWith( - ACTIVITY_STATUS_KEY, - expect.stringContaining('skill:audit') - ); - }); - - it('sets indicator for /skill:name with arguments (includes the ID)', async () => { - registerActivityIndicator(pi as ExtensionAPI); - - const ctx = createMockContext(); - const event: InputEvent = { - type: 'input', - text: '/skill:implement WL-123', - source: 'interactive', - }; - - await inputHandlers[0](event, ctx); - - // Should include the full input after /skill: prefix (skill name + ID) - expect(ctx.ui.setStatus).toHaveBeenCalledWith( - ACTIVITY_STATUS_KEY, - expect.stringContaining('implement WL-123') - ); - }); - - it('leaves indicator unchanged for free-form text (no / prefix)', async () => { - registerActivityIndicator(pi as ExtensionAPI); - - const ctx = createMockContext(); - const event: InputEvent = { - type: 'input', - text: 'Hello, how can I help?', - source: 'interactive', - }; - - await inputHandlers[0](event, ctx); - - // Free-form text should NOT clear the indicator - expect(ctx.ui.setStatus).not.toHaveBeenCalled(); - }); - - it('leaves indicator unchanged for built-in Pi commands', async () => { - registerActivityIndicator(pi as ExtensionAPI); - - const ctx = createMockContext(); - const event: InputEvent = { - type: 'input', - text: '/model', - source: 'interactive', - }; - - await inputHandlers[0](event, ctx); - - // Built-in commands should NOT clear the indicator - expect(ctx.ui.setStatus).not.toHaveBeenCalled(); - }); - - it('leaves indicator unchanged for built-in Pi commands with arguments', async () => { - registerActivityIndicator(pi as ExtensionAPI); - - const ctx = createMockContext(); - const event: InputEvent = { - type: 'input', - text: '/settings thinking high', - source: 'interactive', - }; - - await inputHandlers[0](event, ctx); - - // Built-in commands with arguments should NOT clear the indicator - expect(ctx.ui.setStatus).not.toHaveBeenCalled(); - }); - - it('leaves indicator unchanged for /new command (session_start handles clearing)', async () => { - registerActivityIndicator(pi as ExtensionAPI); - - const ctx = createMockContext(); - const event: InputEvent = { - type: 'input', - text: '/new', - source: 'interactive', - }; - - await inputHandlers[0](event, ctx); - - // /new is handled by session_start (reason: "new"), not the input handler - expect(ctx.ui.setStatus).not.toHaveBeenCalled(); - }); - - it('sets indicator showing full input for unknown /-prefixed text', async () => { - registerActivityIndicator(pi as ExtensionAPI); - - const ctx = createMockContext(); - const event: InputEvent = { - type: 'input', - text: '/intake WL-123', - source: 'interactive', - }; - - await inputHandlers[0](event, ctx); - - // Should show the full input including the ID, not just the first word - expect(ctx.ui.setStatus).toHaveBeenCalledWith( - ACTIVITY_STATUS_KEY, - expect.stringContaining('/intake WL-123') - ); - }); - - it('sets indicator with full input for command with long arguments', async () => { - registerActivityIndicator(pi as ExtensionAPI); - - const ctx = createMockContext(); - const event: InputEvent = { - type: 'input', - text: '/intake WL-0MQL0T5TR0060AEH', - source: 'interactive', - }; - - await inputHandlers[0](event, ctx); - - expect(ctx.ui.setStatus).toHaveBeenCalledWith( - ACTIVITY_STATUS_KEY, - expect.stringContaining('/intake WL-0MQL0T5TR0060AEH') - ); - }); - - it('does not set indicator for /skill: command when isActivityEnabled returns false', async () => { - registerActivityIndicator(pi as ExtensionAPI, () => false); - expect(inputHandlers.length).toBe(1); - - const ctx = createMockContext(); - const event: InputEvent = { - type: 'input', - text: '/skill:audit', - source: 'interactive', - }; - - await inputHandlers[0](event, ctx); - - expect(ctx.ui.setStatus).not.toHaveBeenCalled(); - }); - - it('does not set indicator for unknown /-prefixed command when isActivityEnabled returns false', async () => { - registerActivityIndicator(pi as ExtensionAPI, () => false); - - const ctx = createMockContext(); - const event: InputEvent = { - type: 'input', - text: '/intake WL-0MQL0T5TR0060AEH', - source: 'interactive', - }; - - await inputHandlers[0](event, ctx); - - expect(ctx.ui.setStatus).not.toHaveBeenCalled(); - }); - - it('handles empty text gracefully (leaves indicator unchanged)', async () => { - registerActivityIndicator(pi as ExtensionAPI); - - const ctx = createMockContext(); - const event: InputEvent = { - type: 'input', - text: '', - source: 'interactive', - }; - - await inputHandlers[0](event, ctx); - - // Empty/free-form text should not clear the indicator - expect(ctx.ui.setStatus).not.toHaveBeenCalled(); - }); - - it('handles whitespace-only text as free-form (leaves indicator unchanged)', async () => { - registerActivityIndicator(pi as ExtensionAPI); - - const ctx = createMockContext(); - const event: InputEvent = { - type: 'input', - text: ' ', - source: 'interactive', - }; - - await inputHandlers[0](event, ctx); - - // Whitespace-only/free-form text should not clear the indicator - expect(ctx.ui.setStatus).not.toHaveBeenCalled(); - }); - - describe('work item ID resolution', () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - it('shows raw text immediately, then resolves title for command with work item ID', async () => { - // Mock runWl to return a successful title lookup - mockRunWl.mockResolvedValueOnce({ title: 'Fix login bug that crashes on startup' }); - - registerActivityIndicator(pi as ExtensionAPI); - const ctx = createMockContext(); - const event: InputEvent = { - type: 'input', - text: '/intake WL-0MQL0T5TR0060AEH', - source: 'interactive', - }; - - await inputHandlers[0](event, ctx); - - // Should have shown raw text first, then replaced with command + ID + title - expect(ctx.ui.setStatus).toHaveBeenCalledWith( - ACTIVITY_STATUS_KEY, - expect.stringContaining('WL-0MQL0T5TR0060AEH') - ); - // Final display should include the command context alongside ID + title. - // Work item IDs are truncated to PREFIX...LAST4 format for display. - const lastCallArg = (ctx.ui.setStatus as ReturnType<typeof vi.fn>).mock.calls.slice(-1)[0][1] as string; - expect(lastCallArg).toContain('/intake'); - expect(lastCallArg).toContain('WL...0AEH'); - expect(lastCallArg).not.toContain('WL-0MQL0T5TR0060AEH'); - expect(lastCallArg).toContain('Fix login bug'); - - // Verify runWl was called with the correct arguments - expect(mockRunWl).toHaveBeenCalledWith('show', ['WL-0MQL0T5TR0060AEH'], { timeout: 2000 }); - }); - - it('falls back to raw text on work item ID lookup failure', async () => { - // Mock runWl to reject (lookup failure) - mockRunWl.mockRejectedValueOnce(new Error('Work item not found')); - - registerActivityIndicator(pi as ExtensionAPI); - const ctx = createMockContext(); - const event: InputEvent = { - type: 'input', - text: '/intake WL-0MQL0T5TR0060AEH', - source: 'interactive', - }; - - await inputHandlers[0](event, ctx); - - // Should still show raw text (not cleared) - expect(ctx.ui.setStatus).toHaveBeenCalledWith( - ACTIVITY_STATUS_KEY, - expect.stringContaining('/intake WL-0MQL0T5TR0060AEH') - ); - }); - - it('resolves title for /skill: command with work item ID', async () => { - mockRunWl.mockResolvedValueOnce({ title: 'Add user authentication' }); - - registerActivityIndicator(pi as ExtensionAPI); - const ctx = createMockContext(); - const event: InputEvent = { - type: 'input', - text: '/skill:implement WL-0MP15TA8J009NZUU', - source: 'interactive', - }; - - await inputHandlers[0](event, ctx); - - // Should show skill name (with /skill: prefix stripped) + truncated ID + title - const lastCallArg = (ctx.ui.setStatus as ReturnType<typeof vi.fn>).mock.calls.slice(-1)[0][1] as string; - expect(lastCallArg).not.toContain('/skill:'); - expect(lastCallArg).toContain('implement'); - expect(lastCallArg).toContain('WL...NZUU'); - expect(lastCallArg).not.toContain('WL-0MP15TA8J009NZUU'); - expect(lastCallArg).toContain('Add user authentication'); - expect(mockRunWl).toHaveBeenCalledWith('show', ['WL-0MP15TA8J009NZUU'], { timeout: 2000 }); - }); - - it('preserves existing behavior for command without work item ID', async () => { - registerActivityIndicator(pi as ExtensionAPI); - const ctx = createMockContext(); - const event: InputEvent = { - type: 'input', - text: '/intake some text without ID', - source: 'interactive', - }; - - await inputHandlers[0](event, ctx); - - // Should show full raw text (existing behavior) - expect(ctx.ui.setStatus).toHaveBeenCalledWith( - ACTIVITY_STATUS_KEY, - expect.stringContaining('/intake some text') - ); - // No wl show call should have been made - expect(mockRunWl).not.toHaveBeenCalled(); - }); - - it('resolves title for unknown /-prefixed command with work item ID', async () => { - mockRunWl.mockResolvedValueOnce({ title: 'Resolve work item IDs to titles' }); - - registerActivityIndicator(pi as ExtensionAPI); - const ctx = createMockContext(); - const event: InputEvent = { - type: 'input', - text: '/custom-command WL-0MQLG8PK80041FM3', - source: 'interactive', - }; - - await inputHandlers[0](event, ctx); - - // Should show command + truncated ID + title - const lastCallArg = (ctx.ui.setStatus as ReturnType<typeof vi.fn>).mock.calls.slice(-1)[0][1] as string; - expect(lastCallArg).toContain('/custom-command'); - expect(lastCallArg).toContain('WL...1FM3'); - expect(lastCallArg).not.toContain('WL-0MQLG8PK80041FM3'); - expect(lastCallArg).toContain('Resolve work item IDs to titles'); - expect(mockRunWl).toHaveBeenCalledWith('show', ['WL-0MQLG8PK80041FM3'], { timeout: 2000 }); - }); - - it('uses the first work item ID when multiple IDs are present in input', async () => { - mockRunWl.mockResolvedValueOnce({ title: 'First work item title' }); - - registerActivityIndicator(pi as ExtensionAPI); - const ctx = createMockContext(); - const event: InputEvent = { - type: 'input', - text: '/implement WL-0MQL0T5TR0060AEH and WL-0MQLG8PK80041FM3', - source: 'interactive', - }; - - await inputHandlers[0](event, ctx); - - // Should look up only the first ID - expect(mockRunWl).toHaveBeenCalledTimes(1); - expect(mockRunWl).toHaveBeenCalledWith('show', ['WL-0MQL0T5TR0060AEH'], { timeout: 2000 }); - - const lastCallArg = (ctx.ui.setStatus as ReturnType<typeof vi.fn>).mock.calls.slice(-1)[0][1] as string; - expect(lastCallArg).toContain('/implement'); - expect(lastCallArg).toContain('WL...0AEH'); - expect(lastCallArg).not.toContain('WL-0MQL0T5TR0060AEH'); - expect(lastCallArg).toContain('First work item title'); - }); - }); -}); - -describe('registerActivityIndicator - session_start events', () => { - let pi: Partial<ExtensionAPI>; - let sessionStartHandlers: Array<(event: SessionStartEvent, ctx: ExtensionContext) => Promise<any>>; - - beforeEach(() => { - sessionStartHandlers = []; - pi = { - on: vi.fn((event: string, handler: any) => { - if (event === 'session_start') { - sessionStartHandlers.push(handler); - } - }) as any, - }; - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - function createMockContext(): ExtensionContext { - const setStatus = vi.fn(); - const theme = { fg: vi.fn((_color: string, text: string) => text) }; - return { - ui: { setStatus, theme } as unknown as ExtensionUIContext, - mode: 'tui', - hasUI: true, - cwd: '/test', - sessionManager: { - getBranch: vi.fn().mockReturnValue([]), - } as any, - model: undefined, - modelRegistry: {} as any, - isIdle: vi.fn().mockReturnValue(true), - isProjectTrusted: vi.fn().mockReturnValue(true), - signal: undefined, - abort: vi.fn(), - hasPendingMessages: vi.fn().mockReturnValue(false), - shutdown: vi.fn(), - getContextUsage: vi.fn(), - compact: vi.fn(), - getSystemPrompt: vi.fn().mockReturnValue(''), - }; - } - - it('clears indicator on new session (reason: "new")', async () => { - registerActivityIndicator(pi as ExtensionAPI); - expect(sessionStartHandlers.length).toBe(1); - - const ctx = createMockContext(); - const event: SessionStartEvent = { - type: 'session_start', - reason: 'new', - }; - - await sessionStartHandlers[0](event, ctx); - - expect(ctx.ui.setStatus).toHaveBeenCalledWith(ACTIVITY_STATUS_KEY, undefined); - }); - - it('clears indicator on startup (reason: "startup")', async () => { - registerActivityIndicator(pi as ExtensionAPI); - - const ctx = createMockContext(); - const event: SessionStartEvent = { - type: 'session_start', - reason: 'startup', - }; - - await sessionStartHandlers[0](event, ctx); - - expect(ctx.ui.setStatus).toHaveBeenCalledWith(ACTIVITY_STATUS_KEY, undefined); - }); - - it('clears indicator on reload (reason: "reload")', async () => { - registerActivityIndicator(pi as ExtensionAPI); - - const ctx = createMockContext(); - const event: SessionStartEvent = { - type: 'session_start', - reason: 'reload', - }; - - await sessionStartHandlers[0](event, ctx); - - expect(ctx.ui.setStatus).toHaveBeenCalledWith(ACTIVITY_STATUS_KEY, undefined); - }); - - it('clears indicator on fork (reason: "fork")', async () => { - registerActivityIndicator(pi as ExtensionAPI); - - const ctx = createMockContext(); - const event: SessionStartEvent = { - type: 'session_start', - reason: 'fork', - }; - - await sessionStartHandlers[0](event, ctx); - - expect(ctx.ui.setStatus).toHaveBeenCalledWith(ACTIVITY_STATUS_KEY, undefined); - }); - - it('attempts to recover last command on resume (reason: "resume")', async () => { - registerActivityIndicator(pi as ExtensionAPI); - - const setStatus = vi.fn(); - const theme = { fg: vi.fn((_color: string, text: string) => text) }; - const ctx = { - ui: { setStatus, theme } as unknown as ExtensionUIContext, - mode: 'tui', - hasUI: true, - cwd: '/test', - sessionManager: { - getBranch: vi.fn().mockReturnValue([ - { - type: 'message', - message: { - role: 'user', - content: [{ type: 'text', text: '/wl list' }], - }, - }, - ]), - } as any, - model: undefined, - modelRegistry: {} as any, - isIdle: vi.fn().mockReturnValue(true), - isProjectTrusted: vi.fn().mockReturnValue(true), - signal: undefined, - abort: vi.fn(), - hasPendingMessages: vi.fn().mockReturnValue(false), - shutdown: vi.fn(), - getContextUsage: vi.fn(), - compact: vi.fn(), - getSystemPrompt: vi.fn().mockReturnValue(''), - }; - - const event: SessionStartEvent = { - type: 'session_start', - reason: 'resume', - previousSessionFile: '/path/to/session.jsonl', - }; - - await sessionStartHandlers[0](event, ctx); - - // Should have recovered the /wl command - expect(setStatus).toHaveBeenCalledWith( - ACTIVITY_STATUS_KEY, - expect.stringContaining('/wl') - ); - }); - - it('attempts to recover skill command on resume', async () => { - registerActivityIndicator(pi as ExtensionAPI); - - const setStatus = vi.fn(); - const theme = { fg: vi.fn((_color: string, text: string) => text) }; - const ctx = { - ui: { setStatus, theme } as unknown as ExtensionUIContext, - mode: 'tui', - hasUI: true, - cwd: '/test', - sessionManager: { - getBranch: vi.fn().mockReturnValue([ - { - type: 'message', - message: { - role: 'user', - content: [{ type: 'text', text: '/skill:audit WL-123' }], - }, - }, - ]), - } as any, - model: undefined, - modelRegistry: {} as any, - isIdle: vi.fn().mockReturnValue(true), - isProjectTrusted: vi.fn().mockReturnValue(true), - signal: undefined, - abort: vi.fn(), - hasPendingMessages: vi.fn().mockReturnValue(false), - shutdown: vi.fn(), - getContextUsage: vi.fn(), - compact: vi.fn(), - getSystemPrompt: vi.fn().mockReturnValue(''), - }; - - const event: SessionStartEvent = { - type: 'session_start', - reason: 'resume', - }; - - await sessionStartHandlers[0](event, ctx); - - // Should have recovered the skill command - expect(setStatus).toHaveBeenCalledWith( - ACTIVITY_STATUS_KEY, - expect.stringContaining('skill:audit') - ); - }); - - it('clears indicator on resume if no recoverable command found', async () => { - registerActivityIndicator(pi as ExtensionAPI); - - const ctx = createMockContext(); - const event: SessionStartEvent = { - type: 'session_start', - reason: 'resume', - }; - - await sessionStartHandlers[0](event, ctx); - - // No user commands in history — should clear - expect(ctx.ui.setStatus).toHaveBeenCalledWith(ACTIVITY_STATUS_KEY, undefined); - }); - - it('clears indicator on resume if last user entry is free-form text', async () => { - registerActivityIndicator(pi as ExtensionAPI); - - const setStatus = vi.fn(); - const theme = { fg: vi.fn((_color: string, text: string) => text) }; - const ctx = { - ui: { setStatus, theme } as unknown as ExtensionUIContext, - mode: 'tui', - hasUI: true, - cwd: '/test', - sessionManager: { - getBranch: vi.fn().mockReturnValue([ - { - type: 'message', - message: { - role: 'user', - content: [{ type: 'text', text: 'Please fix the bug' }], - }, - }, - ]), - } as any, - model: undefined, - modelRegistry: {} as any, - isIdle: vi.fn().mockReturnValue(true), - isProjectTrusted: vi.fn().mockReturnValue(true), - signal: undefined, - abort: vi.fn(), - hasPendingMessages: vi.fn().mockReturnValue(false), - shutdown: vi.fn(), - getContextUsage: vi.fn(), - compact: vi.fn(), - getSystemPrompt: vi.fn().mockReturnValue(''), - }; - - const event: SessionStartEvent = { - type: 'session_start', - reason: 'resume', - }; - - await sessionStartHandlers[0](event, ctx); - - // Free-form text should not be recovered - expect(setStatus).toHaveBeenCalledWith(ACTIVITY_STATUS_KEY, undefined); - }); - - it('clears indicator on resume when isActivityEnabled returns false', async () => { - registerActivityIndicator(pi as ExtensionAPI, () => false); - expect(sessionStartHandlers.length).toBe(1); - - const ctx = createMockContext(); - const event: SessionStartEvent = { - type: 'session_start', - reason: 'resume', - previousSessionFile: '/path/to/session.jsonl', - }; - - await sessionStartHandlers[0](event, ctx); - - // Should clear indicator instead of attempting recovery - expect(ctx.ui.setStatus).toHaveBeenCalledWith(ACTIVITY_STATUS_KEY, undefined); - }); -}); - -describe('registerActivityIndicator - wiring', () => { - it('registers input and session_start event handlers', () => { - const on = vi.fn(); - const pi = { on } as unknown as ExtensionAPI; - - registerActivityIndicator(pi); - - expect(on).toHaveBeenCalledWith('input', expect.any(Function)); - expect(on).toHaveBeenCalledWith('session_start', expect.any(Function)); - }); - - it('handler registration order is preserved (input first, then session_start)', () => { - const on = vi.fn(); - const pi = { on } as unknown as ExtensionAPI; - - registerActivityIndicator(pi); - - expect(on.mock.calls[0][0]).toBe('input'); - expect(on.mock.calls[1][0]).toBe('session_start'); - }); -}); diff --git a/packages/tui/tests/browse-auto-refresh.test.ts b/packages/tui/tests/browse-auto-refresh.test.ts deleted file mode 100644 index d2e4b5c2e..000000000 --- a/packages/tui/tests/browse-auto-refresh.test.ts +++ /dev/null @@ -1,1007 +0,0 @@ -vi.mock('@earendil-works/pi-coding-agent', () => ({ - getAgentDir: () => '/home/test-user/.pi/agent', -})); - -vi.mock('node:fs', () => ({ - readFileSync: vi.fn((path) => { - if (String(path).endsWith('shortcuts.json')) { - return JSON.stringify([ - { key: 'n', command: '/intake <id>', view: 'both', stages: ['idea'] }, - { key: 'p', command: '/plan <id>', view: 'both', stages: ['intake_complete'] }, - { key: 'i', command: '/skill:implement <id>', view: 'both', stages: ['intake_complete', 'plan_complete', 'in_progress'] }, - { key: 'a', command: '/skill:audit <id>', view: 'both', stages: ['in_progress', 'in_review'] }, - ]); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }), - writeFileSync: vi.fn(), - mkdirSync: vi.fn(), - realpathSync: vi.fn((p) => p), - existsSync: vi.fn(() => false), -})); - -/** - - * Tests for the auto-refresh feature in the browse selection list. - - * - - * Verifies that: - - * - The items list is re-fetched every 5 seconds when reFetchItems is provided - - * - The currently selected item remains selected after refresh if its ID exists - - * - Selection falls back to index 0 when the selected item no longer exists - - * - The interval is cleaned up when the overlay closes (done() is called) - - * - Auto-refresh does not cause errors during normal operation - - * - - * Run: npx vitest run packages/tui/tests/browse-auto-refresh.test.ts - - */ - - - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -import { defaultChooseWorkItem, buildSelectionWidget, type WorklogBrowseItem, type SelectionChangeHandler } from '../extensions/Worklog/index.js'; -import { ShortcutRegistry, type ShortcutEntry } from '../extensions/Worklog/shortcut-config.js'; -import { type Settings } from '../extensions/Worklog/settings-config.js'; - -describe('Browse list auto-refresh', () => { - let items: WorklogBrowseItem[]; - let reFetchItems: ReturnType<typeof vi.fn>; - - beforeEach(() => { - vi.useFakeTimers(); - items = [ - { id: 'WL-001', title: 'First item', status: 'open' }, - { id: 'WL-002', title: 'Second item', status: 'in_progress' }, - { id: 'WL-003', title: 'Third item', status: 'open' }, - ]; - reFetchItems = vi.fn(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - /** - * Create a mock BrowseContext that captures the widget factory output. - * Returns the mock context and helpers to inspect rendered output and - * interact with the widget. - */ - function createMockContext() { - let capturedWidget: { - render: (width: number) => string[]; - invalidate: () => void; - handleInput?: (data: string) => void; - } | null = null; - let capturedTui: { requestRender: ReturnType<typeof vi.fn> } | null = null; - let capturedDone: ReturnType<typeof vi.fn> | null = null; - - const mockUi = { - custom: vi.fn(<T>( - factory: ( - tui: any, - theme: any, - _keybindings: unknown, - done: (value: T) => void, - ) => { - render: (width: number) => string[]; - invalidate: () => void; - handleInput?: (data: string) => void; - }, - ) => { - const tui = { requestRender: vi.fn() }; - const theme = { - fg: vi.fn((_color: string, text: string) => text), - bold: vi.fn((text: string) => text), - }; - const done = vi.fn(); - - capturedWidget = factory(tui, theme, undefined, done); - capturedTui = tui; - capturedDone = done; - - // Return a never-resolving promise to keep the overlay "open" - return new Promise<T>(() => { /* never resolves */ }); - }), - notify: vi.fn(), - setEditorText: vi.fn(), - setWidget: vi.fn(), - select: vi.fn(), - }; - - return { - ctx: { ui: mockUi }, - getWidget: () => capturedWidget, - getTui: () => capturedTui, - getDone: () => capturedDone, - }; - } - - it('re-fetches items and re-renders after 5 seconds when reFetchItems is provided', async () => { - const { ctx, getWidget, getTui } = createMockContext(); - - // Set up reFetchItems to return updated items - reFetchItems.mockResolvedValue([ - { id: 'WL-001', title: 'First item (updated)', status: 'open' }, - { id: 'WL-004', title: 'Brand new item', status: 'open' }, - ]); - - // Start the browse dialog (don't await — it never resolves in the mock) - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, reFetchItems); - - // Initial render should show original items - const widget = getWidget(); - expect(widget).not.toBeNull(); - - // Advance timers by 5 seconds to trigger the refresh - await vi.advanceTimersByTimeAsync(5000); - - // reFetchItems should have been called - expect(reFetchItems).toHaveBeenCalledTimes(1); - - // The tui.requestRender should have been called to trigger re-render - expect(getTui()?.requestRender).toHaveBeenCalled(); - - // Re-render to see updated items - const linesAfter = widget!.render(80); - // The updated items should now be rendered - const rendered = linesAfter.join('\n'); - expect(rendered).toContain('First item (updated)'); - expect(rendered).toContain('Brand new item'); - // Original title should no longer be present - expect(rendered).not.toContain('Second item'); - expect(rendered).not.toContain('Third item'); - }); - - it('preserves the selected item index when its ID still exists after refresh', async () => { - const { ctx, getWidget } = createMockContext(); - - // Start with selection on the second item (index 1) - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, reFetchItems); - const widget = getWidget()!; - - // Simulate navigating down to select the second item - // The initial selection is index 0 (first item), so press Down to move to index 1 - widget.handleInput!('\u001b[B'); // down key - const linesBefore = widget.render(80); - // The selected item line should have '›' prefix (icons appear between › and title) - const lineWithSecondBefore = linesBefore.find(l => l.includes('Second item')); - expect(lineWithSecondBefore).toBeDefined(); - expect(lineWithSecondBefore).toContain('›'); - - // Now refresh with updated items that still contain 'Second item' at a different position - reFetchItems.mockResolvedValue([ - { id: 'WL-003', title: 'Third item', status: 'open' }, - { id: 'WL-002', title: 'Second item (updated)', status: 'in_progress' }, - { id: 'WL-001', title: 'First item', status: 'open' }, - ]); - - await vi.advanceTimersByTimeAsync(5000); - - // After refresh, selection should be on the item with ID WL-002 (now at index 1) - const linesAfter = widget.render(80); - const rendered = linesAfter.join('\n'); - // The selected item marker (›) should be on the Second item line - const lineWithSecond = linesAfter.find(l => l.includes('Second item (updated)')); - expect(lineWithSecond).toBeDefined(); - expect(lineWithSecond).toContain('›'); - }); - - it('falls back to index 0 when the previously selected item no longer exists after refresh', async () => { - const { ctx, getWidget } = createMockContext(); - - // Navigate to second item then refresh with items that don't include WL-002 - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, reFetchItems); - const widget = getWidget()!; - - // Navigate down once to select WL-002 (index 1) - widget.handleInput!('\u001b[B'); - - // Refresh with items that only contain WL-001 and WL-003 (WL-002 removed) - reFetchItems.mockResolvedValue([ - { id: 'WL-001', title: 'First item', status: 'open' }, - { id: 'WL-003', title: 'Third item', status: 'open' }, - ]); - - await vi.advanceTimersByTimeAsync(5000); - - // Selection should have fallen back to index 0 (WL-001) - const linesAfter = widget.render(80); - const firstItemLine = linesAfter.find(l => l.includes('First item')); - expect(firstItemLine).toBeDefined(); - expect(firstItemLine).toContain('›'); - }); - - it('does NOT re-fetch when reFetchItems is not provided', async () => { - const { ctx, reFetchItems: _unused } = createMockContext(); - - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, undefined); - - // Even with no reFetchItems provided, reFetchItems mock shouldn't be called - // We just verify the widget works without auto-refresh - await vi.advanceTimersByTimeAsync(5000); - - // No error should occur - expect(true).toBe(true); - }); - - it('silently handles errors from reFetchItems without crashing', async () => { - const { ctx, getWidget } = createMockContext(); - - // reFetchItems returns a rejected promise - reFetchItems.mockRejectedValue(new Error('Network error')); - - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, reFetchItems); - - // Initial state should be fine - const widget = getWidget()!; - let linesBefore = widget.render(80); - expect(linesBefore.join('\n')).toContain('First item'); - - // Advance timers - the error should be caught silently - await vi.advanceTimersByTimeAsync(5000); - - // Widget should still work after error - const linesAfter = widget.render(80); - expect(linesAfter.join('\n')).toContain('First item'); - expect(linesAfter.join('\n')).toContain('Second item'); - }); - - it('cleans up the interval when the overlay is closed via Enter', async () => { - const { ctx, getWidget, getDone } = createMockContext(); - - reFetchItems.mockResolvedValue([ - { id: 'WL-001', title: 'New title', status: 'open' }, - ]); - - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, reFetchItems); - const widget = getWidget()!; - const done = getDone()!; - - // Close the overlay by pressing Enter - widget.handleInput!('\r'); // enter key - - // After done() is called, advance timers — reFetchItems should NOT be called again - await vi.advanceTimersByTimeAsync(5000); - - // reFetchItems should have been called exactly 0 times (interval was cleared before it could fire) - // But actually the interval might have fired once before Enter was pressed, - // depending on timing. Let me restructure to be more precise. - // Since we're using fake timers and the interval setup is synchronous, - // the interval hasn't fired yet when we press Enter. So reFetchItems should be 0. - expect(reFetchItems).toHaveBeenCalledTimes(0); - expect(done).toHaveBeenCalled(); - }); - - it('cleans up the interval when shortcut is dispatched', async () => { - // Create a registry with a simple shortcut - const entries: ShortcutEntry[] = [ - { key: 'i', command: '/implement <id>', view: 'list' }, - ]; - const registry = new ShortcutRegistry(entries); - const { ctx, getWidget, getDone } = createMockContext(); - - reFetchItems.mockResolvedValue([ - { id: 'WL-001', title: 'New title', status: 'open' }, - ]); - - defaultChooseWorkItem(items, ctx, vi.fn(), registry, reFetchItems); - const widget = getWidget()!; - const done = getDone()!; - - // Dispatch a shortcut by pressing 'i' - widget.handleInput!('i'); - - // After done() is called via shortcut dispatch, advance timers - await vi.advanceTimersByTimeAsync(5000); - - // reFetchItems should not have been called (interval was cleared when done was called) - expect(reFetchItems).toHaveBeenCalledTimes(0); - expect(done).toHaveBeenCalledWith(expect.objectContaining({ type: 'shortcut' })); - }); - - it('does not refresh while a chord leader is pending', async () => { - // Create registry with chord entries - const chordEntries: ShortcutEntry[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'list' }, - { chord: ['u', 't'], command: 'update-title <id>', view: 'list' }, - ]; - const registry = new ShortcutRegistry(chordEntries); - const { ctx, getWidget } = createMockContext(); - - reFetchItems.mockResolvedValue([ - { id: 'WL-099', title: 'Refreshed', status: 'open' }, - ]); - - defaultChooseWorkItem(items, ctx, vi.fn(), registry, reFetchItems); - const widget = getWidget()!; - - // Simulate pressing the chord leader key 'u' to enter pending state - widget.handleInput!('u'); - - // Advance timers - refresh should NOT fire because chord is pending - await vi.advanceTimersByTimeAsync(5000); - - // reFetchItems should NOT have been called - expect(reFetchItems).not.toHaveBeenCalled(); - - // Now cancel the chord by pressing Escape - widget.handleInput!('\u001b'); // escape key - - // Advance timers again - now refresh should fire - await vi.advanceTimersByTimeAsync(5000); - - // reFetchItems should have been called after chord was cancelled - expect(reFetchItems).toHaveBeenCalledTimes(1); - }); - - it('refreshes children via fetchChildren while navigating children (navStack non-empty)', async () => { - const rootItems = [ - { id: 'WL-001', title: 'Parent item', status: 'open', childCount: 2 }, - { id: 'WL-002', title: 'Standalone item', status: 'open' }, - ]; - const childItems = [ - { id: 'WL-010', title: 'Child one', status: 'open' }, - { id: 'WL-011', title: 'Child two', status: 'open' }, - ]; - const updatedChildItems = [ - { id: 'WL-011', title: 'Child two (updated)', status: 'open' }, - { id: 'WL-012', title: 'New child', status: 'open' }, - ]; - const fetchChildren = vi.fn(); - // First call returns initial children, subsequent calls return updated - fetchChildren.mockResolvedValueOnce(childItems); - fetchChildren.mockResolvedValue(updatedChildItems); - - const { ctx, getWidget } = createMockContext(); - - reFetchItems.mockResolvedValue([ - { id: 'WL-099', title: 'Refreshed root items', status: 'open' }, - ]); - - defaultChooseWorkItem(rootItems, ctx, vi.fn(), undefined, reFetchItems, fetchChildren); - const widget = getWidget()!; - - // Navigate into children by pressing Tab on parent item (index 0) - widget.handleInput!('\t'); - await vi.advanceTimersByTimeAsync(10); - - // Verify we're viewing children - let lines = widget.render(80); - expect(lines.join('\n')).toContain('Child one'); - expect(lines.join('\n')).toContain('Child two'); - - // Verify fetchChildren was called with the correct parent ID - expect(fetchChildren).toHaveBeenCalledWith('WL-001'); - const firstCallCount = fetchChildren.mock.calls.length; - - // Advance timers by 5 seconds — auto-refresh should fire and call fetchChildren - await vi.advanceTimersByTimeAsync(5000); - - // fetchChildren should have been called again with the same parent ID - expect(fetchChildren).toHaveBeenCalledTimes(firstCallCount + 1); - expect(fetchChildren).toHaveBeenLastCalledWith('WL-001'); - - // reFetchItems should NOT have been called (we are not at root level) - expect(reFetchItems).not.toHaveBeenCalled(); - - // The updated children should now be visible - lines = widget.render(80); - const rendered = lines.join('\n'); - expect(rendered).toContain('Child two (updated)'); - expect(rendered).toContain('New child'); - // Original items that are no longer in the refreshed set should be gone - expect(rendered).not.toContain('Child one'); - // The ".." entry should still be present - expect(rendered).toContain('..'); - }); - - it('uses reFetchItems at root level but fetchChildren when viewing children', async () => { - const rootItems = [ - { id: 'WL-001', title: 'Parent item', status: 'open', childCount: 2 }, - { id: 'WL-002', title: 'Standalone item', status: 'open' }, - ]; - const childItems = [ - { id: 'WL-010', title: 'Child one', status: 'open' }, - { id: 'WL-011', title: 'Child two', status: 'open' }, - ]; - const fetchChildren = vi.fn(); - fetchChildren.mockResolvedValue(childItems); - - const { ctx, getWidget } = createMockContext(); - - reFetchItems.mockResolvedValue([ - { id: 'WL-099', title: 'Refreshed after root', status: 'open' }, - ]); - - defaultChooseWorkItem(rootItems, ctx, vi.fn(), undefined, reFetchItems, fetchChildren); - const widget = getWidget()!; - - // Navigate into children using Tab - widget.handleInput!('\t'); - await vi.advanceTimersByTimeAsync(10); - - // Advance timers — should use fetchChildren, not reFetchItems - await vi.advanceTimersByTimeAsync(5000); - - expect(fetchChildren).toHaveBeenCalledWith('WL-001'); - expect(reFetchItems).not.toHaveBeenCalled(); - - // Navigate back to root via Escape, then advance timers - widget.handleInput!('\u001b'); - await vi.advanceTimersByTimeAsync(5000); - - // Now at root level, reFetchItems SHOULD be called - expect(reFetchItems).toHaveBeenCalledTimes(1); - const lines = widget.render(80); - expect(lines.join('\n')).toContain('Refreshed after root'); - }); - - it('properly applies sorted order from wl next on auto-refresh', async () => { - const { ctx, getWidget } = createMockContext(); - - // Initial items in unsorted order (simulating how they might arrive) - // The auto-refresh should replace with correctly sorted items - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, reFetchItems); - const widget = getWidget()!; - - // Render initial items and capture display order - let lines = widget.render(80); - const initialRendered = lines.join('\n'); - const initialOrder = [ - initialRendered.indexOf('First item'), - initialRendered.indexOf('Second item'), - initialRendered.indexOf('Third item'), - ]; - - // Simulate wl next returning items in a different order (sorted) - reFetchItems.mockResolvedValue([ - { id: 'WL-003', title: 'Third item', status: 'open' }, // was last, now first - { id: 'WL-001', title: 'First item', status: 'open' }, - { id: 'WL-002', title: 'Second item', status: 'in_progress' }, // moved to end (in_progress, different priority) - ]); - - // Advance timers by 5 seconds to trigger refresh - await vi.advanceTimersByTimeAsync(5000); - - lines = widget.render(80); - const rendered = lines.join('\n'); - - // The order in the rendered list should match the new sorted order - const orderAfter = [ - rendered.indexOf('Third item'), - rendered.indexOf('First item'), - rendered.indexOf('Second item'), - ]; - - // Each item should appear before the next one in the sorted order - expect(orderAfter[0]).toBeLessThan(orderAfter[1]); - expect(orderAfter[1]).toBeLessThan(orderAfter[2]); - - // All three items should still be present - expect(rendered).toContain('First item'); - expect(rendered).toContain('Second item'); - expect(rendered).toContain('Third item'); - }); - - it('calls onSelectionChange when auto-refresh provides updated data for the same item ID', async () => { - const { ctx } = createMockContext(); - const onSelectionChange = vi.fn(); - - // Mock onSelectionChange to simulate announceSelection-like behavior - // (tracks last announced ID for verification purposes but DOES NOT suppress calls) - defaultChooseWorkItem(items, ctx, onSelectionChange, undefined, reFetchItems); - - // Reset mock so we only track auto-refresh calls - onSelectionChange.mockClear(); - - // Set up reFetchItems to return updated data for the same item (WL-001) - // Status changed from 'open' to 'in_progress' - reFetchItems.mockResolvedValue([ - { id: 'WL-001', title: 'First item', status: 'in_progress' }, - { id: 'WL-002', title: 'Second item', status: 'in_progress' }, - { id: 'WL-003', title: 'Third item', status: 'open' }, - ]); - - // Advance timers by 5 seconds to trigger the refresh - await vi.advanceTimersByTimeAsync(5000); - - // onSelectionChange should have been called with the updated item - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(onSelectionChange).toHaveBeenCalledWith( - expect.objectContaining({ id: 'WL-001', status: 'in_progress' }) - ); - }); - - it('calls onSelectionChange on each auto-refresh even when item ID stays the same', async () => { - const { ctx } = createMockContext(); - const onSelectionChange = vi.fn(); - - defaultChooseWorkItem(items, ctx, onSelectionChange, undefined, reFetchItems); - - // Reset mock to track only auto-refresh calls - onSelectionChange.mockClear(); - - // ReFetchItems returns same items (no data change) - reFetchItems.mockResolvedValue([ - { id: 'WL-001', title: 'First item', status: 'open' }, - { id: 'WL-002', title: 'Second item', status: 'in_progress' }, - { id: 'WL-003', title: 'Third item', status: 'open' }, - ]); - - // First auto-refresh cycle - await vi.advanceTimersByTimeAsync(5000); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(onSelectionChange).toHaveBeenCalledWith( - expect.objectContaining({ id: 'WL-001' }) - ); - - // Second auto-refresh cycle (still same data) - await vi.advanceTimersByTimeAsync(5000); - expect(onSelectionChange).toHaveBeenCalledTimes(2); - }); - - it('does not suppress widget rebuilds when announceSelection receives same item ID with changed data', async () => { - const { ctx } = createMockContext(); - const setWidget = ctx.ui.setWidget as ReturnType<typeof vi.fn>; - - // Simulate announceSelection with the fix applied (no early return for same ID) - let lastAnnouncedId: string | undefined; - const announceSelection: SelectionChangeHandler = (item) => { - // After the fix: no `if (item.id === lastAnnouncedId) return;` guard - lastAnnouncedId = item.id; - ctx.ui.setWidget?.('worklog-browse-selection', buildSelectionWidget(item), { placement: 'belowEditor' }); - }; - - // Initial announcement of first item - announceSelection(items[0]); - expect(setWidget).toHaveBeenCalledTimes(1); - expect(setWidget).toHaveBeenCalledWith( - 'worklog-browse-selection', - expect.any(Function), - { placement: 'belowEditor' } - ); - - // Re-announce same item with updated data (simulating auto-refresh providing fresh data) - const updatedItem: WorklogBrowseItem = { ...items[0], status: 'in_progress' }; - announceSelection(updatedItem); - - // After the fix, setWidget should have been called again even though the ID is the same - expect(setWidget).toHaveBeenCalledTimes(2); - expect(setWidget).toHaveBeenLastCalledWith( - 'worklog-browse-selection', - expect.any(Function), - { placement: 'belowEditor' } - ); - }); - - // ── Cross-instance synchronisation tests ──────────────────────────── - // - // These tests verify that auto-refresh correctly picks up changes made - // by another browse instance (e.g. a separate Pi TUI session) to the - // underlying work-item data source. - // - // The key bug fixed: `if (newItems.length === 0) return;` in the - // auto-refresh guard unconditionally skipped empty results, even when - // the current list was non-empty (i.e. all items were closed by another - // instance). The fix changes the guard to: - // `if (newItems.length === 0 && items.length === 0) return;` - // so that a transition from populated to empty is reflected. - - it('updates the list when items are removed in another instance (cross-instance sync)', async () => { - const { ctx, getWidget } = createMockContext(); - - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, reFetchItems); - const widget = getWidget()!; - - // Verify initial state: three items visible - let lines = widget.render(80); - expect(lines.join('\n')).toContain('First item'); - expect(lines.join('\n')).toContain('Second item'); - expect(lines.join('\n')).toContain('Third item'); - - // Simulate another instance closing the second item - reFetchItems.mockResolvedValue([ - { id: 'WL-001', title: 'First item', status: 'open' }, - { id: 'WL-003', title: 'Third item', status: 'open' }, - ]); - - // Advance timers by 5 seconds to trigger the refresh - await vi.advanceTimersByTimeAsync(5000); - - // The list should no longer show the closed item - lines = widget.render(80); - const rendered = lines.join('\n'); - expect(rendered).toContain('First item'); - expect(rendered).toContain('Third item'); - expect(rendered).not.toContain('Second item'); - }); - - it('clears the list when all items are closed in another instance', async () => { - const { ctx, getWidget } = createMockContext(); - - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, reFetchItems); - const widget = getWidget()!; - - // Verify initial state: three items visible - let lines = widget.render(80); - expect(lines.join('\n')).toContain('First item'); - - // Simulate another instance closing ALL items - reFetchItems.mockResolvedValue([]); - - // Advance timers by 5 seconds to trigger the refresh - await vi.advanceTimersByTimeAsync(5000); - - // The list should now be cleared (no item lines rendered) - lines = widget.render(80); - const rendered = lines.join('\n'); - // Title should show the empty-state notice - expect(rendered).toContain('No work items to browse'); - // The empty-state placeholder should appear - expect(rendered).toContain('No items to display'); - // No item titles should remain - expect(rendered).not.toContain('First item'); - expect(rendered).not.toContain('Second item'); - expect(rendered).not.toContain('Third item'); - // reFetchItems should have been called - expect(reFetchItems).toHaveBeenCalledTimes(1); - }); - - it('skips mutation when both the new list and current list are empty', async () => { - const { ctx, getWidget } = createMockContext(); - - // Start with an empty list - const emptyInitial: WorklogBrowseItem[] = []; - reFetchItems.mockResolvedValue([]); - - defaultChooseWorkItem(emptyInitial, ctx, vi.fn(), undefined, reFetchItems); - const widget = getWidget()!; - - // Advance timers — the interval fires and calls reFetchItems which - // returns []. The guard `if (newItems.length === 0 && items.length === 0) return;` - // then triggers because both lists are empty, preventing unnecessary - // mutation. The key point: no crash, no spurious re-render. - await vi.advanceTimersByTimeAsync(5000); - - // reFetchItems WAS called (the interval fires regardless), but the - // items array should remain empty and render should still work - expect(reFetchItems).toHaveBeenCalled(); - // Render should not crash and should show the empty-state notice - const lines = widget.render(80); - expect(lines.join('\n')).toContain('No work items to browse'); - }); - - it('preserves selection after cross-instance item removal when the selected item still exists', async () => { - const { ctx, getWidget } = createMockContext(); - - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, reFetchItems); - const widget = getWidget()!; - - // Navigate to select the second item (index 1) - widget.handleInput!('\u001b[B'); // down key - - // Render and verify second item is selected - let lines = widget.render(80); - const lineWithSecond = lines.find(l => l.includes('Second item')); - expect(lineWithSecond).toBeDefined(); - expect(lineWithSecond).toContain('›'); - - // Simulate another instance closing the THIRD item (not our selected one) - reFetchItems.mockResolvedValue([ - { id: 'WL-001', title: 'First item', status: 'open' }, - { id: 'WL-002', title: 'Second item', status: 'in_progress' }, - ]); - - await vi.advanceTimersByTimeAsync(5000); - - // Our selected item (WL-002) should still be selected - lines = widget.render(80); - const rendered = lines.join('\n'); - expect(rendered).toContain('Second item'); - expect(rendered).not.toContain('Third item'); - // The selected item marker should still be on Second item - const lineWithSecondAfter = lines.find(l => l.includes('Second item')); - expect(lineWithSecondAfter).toBeDefined(); - expect(lineWithSecondAfter).toContain('›'); - }); - - // ── Empty-list auto-refresh tests ─────────────────────────────────── - // - // These tests verify that the browse overlay works correctly when opened - // with an empty items array. The overlay should remain open showing an - // appropriate empty state (title bar + help text visible, no item lines), - // the auto-refresh interval should start, and when items become available - // the list should transition from empty to populated automatically. - // - // The fix: remove the early return in runBrowseFlow() when items.length - // === 0, and guard shortcut/key dispatch in defaultChooseWorkItem() - // against accessing items[selectedIndex] when the array is empty. - - it('handles empty items array without crashing (overlay opens)', async () => { - const { ctx, getWidget } = createMockContext(); - const emptyItems: WorklogBrowseItem[] = []; - - // Start the browse dialog with an empty array - defaultChooseWorkItem(emptyItems, ctx, vi.fn(), undefined, reFetchItems); - - // The widget should be created (overlay opens) rather than exiting early - const widget = getWidget(); - expect(widget).not.toBeNull(); - - // Render should not crash and should produce output - const lines = widget!.render(80); - expect(lines.length).toBeGreaterThan(0); - // Empty-state notice should be visible - expect(lines.join('\n')).toContain('No work items to browse'); - }); - - it('renders title and help text when items list is empty', async () => { - const { ctx, getWidget } = createMockContext(); - const emptyItems: WorklogBrowseItem[] = []; - - defaultChooseWorkItem(emptyItems, ctx, vi.fn(), undefined, reFetchItems); - const widget = getWidget()!; - - const lines = widget.render(80); - const rendered = lines.join('\n'); - // Empty-state notice should be visible, not the browse title - expect(rendered).toContain('No work items to browse'); - expect(rendered).not.toContain('Browse Worklog'); - // Empty-state placeholder should appear - expect(rendered).toContain('No items to display'); - // No item lines should appear - expect(rendered).not.toContain('WL-'); - }); - - it('auto-refresh fires when items start empty (interval is started)', async () => { - const { ctx, getWidget } = createMockContext(); - const emptyItems: WorklogBrowseItem[] = []; - - reFetchItems.mockResolvedValue([]); - - defaultChooseWorkItem(emptyItems, ctx, vi.fn(), undefined, reFetchItems); - const widget = getWidget()!; - - // Advance timers by 5 seconds - await vi.advanceTimersByTimeAsync(5000); - - // reFetchItems should have been called (interval is active) - expect(reFetchItems).toHaveBeenCalled(); - - // Render should still work after refresh, showing empty-state notice - const lines = widget.render(80); - expect(lines.join('\n')).toContain('No work items to browse'); - }); - - it('transitions from empty to populated on auto-refresh when items appear', async () => { - const { ctx, getWidget } = createMockContext(); - const emptyItems: WorklogBrowseItem[] = []; - - // First call returns empty, second call returns items - reFetchItems - .mockResolvedValueOnce([]) - .mockResolvedValue([ - { id: 'WL-010', title: 'New item 1', status: 'open' }, - { id: 'WL-011', title: 'New item 2', status: 'open' }, - ]); - - defaultChooseWorkItem(emptyItems, ctx, vi.fn(), undefined, reFetchItems); - const widget = getWidget()!; - - // First refresh: still empty, no mutation (guard: both empty → skip) - await vi.advanceTimersByTimeAsync(5000); - - let lines = widget.render(80); - let rendered = lines.join('\n'); - // Empty-state notice should be visible - expect(rendered).toContain('No work items to browse'); - // No items should appear yet - expect(rendered).not.toContain('New item'); - - // Second refresh: items become available - await vi.advanceTimersByTimeAsync(5000); - - lines = widget.render(80); - rendered = lines.join('\n'); - // Title should switch back to the browse title - expect(rendered).toContain('Browse Worklog'); - expect(rendered).not.toContain('No work items to browse'); - // Items should now appear - expect(rendered).toContain('New item 1'); - expect(rendered).toContain('New item 2'); - }); - - it('does not crash when a single-key shortcut is pressed with empty items', async () => { - const entries: ShortcutEntry[] = [ - { key: 'i', command: '/implement <id>', view: 'list' }, - ]; - const registry = new ShortcutRegistry(entries); - const { ctx, getWidget, getDone } = createMockContext(); - const emptyItems: WorklogBrowseItem[] = []; - - defaultChooseWorkItem(emptyItems, ctx, vi.fn(), registry, reFetchItems); - const widget = getWidget()!; - - // Pressing 'i' should NOT crash and should NOT dispatch a shortcut - expect(() => widget.handleInput!('i')).not.toThrow(); - - // done should NOT have been called (no shortcut dispatched) - expect(getDone()).not.toHaveBeenCalled(); - }); - - it('does not crash when a chord leader is entered with empty items', async () => { - const chordEntries: ShortcutEntry[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'list' }, - ]; - const registry = new ShortcutRegistry(chordEntries); - const { ctx, getWidget, getDone } = createMockContext(); - const emptyItems: WorklogBrowseItem[] = []; - - defaultChooseWorkItem(emptyItems, ctx, vi.fn(), registry, reFetchItems); - const widget = getWidget()!; - - // Press chord leader 'u' — should not crash - expect(() => widget.handleInput!('u')).not.toThrow(); - expect(getDone()).not.toHaveBeenCalled(); - - // Press chord completer 'p' — should not crash (no item to dispatch on) - expect(() => widget.handleInput!('p')).not.toThrow(); - expect(getDone()).not.toHaveBeenCalled(); - }); - - it('dispatches non-item single-key shortcut when items are empty', async () => { - // 'c' for create (no <id> in command) should work even when list is empty - const entries: ShortcutEntry[] = [ - { key: 'c', command: '/intake', view: 'list' }, - { key: 'i', command: '/skill:implement <id>', view: 'list' }, - ]; - const registry = new ShortcutRegistry(entries); - const { ctx, getWidget, getDone } = createMockContext(); - const emptyItems: WorklogBrowseItem[] = []; - - defaultChooseWorkItem(emptyItems, ctx, vi.fn(), registry, reFetchItems); - const widget = getWidget()!; - - // Press 'c' (create — no <id>) — should dispatch - expect(() => widget.handleInput!('c')).not.toThrow(); - expect(getDone()).toHaveBeenCalledWith({ - type: 'shortcut', - command: '/intake', - }); - }); - - it('enters chord leader pending state for non-item chords when items are empty', async () => { - // 'f' is a chord leader for filter chords (f-i, f-n, f-p, f-r) - // None of these chords contain <id>, so 'f' should enter pending state - const chordEntries: ShortcutEntry[] = [ - { chord: ['f', 'i'], command: '/wl idea', view: 'list' }, - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'list' }, - ]; - const registry = new ShortcutRegistry(chordEntries); - const { ctx, getWidget, getDone } = createMockContext(); - const emptyItems: WorklogBrowseItem[] = []; - - defaultChooseWorkItem(emptyItems, ctx, vi.fn(), registry, reFetchItems); - const widget = getWidget()!; - - // Press chord leader 'f' (filter — no <id> chords) — should enter pending - expect(() => widget.handleInput!('f')).not.toThrow(); - expect(getDone()).not.toHaveBeenCalled(); - - // Complete the chord with 'i' — should dispatch /wl idea - expect(() => widget.handleInput!('i')).not.toThrow(); - expect(getDone()).toHaveBeenCalledWith({ - type: 'shortcut', - command: '/wl idea', - }); - }); - - it('does NOT enter chord leader pending state for item-only chords when items are empty', async () => { - // 'u' is a chord leader whose ALL chords require <id> (u-p, u-s, u-t) - // When items is empty, 'u' should NOT enter pending state - const chordEntries: ShortcutEntry[] = [ - { chord: ['u', 'p'], command: 'update-priority <id>', view: 'list' }, - { chord: ['u', 's'], command: 'update-stage <id>', view: 'list' }, - ]; - const registry = new ShortcutRegistry(chordEntries); - const { ctx, getWidget, getDone } = createMockContext(); - const emptyItems: WorklogBrowseItem[] = []; - - defaultChooseWorkItem(emptyItems, ctx, vi.fn(), registry, reFetchItems); - const widget = getWidget()!; - - // Press 'u' — should NOT enter pending (all u-* chords need <id>) - // Since not a registered single-key shortcut either, it should fall - // through to the navigation handler (no-op for empty list) - expect(() => widget.handleInput!('u')).not.toThrow(); - expect(getDone()).not.toHaveBeenCalled(); - }); - - it('shows non-item shortcuts in help text when items are empty', async () => { - const entries: ShortcutEntry[] = [ - { key: 'c', command: '/intake', view: 'list', label: 'create' }, - { key: 'i', command: '/skill:implement <id>', view: 'list', label: 'implement' }, - ]; - const registry = new ShortcutRegistry(entries); - const { ctx, getWidget, getDone } = createMockContext(); - const emptyItems: WorklogBrowseItem[] = []; - - defaultChooseWorkItem(emptyItems, ctx, vi.fn(), registry, reFetchItems); - const widget = getWidget()!; - - const lines = widget.render(80); - const rendered = lines.join('\n'); - // 'c' (create — no <id>) should appear in help text - expect(rendered).toContain('c:create'); - // 'i' (implement — has <id>) should NOT appear - expect(rendered).not.toContain('i:implement'); - }); - - it('pressing Escape closes the overlay when items are empty', async () => { - const { ctx, getWidget, getDone } = createMockContext(); - const emptyItems: WorklogBrowseItem[] = []; - - defaultChooseWorkItem(emptyItems, ctx, vi.fn(), undefined, reFetchItems); - const widget = getWidget()!; - - // Press Escape — should close the overlay (done with null) - widget.handleInput!('\u001b'); - - expect(getDone()).toHaveBeenCalledWith(null); - }); - - it('pressing Enter closes the overlay when items are empty', async () => { - const { ctx, getWidget, getDone } = createMockContext(); - const emptyItems: WorklogBrowseItem[] = []; - - defaultChooseWorkItem(emptyItems, ctx, vi.fn(), undefined, reFetchItems); - const widget = getWidget()!; - - // Press Enter — should close the overlay (done with null since no item is selected) - widget.handleInput!('\r'); - - expect(getDone()).toHaveBeenCalledWith(null); - }); - - it('arrow keys do not crash when items are empty', async () => { - const { ctx, getWidget } = createMockContext(); - const emptyItems: WorklogBrowseItem[] = []; - - defaultChooseWorkItem(emptyItems, ctx, vi.fn(), undefined, reFetchItems); - const widget = getWidget()!; - - // Render before — should show empty list with empty-state notice - let lines = widget.render(80); - expect(lines.join('\n')).toContain('No work items to browse'); - - // Press Down arrow - expect(() => widget.handleInput!('\u001b[B')).not.toThrow(); - - // Press Up arrow - expect(() => widget.handleInput!('\u001b[A')).not.toThrow(); - - // Render after arrows — should still show empty list with notice - lines = widget.render(80); - expect(lines.join('\n')).toContain('No work items to browse'); - }); - - it('announceSelection is not called when items is empty', async () => { - const { ctx } = createMockContext(); - const emptyItems: WorklogBrowseItem[] = []; - const onSelectionChange = vi.fn(); - - defaultChooseWorkItem(emptyItems, ctx, onSelectionChange, undefined, reFetchItems); - - // onSelectionChange should not have been called (no item to announce) - expect(onSelectionChange).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/tui/tests/browse-detail-escape-loop.test.ts b/packages/tui/tests/browse-detail-escape-loop.test.ts deleted file mode 100644 index bb8a58105..000000000 --- a/packages/tui/tests/browse-detail-escape-loop.test.ts +++ /dev/null @@ -1,468 +0,0 @@ -/** - * Tests for the detail-view Escape → selection list loop in runBrowseFlow. - * - * Verifies that: - * - Pressing Escape in the work item detail view returns to the selection list - * - Pressing Escape at the root level of the selection list exits the browse flow - * - Shortcuts dispatched from the detail view exit the browse flow - * - The loop supports multiple detail → Escape → detail cycles - * - The list of items is re-fetched each time the loop restarts - * - * Run: npx vitest run packages/tui/tests/browse-detail-escape-loop.test.ts - */ - -import { describe, it, expect, vi } from 'vitest'; -import { runBrowseFlow, defaultChooseWorkItem, type BrowseFlowOptions, type WorklogBrowseItem, type ShortcutResult } from '../extensions/Worklog/lib/browse.js'; -import { ShortcutRegistry, type ShortcutEntry } from '../extensions/Worklog/shortcut-config.js'; - -vi.mock('@earendil-works/pi-coding-agent', () => ({ - getAgentDir: () => '/home/test-user/.pi/agent', - getSettingsListTheme: () => ({}), -})); - -describe('Browse flow detail-view Escape loop', () => { - const items: WorklogBrowseItem[] = [ - { id: 'WL-001', title: 'First item', status: 'open' }, - { id: 'WL-002', title: 'Second item', status: 'in_progress' }, - ]; - - const itemsStage: WorklogBrowseItem[] = [ - { id: 'WL-010', title: 'Stage item 1', status: 'open', stage: 'idea' }, - { id: 'WL-011', title: 'Stage item 2', status: 'in_progress', stage: 'in_progress' }, - ]; - - /** - * Create mock dependencies for runBrowseFlow with controllable resolution. - * - * @param chooseWorkItemSequence - Sequence of values returned by chooseWorkItem - * @param detailViewResult - Value returned by ctx.ui.custom (detail view result) - */ - function createFlowMocks({ - chooseWorkItemSequence = [items[0], undefined], - detailViewResult = null, - listItems = items, - } = {}) { - // Mock runWlImpl ── handles total count query and detail show - const runWlImpl = vi.fn().mockImplementation((args: string[]) => { - const argStr = args.join(' '); - if (argStr.includes('--status') && argStr.includes('open,in-progress')) { - // fetchTotalActionableCount - return Promise.resolve(JSON.stringify({ count: 10 })); - } - if (args[0] === 'show') { - return Promise.resolve('# Work Item Detail\n\nSome content here'); - } - if (argStr.includes('--json')) { - return Promise.resolve(JSON.stringify({ items: listItems })); - } - return Promise.resolve(JSON.stringify({ items: listItems })); - }); - - // Mock chooseWorkItem ── returns from the sequence - const chooseWorkItem = vi.fn(); - for (const value of chooseWorkItemSequence) { - chooseWorkItem.mockResolvedValueOnce(value); - } - - // Mock ui - const mockUi = { - custom: vi.fn().mockResolvedValue(detailViewResult), - notify: vi.fn(), - setEditorText: vi.fn(), - setWidget: vi.fn(), - }; - - const listWorkItems = vi.fn().mockResolvedValue(listItems); - const listWorkItemsWithStage = vi.fn().mockResolvedValue(listItems); - - const registry = new ShortcutRegistry([]); - - return { - ctx: { ui: mockUi }, - options: { - listWorkItems, - listWorkItemsWithStage, - runWlImpl, - shortcutRegistry: registry, - chooseWorkItem, - } as BrowseFlowOptions, - mocks: { chooseWorkItem, runWlImpl, mockUi, listWorkItems, listWorkItemsWithStage }, - }; - } - - // ── Core behavior ────────────────────────────────────────────────── - - it('returns to selection list when Escape is pressed in detail view', async () => { - const { ctx, options, mocks } = createFlowMocks({ - // First call: user selects an item → detail view shown - // Second call: user presses Escape at root of list → exit - chooseWorkItemSequence: [items[0], undefined], - detailViewResult: null, // Escape in detail view - }); - - await runBrowseFlow(ctx, options); - - // chooseWorkItem should have been called twice - expect(mocks.chooseWorkItem).toHaveBeenCalledTimes(2); - // First call with first item selected - expect(mocks.chooseWorkItem).toHaveBeenNthCalledWith(1, items, ctx, expect.any(Function)); - // Second call (after detail Escape) with refreshed items - expect(mocks.chooseWorkItem).toHaveBeenNthCalledWith(2, items, ctx, expect.any(Function)); - - // custom() should have been called once (for the detail view) - expect(mocks.mockUi.custom).toHaveBeenCalledTimes(1); - - // setWidget should have been called with undefined to clean up on exit - expect(mocks.mockUi.setWidget).toHaveBeenCalledWith('worklog-browse-selection', undefined); - - // No error notifications - expect(mocks.mockUi.notify).not.toHaveBeenCalledWith( - expect.any(String), - expect.stringContaining('error'), - ); - }); - - it('exits browse flow when Escape is pressed at root level of selection list', async () => { - const { ctx, options, mocks } = createFlowMocks({ - chooseWorkItemSequence: [undefined], // User presses Escape at root immediately - }); - - await runBrowseFlow(ctx, options); - - // chooseWorkItem should have been called once (then exited) - expect(mocks.chooseWorkItem).toHaveBeenCalledTimes(1); - - // custom should NOT have been called (no detail view shown) - expect(mocks.mockUi.custom).not.toHaveBeenCalled(); - - // Cleanup should have happened - expect(mocks.mockUi.setWidget).toHaveBeenCalledWith('worklog-browse-selection', undefined); - }); - - it('dispatches shortcuts from the detail view and exits', async () => { - const { ctx, options, mocks } = createFlowMocks({ - chooseWorkItemSequence: [items[0]], // User selects an item - detailViewResult: { type: 'shortcut', command: '/implement WL-001' } as ShortcutResult, // Shortcut from detail - }); - - await runBrowseFlow(ctx, options); - - // chooseWorkItem should have been called once (no loop back after shortcut) - expect(mocks.chooseWorkItem).toHaveBeenCalledTimes(1); - - // custom should have been called (detail view) - expect(mocks.mockUi.custom).toHaveBeenCalledTimes(1); - - // setEditorText should have been called with the shortcut command - expect(mocks.mockUi.setEditorText).toHaveBeenCalledWith('/implement WL-001'); - - // Cleanup should have happened - expect(mocks.mockUi.setWidget).toHaveBeenCalledWith('worklog-browse-selection', undefined); - }); - - it('supports multiple detail → Escape → detail cycles', async () => { - const { ctx, options, mocks } = createFlowMocks({ - // Three iterations: select item → enter detail → Escape → re-select → Enter detail → Escape → Escape at root - chooseWorkItemSequence: [items[0], items[1], undefined], - detailViewResult: null, // Escape in detail view each time - }); - - await runBrowseFlow(ctx, options); - - // chooseWorkItem should have been called 3 times - expect(mocks.chooseWorkItem).toHaveBeenCalledTimes(3); - - // custom should have been called 2 times (detail view each time) - expect(mocks.mockUi.custom).toHaveBeenCalledTimes(2); - - // Cleanup on exit - expect(mocks.mockUi.setWidget).toHaveBeenCalledWith('worklog-browse-selection', undefined); - }); - - it('re-fetches items each time the loop restarts', async () => { - const { ctx, options, mocks } = createFlowMocks({ - chooseWorkItemSequence: [items[0], undefined], - detailViewResult: null, // Escape in detail view - }); - - await runBrowseFlow(ctx, options); - - // listWorkItems should have been called twice (once per loop iteration) - expect(mocks.listWorkItems).toHaveBeenCalledTimes(2); - }); - - // ─── Stage-filtered flow ────────────────────────────────────────── - - it('works correctly with stage-filtered browsing', async () => { - const stage = 'idea'; - const { ctx, options, mocks } = createFlowMocks({ - chooseWorkItemSequence: [itemsStage[0], undefined], - detailViewResult: null, // Escape in detail view - listItems: itemsStage, - }); - - await runBrowseFlow(ctx, options, stage); - - // listWorkItemsWithStage should have been called with the stage - expect(mocks.listWorkItemsWithStage).toHaveBeenCalledWith(stage); - - // chooseWorkItem should have been called twice - expect(mocks.chooseWorkItem).toHaveBeenCalledTimes(2); - - // Cleanup on exit - expect(mocks.mockUi.setWidget).toHaveBeenCalledWith('worklog-browse-selection', undefined); - }); - - it('stage-filtered flow fetches fresh items each loop iteration', async () => { - const stage = 'in_progress'; - const { ctx, options, mocks } = createFlowMocks({ - chooseWorkItemSequence: [itemsStage[0], undefined], - detailViewResult: null, - listItems: itemsStage, - }); - - await runBrowseFlow(ctx, options, stage); - - // listWorkItemsWithStage should have been called twice (once per loop) - expect(mocks.listWorkItemsWithStage).toHaveBeenCalledTimes(2); - expect(mocks.listWorkItemsWithStage).toHaveBeenCalledWith(stage); - }); - - // ── Shortcuts from selection list ────────────────────────────────── - - it('still dispatches shortcuts from the selection list', async () => { - const { ctx, options, mocks } = createFlowMocks({ - chooseWorkItemSequence: [{ type: 'shortcut', command: '/intake' } as ShortcutResult], - }); - - await runBrowseFlow(ctx, options); - - // chooseWorkItem should have been called once - expect(mocks.chooseWorkItem).toHaveBeenCalledTimes(1); - - // setEditorText should have been called with the shortcut - expect(mocks.mockUi.setEditorText).toHaveBeenCalledWith('/intake'); - - // Cleanup - expect(mocks.mockUi.setWidget).toHaveBeenCalledWith('worklog-browse-selection', undefined); - - // No detail view shown - expect(mocks.mockUi.custom).not.toHaveBeenCalled(); - }); - - // ── Detail view error handling ──────────────────────────────────── - - it('handles detail view rendering errors gracefully and continues loop', async () => { - const { ctx, options, mocks } = createFlowMocks({ - chooseWorkItemSequence: [items[0], undefined], - }); - - // Make runWlImpl throw on 'show' command - mocks.runWlImpl.mockImplementation((args: string[]) => { - const argStr = args.join(' '); - if (argStr.includes('--status') && argStr.includes('open,in-progress')) { - return Promise.resolve(JSON.stringify({ count: 10 })); - } - if (args[0] === 'show') { - return Promise.reject(new Error('Detail fetch failed')); - } - return Promise.resolve(JSON.stringify({ items })); - }); - - await runBrowseFlow(ctx, options); - - // Error notification should be shown - expect(mocks.mockUi.notify).toHaveBeenCalledWith( - expect.stringContaining('Detail fetch failed'), - 'error', - ); - - // Flow should continue: chooseWorkItem called again (loop restarts) - expect(mocks.chooseWorkItem).toHaveBeenCalledTimes(2); - - // Cleanup on exit - expect(mocks.mockUi.setWidget).toHaveBeenCalledWith('worklog-browse-selection', undefined); - }); - - // ── Empty items ─────────────────────────────────────────────────── - - it('handles empty items list after returning from detail view', async () => { - const { ctx, options, mocks } = createFlowMocks({ - // First iteration: has items, user selects one, enters detail, presses Escape - // Second iteration: listWorkItems returns empty, so nothing to select → exit - chooseWorkItemSequence: [items[0]], - detailViewResult: null, - }); - - // Override listWorkItems to return empty on second call - mocks.listWorkItems - .mockResolvedValueOnce(items) // First iteration: has items - .mockResolvedValueOnce([]); // Second iteration: empty - - await runBrowseFlow(ctx, options); - - // chooseWorkItem should have been called only once (second fetch returned empty, so nothing to choose) - // Let me think... when items is empty, announceSelection(items[0]) won't be called - // Then defaultChooseWorkItem or chooseWorkItem... - // Actually, looking at the code, when items.length === 0, announceSelection is not called - // (if (items[0]) { announceSelection(items[0]) }) - // Then chooseWorkItem is still called... but with empty items array - - // Let me check what happens. The chooseWorkItemSequence has items[0] for the first iteration. - // But wait, the loop changes: after Escape in detail, the loop restarts and re-fetches items. - // With empty items, chooseWorkItem is called with an empty array. - // chooseWorkItem would return... well in our mock, we only have one value in chooseWorkItemSequence. - // Actually, when chooseWorkItem runs out of values, vi.fn() returns undefined. - // So it would return undefined → exit. - - // At minimum, we should verify no crash and cleanup - expect(mocks.mockUi.setWidget).toHaveBeenCalledWith('worklog-browse-selection', undefined); - }); - - // ── Selection list Escape from non-root level ────────────────────── - - it('preserves existing Escape-in-hierarchy behavior in selection list', async () => { - // This tests that the hierarchy navigation inside defaultChooseWorkItem - // is unaffected — already covered by browse-hierarchical-navigation.test.ts. - // Here we verify that the loop doesn't interfere with it. - - const { ctx, options, mocks } = createFlowMocks({ - chooseWorkItemSequence: [items[0], undefined], - detailViewResult: null, - }); - - await runBrowseFlow(ctx, options); - - // The chooseWorkItem handles hierarchy internally; we just verify the - // loop-around doesn't break it. No crash means hierarchy works. - expect(mocks.chooseWorkItem).toHaveBeenCalledTimes(2); - expect(mocks.mockUi.setWidget).toHaveBeenCalledWith('worklog-browse-selection', undefined); - }); - - // ── Selection state preservation (hierarchy restoration) ────────── - - it('saves selection state with hierarchy context when item is selected', async () => { - let widgetHandleInput: ((data: string) => void) | null = null; - - const mockUi = { - custom: vi.fn((factory: any) => { - return new Promise((resolve) => { - const tui = { requestRender: vi.fn() }; - const theme = { - fg: vi.fn((_c: string, t: string) => t), - bold: vi.fn((t: string) => t), - }; - const done = (value: any) => { resolve(value); }; - const widget = factory(tui, theme, undefined, done); - widgetHandleInput = widget.handleInput ?? null; - }); - }), - notify: vi.fn(), - setEditorText: vi.fn(), - setWidget: vi.fn(), - }; - - const selectionState = { - currentItems: [], - selectedIndex: 0, - lastSelectionId: undefined as string | undefined, - navStack: [] as Array<{ items: any[]; selectedIndex: number; lastSelectionId: string | undefined }>, - }; - - const childItems = [ - { id: 'WL-010', title: 'Child item', status: 'open' as const }, - ]; - - const promise = defaultChooseWorkItem( - childItems, - { ui: mockUi }, - vi.fn(), - undefined, - undefined, - undefined, - undefined, - selectionState, - ); - - // Simulate pressing Enter on the selected item (index 0) - expect(widgetHandleInput).not.toBeNull(); - widgetHandleInput!('\r'); - - const result = await promise; - - // Selection state should now be populated - expect(selectionState.currentItems).toEqual(childItems); - expect(selectionState.selectedIndex).toBe(0); - expect(selectionState.lastSelectionId).toBe('WL-010'); - expect(result).toEqual(childItems[0]); - }); - - it('restores selection state with navStack when re-entering', async () => { - let widgetHandleInput: ((data: string) => void) | null = null; - - const mockUi = { - custom: vi.fn((factory: any) => { - return new Promise((resolve) => { - const tui = { requestRender: vi.fn() }; - const theme = { - fg: vi.fn((_c: string, t: string) => t), - bold: vi.fn((t: string) => t), - }; - const done = (value: any) => { resolve(value); }; - const widget = factory(tui, theme, undefined, done); - widgetHandleInput = widget.handleInput ?? null; - }); - }), - notify: vi.fn(), - setEditorText: vi.fn(), - setWidget: vi.fn(), - }; - - const parentItems = [ - { id: 'WL-001', title: 'Parent', status: 'open' as const, childCount: 2 }, - ]; - const childItems = [ - { id: '..', title: '..', status: 'open' as const }, - { id: 'WL-010', title: 'Child one', status: 'open' as const }, - { id: 'WL-011', title: 'Child two', status: 'in_progress' as const }, - ]; - - const selectionState = { - currentItems: [...childItems], - selectedIndex: 1, - lastSelectionId: 'WL-010', - navStack: [ - { - items: [...parentItems], - selectedIndex: 0, - lastSelectionId: 'WL-001', - }, - ], - }; - - const promise = defaultChooseWorkItem( - childItems, - { ui: mockUi }, - vi.fn(), - undefined, - undefined, - undefined, - undefined, - selectionState, - ); - - // The state should have been consumed (currentItems cleared) - expect(selectionState.currentItems).toEqual([]); - - // Simulate pressing Enter on the selected item (index 1, 'Child one') - expect(widgetHandleInput).not.toBeNull(); - widgetHandleInput!('\r'); - - const result = await promise; - - // After _done, selection state should be re-populated - expect(selectionState.currentItems).toEqual(childItems); - expect(selectionState.navStack.length).toBe(1); - expect(result).toEqual(childItems[1]); - }); -}); diff --git a/packages/tui/tests/browse-hierarchical-navigation.test.ts b/packages/tui/tests/browse-hierarchical-navigation.test.ts deleted file mode 100644 index a18f83c68..000000000 --- a/packages/tui/tests/browse-hierarchical-navigation.test.ts +++ /dev/null @@ -1,928 +0,0 @@ -vi.mock('@earendil-works/pi-coding-agent', () => ({ - getAgentDir: () => '/home/test-user/.pi/agent', -})); - -vi.mock('node:fs', () => ({ - readFileSync: vi.fn((path) => { - if (String(path).endsWith('shortcuts.json')) { - return JSON.stringify([ - { key: 'n', command: '/intake <id>', view: 'both', stages: ['idea'] }, - { key: 'p', command: '/plan <id>', view: 'both', stages: ['intake_complete'] }, - { key: 'i', command: '/skill:implement <id>', view: 'both', stages: ['intake_complete', 'plan_complete', 'in_progress'] }, - { key: 'a', command: '/skill:audit <id>', view: 'both', stages: ['in_progress', 'in_review'] }, - ]); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }), - writeFileSync: vi.fn(), - mkdirSync: vi.fn(), - realpathSync: vi.fn((p) => p), - existsSync: vi.fn(() => false), -})); - -/** - * Tests for hierarchical navigation in the browse selection list. - * - * Verifies that: - * - Items with children show child count indicator regardless of issue type - * - Enter on item with children opens the detail view (calls done) - * - Tab on item with children fetches and displays children - * - ".." entry is shown at the top of child lists - * - Enter on ".." navigates back to the parent level - * - Escape navigates back one level when viewing children - * - Escape closes the overlay at root level - * - Arbitrary depth navigation works (children of children) - * - Selection position is restored when navigating back - * - Enter on item without children opens detail view at root level - * - * Run: npx vitest run packages/tui/tests/browse-hierarchical-navigation.test.ts - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { defaultChooseWorkItem, getIconPrefix, type WorklogBrowseItem } from '../extensions/Worklog/index.js'; - -// ─── getIconPrefix tests ───────────────────────────────────────────── - -describe('getIconPrefix - child count indicator', () => { - it('shows child count for epic items with children (existing behavior)', () => { - const item: WorklogBrowseItem = { - id: 'WL-001', - title: 'Epic item', - status: 'open', - issueType: 'epic', - childCount: 3, - }; - const prefix = getIconPrefix(item, false); - expect(prefix).toContain('(3)'); - }); - - it('shows child count for feature items with children', () => { - const item: WorklogBrowseItem = { - id: 'WL-002', - title: 'Feature with children', - status: 'open', - issueType: 'feature', - childCount: 2, - }; - const prefix = getIconPrefix(item, false); - expect(prefix).toContain('(2)'); - }); - - it('shows child count for task items with children', () => { - const item: WorklogBrowseItem = { - id: 'WL-003', - title: 'Task with children', - status: 'open', - issueType: 'task', - childCount: 1, - }; - const prefix = getIconPrefix(item, false); - expect(prefix).toContain('(1)'); - }); - - it('shows child count for bug items with children', () => { - const item: WorklogBrowseItem = { - id: 'WL-004', - title: 'Bug with children', - status: 'open', - issueType: 'bug', - childCount: 5, - }; - const prefix = getIconPrefix(item, false); - expect(prefix).toContain('(5)'); - }); - - it('does NOT show child count for items with no children (childCount 0)', () => { - const item: WorklogBrowseItem = { - id: 'WL-005', - title: 'No children', - status: 'open', - issueType: 'feature', - childCount: 0, - }; - const prefix = getIconPrefix(item, false); - expect(prefix).not.toMatch(/\(\d+\)/); - }); - - it('does NOT show child count for items with undefined childCount', () => { - const item: WorklogBrowseItem = { - id: 'WL-006', - title: 'No children', - status: 'open', - issueType: 'task', - }; - const prefix = getIconPrefix(item, false); - expect(prefix).not.toMatch(/\(\d+\)/); - }); - - it('shows child count for all items with children regardless of issueType', () => { - const epicItem: WorklogBrowseItem = { - id: 'WL-010', title: 'Epic', status: 'open', - issueType: 'epic', childCount: 3, - }; - const featureItem: WorklogBrowseItem = { - id: 'WL-011', title: 'Feature', status: 'open', - issueType: 'feature', childCount: 2, - }; - const taskItem: WorklogBrowseItem = { - id: 'WL-012', title: 'Task', status: 'open', - issueType: 'task', childCount: 4, - }; - const bugItem: WorklogBrowseItem = { - id: 'WL-013', title: 'Bug', status: 'open', - issueType: 'bug', childCount: 1, - }; - - expect(getIconPrefix(epicItem, false)).toContain('(3)'); - expect(getIconPrefix(featureItem, false)).toContain('(2)'); - expect(getIconPrefix(taskItem, false)).toContain('(4)'); - expect(getIconPrefix(bugItem, false)).toContain('(1)'); - }); - - it('shows child count even when icons are disabled (noIcons=true)', () => { - const item: WorklogBrowseItem = { - id: 'WL-020', title: 'Has children', status: 'open', - issueType: 'feature', childCount: 3, - }; - const prefix = getIconPrefix(item, true); - // With noIcons, epic icon may be empty, but child count should still show - expect(prefix).toContain('(3)'); - }); -}); - -// ─── Hierarchical navigation tests ────────────────────────────────── - -describe('Hierarchical navigation in defaultChooseWorkItem', () => { - let rootItems: WorklogBrowseItem[]; - let childItems: WorklogBrowseItem[]; - let grandchildItems: WorklogBrowseItem[]; - - beforeEach(() => { - vi.useFakeTimers(); - rootItems = [ - { id: 'WL-001', title: 'Parent item', status: 'open', childCount: 2 }, - { id: 'WL-002', title: 'Standalone item', status: 'open' }, - ]; - childItems = [ - { id: 'WL-003', title: 'First child', status: 'open', childCount: 1 }, - { id: 'WL-004', title: 'Second child', status: 'open' }, - ]; - grandchildItems = [ - { id: 'WL-005', title: 'Grandchild', status: 'open' }, - ]; - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - /** - * Create a mock BrowseContext that captures the widget factory output. - * Pattern adapted from browse-auto-refresh.test.ts. - */ - function createMockContext() { - let capturedWidget: { - render: (width: number) => string[]; - invalidate: () => void; - handleInput?: (data: string) => void; - } | null = null; - let capturedTui: { requestRender: ReturnType<typeof vi.fn> } | null = null; - let capturedDone: ReturnType<typeof vi.fn> | null = null; - - const mockUi = { - custom: vi.fn(<T>( - factory: ( - tui: any, - theme: any, - _keybindings: unknown, - done: (value: T) => void, - ) => { - render: (width: number) => string[]; - invalidate: () => void; - handleInput?: (data: string) => void; - }, - ) => { - const tui = { requestRender: vi.fn() }; - const theme = { - fg: vi.fn((_color: string, text: string) => text), - bold: vi.fn((text: string) => text), - }; - const done = vi.fn(); - capturedWidget = factory(tui, theme, undefined, done); - capturedTui = tui; - capturedDone = done; - return new Promise<T>(() => { /* never resolves */ }); - }), - notify: vi.fn(), - setEditorText: vi.fn(), - setWidget: vi.fn(), - select: vi.fn(), - }; - - return { - ctx: { ui: mockUi }, - getWidget: () => capturedWidget, - getTui: () => capturedTui, - getDone: () => capturedDone, - }; - } - - /** - * Helper: check if a rendered line has the selection marker (›) for the - * item at the given index. - */ - function getSelectionMarker(lines: string[], itemTitle: string): string | undefined { - return lines.find(l => l.includes(itemTitle)); - } - - it('calls done with the selected item when Enter is pressed on an item without children (root level)', () => { - const { ctx, getWidget, getDone } = createMockContext(); - - defaultChooseWorkItem(rootItems, ctx, vi.fn()); - const widget = getWidget()!; - const done = getDone()!; - - // Navigate down to standalone item (index 1, no childCount) - widget.handleInput!('\u001b[B'); - // Press Enter - widget.handleInput!('\r'); - - expect(done).toHaveBeenCalledWith(rootItems[1]); - }); - - it('calls done when Enter is pressed on an item with children (opens detail view)', () => { - const { ctx, getWidget, getDone } = createMockContext(); - - // Provide a fetchChildren mock - const fetchChildren = vi.fn().mockResolvedValue(childItems); - defaultChooseWorkItem(rootItems, ctx, vi.fn(), undefined, undefined, fetchChildren); - const widget = getWidget()!; - const done = getDone()!; - - // Press Enter on parent item (index 0, childCount=2) - widget.handleInput!('\r'); - - // done SHOULD have been called (Enter now shows detail view for parents too) - expect(done).toHaveBeenCalledWith(rootItems[0]); - // fetchChildren should NOT have been called (Enter does not navigate into children anymore) - expect(fetchChildren).not.toHaveBeenCalled(); - }); - - it('navigates into children when Tab is pressed on a parent item', async () => { - const { ctx, getWidget, getDone } = createMockContext(); - - const fetchChildren = vi.fn().mockResolvedValue(childItems); - defaultChooseWorkItem(rootItems, ctx, vi.fn(), undefined, undefined, fetchChildren); - const widget = getWidget()!; - const done = getDone()!; - - // Press Tab on parent item (index 0, childCount=2) - // Tab key sends \t - widget.handleInput!('\t'); - - // done should NOT have been called (Tab navigates into children) - expect(done).not.toHaveBeenCalled(); - // fetchChildren should have been called with the parent ID - expect(fetchChildren).toHaveBeenCalledWith('WL-001'); - }); - - it('navigates into children when Tab is pressed on a parent item', async () => { - const { ctx, getWidget, getDone } = createMockContext(); - - const fetchChildren = vi.fn().mockResolvedValue(childItems); - defaultChooseWorkItem(rootItems, ctx, vi.fn(), undefined, undefined, fetchChildren); - const widget = getWidget()!; - const done = getDone()!; - - // Press Tab on parent item (index 0, childCount=2) - widget.handleInput!('\t'); - - // done should NOT have been called (Tab navigates into children) - expect(done).not.toHaveBeenCalled(); - // fetchChildren should have been called with the parent ID - expect(fetchChildren).toHaveBeenCalledWith('WL-001'); - }); - - it('renders child items and a ".." entry after Tab on parent', async () => { - const { ctx, getWidget } = createMockContext(); - - const fetchChildren = vi.fn().mockResolvedValue(childItems); - defaultChooseWorkItem(rootItems, ctx, vi.fn(), undefined, undefined, fetchChildren); - const widget = getWidget()!; - - // Initial render should show root items - let lines = widget.render(80); - expect(lines.join('\n')).toContain('Parent item'); - - // Press Tab on parent item - widget.handleInput!('\t'); - - // After Tab, children should be fetched and rendered - await vi.advanceTimersByTimeAsync(10); - - lines = widget.render(80); - const rendered = lines.join('\n'); - - // Should contain the ".." entry - expect(rendered).toContain('..'); - // Should contain child items - expect(rendered).toContain('First child'); - expect(rendered).toContain('Second child'); - }); - - it('renders child items and a ".." entry after Tab on parent', async () => { - const { ctx, getWidget } = createMockContext(); - - const fetchChildren = vi.fn().mockResolvedValue(childItems); - defaultChooseWorkItem(rootItems, ctx, vi.fn(), undefined, undefined, fetchChildren); - const widget = getWidget()!; - - // Initial render should show root items - let lines = widget.render(80); - expect(lines.join('\n')).toContain('Parent item'); - expect(lines.join('\n')).toContain('Standalone item'); - - // Press Tab on parent item (index 0, has 2 children) - widget.handleInput!('\t'); - - // After Tab, children should be fetched and rendered - await vi.advanceTimersByTimeAsync(10); // Let the promise resolve - - lines = widget.render(80); - const rendered = lines.join('\n'); - - // Should contain the ".." entry - expect(rendered).toContain('..'); - // Should contain child items - expect(rendered).toContain('First child'); - expect(rendered).toContain('Second child'); - // Should NOT contain parent root items anymore - expect(rendered).not.toContain('Parent item'); - expect(rendered).not.toContain('Standalone item'); - }); - - it('navigates into children when Tab is pressed on a parent item', async () => { - const { ctx, getWidget, getDone } = createMockContext(); - - const fetchChildren = vi.fn().mockResolvedValue(childItems); - defaultChooseWorkItem(rootItems, ctx, vi.fn(), undefined, undefined, fetchChildren); - const widget = getWidget()!; - const done = getDone()!; - - // Press Tab on parent item (index 0, childCount=2) - // Tab key sends \t - widget.handleInput!('\t'); - - // done should NOT have been called (Tab navigates into children) - expect(done).not.toHaveBeenCalled(); - // fetchChildren should have been called with the parent ID - expect(fetchChildren).toHaveBeenCalledWith('WL-001'); - }); - - it('renders child items and a ".." entry after Tab on parent', async () => { - const { ctx, getWidget, getDone } = createMockContext(); - - const fetchChildren = vi.fn().mockResolvedValue(childItems); - defaultChooseWorkItem(rootItems, ctx, vi.fn(), undefined, undefined, fetchChildren); - const widget = getWidget()!; - - // Initial render should show root items - let lines = widget.render(80); - expect(lines.join('\n')).toContain('Parent item'); - expect(lines.join('\n')).toContain('Standalone item'); - - // Press Tab on parent item (index 0, has 2 children) - widget.handleInput!('\t'); - - // After Tab, children should be fetched and rendered - await vi.advanceTimersByTimeAsync(10); // Let the promise resolve - - lines = widget.render(80); - const rendered = lines.join('\n'); - - // Should contain the ".." entry - expect(rendered).toContain('..'); - // Should contain child items - expect(rendered).toContain('First child'); - expect(rendered).toContain('Second child'); - // Should NOT contain parent root items anymore - expect(rendered).not.toContain('Parent item'); - expect(rendered).not.toContain('Standalone item'); - }); - - it('navigates back to parent level when Enter is pressed on ".." entry', async () => { - const { ctx, getWidget, getDone } = createMockContext(); - - const fetchChildren = vi.fn().mockResolvedValue(childItems); - defaultChooseWorkItem(rootItems, ctx, vi.fn(), undefined, undefined, fetchChildren); - const widget = getWidget()!; - - // Navigate into parent.s children using Tab - widget.handleInput!('\t'); - await vi.advanceTimersByTimeAsync(10); - - // Now we should be viewing children. Press Enter on ".." (index 0) - widget.handleInput!('\r'); - - // Should be back at root level with root items - const lines = widget.render(80); - const rendered = lines.join('\n'); - expect(rendered).toContain('Parent item'); - expect(rendered).toContain('Standalone item'); - expect(rendered).not.toContain('First child'); - }); - - it('navigates back to parent level when Escape is pressed (while viewing children)', async () => { - const { ctx, getWidget, getDone } = createMockContext(); - - const fetchChildren = vi.fn().mockResolvedValue(childItems); - defaultChooseWorkItem(rootItems, ctx, vi.fn(), undefined, undefined, fetchChildren); - const widget = getWidget()!; - - // Navigate into children using Tab - widget.handleInput!('\t'); - await vi.advanceTimersByTimeAsync(10); - - // Verify we're in children view - let lines = widget.render(80); - expect(lines.join('\n')).toContain('First child'); - - // Press Escape to go back - widget.handleInput!('\u001b'); - - // Should be back at root - lines = widget.render(80); - const rendered = lines.join('\n'); - expect(rendered).toContain('Parent item'); - expect(rendered).toContain('Standalone item'); - expect(rendered).not.toContain('First child'); - }); - - it('closes the overlay when Escape is pressed at root level', () => { - const { ctx, getWidget, getDone } = createMockContext(); - - defaultChooseWorkItem(rootItems, ctx, vi.fn()); - const widget = getWidget()!; - const done = getDone()!; - - // Press Escape at root level (navigation stack empty) - widget.handleInput!('\u001b'); - - expect(done).toHaveBeenCalledWith(null); - }); - - it('supports arbitrary depth navigation with Tab', async () => { - const { ctx, getWidget, getDone } = createMockContext(); - - // First level: children have child items - const deepChildItems: WorklogBrowseItem[] = [ - { id: 'WL-003', title: 'First child', status: 'open', childCount: 1 }, - { id: 'WL-004', title: 'Second child', status: 'open' }, - ]; - - const fetchChildren = vi.fn((id: string) => { - if (id === 'WL-001') return Promise.resolve(deepChildItems); - if (id === 'WL-003') return Promise.resolve(grandchildItems); - return Promise.resolve([]); - }); - - defaultChooseWorkItem(rootItems, ctx, vi.fn(), undefined, undefined, fetchChildren); - const widget = getWidget()!; - - // Navigate into WL-001.s children using Tab - widget.handleInput!('\t'); - await vi.advanceTimersByTimeAsync(10); - - // Navigate down to "First child" (WL-003, has childCount=1) - widget.handleInput!('\u001b[B'); // move to child item (index 1, after "..") - widget.handleInput!('\t'); // Tab on First child - await vi.advanceTimersByTimeAsync(10); - - // Should now be viewing grandchildren - let lines = widget.render(80); - expect(lines.join('\n')).toContain('Grandchild'); - - // Press Escape to go back - widget.handleInput!('\u001b'); - lines = widget.render(80); - expect(lines.join('\n')).toContain('First child'); - expect(lines.join('\n')).toContain('Second child'); - - // Press Escape again to go to root - widget.handleInput!('\u001b'); - lines = widget.render(80); - expect(lines.join('\n')).toContain('Parent item'); - expect(lines.join('\n')).toContain('Standalone item'); - }); - - it('supports arbitrary depth navigation (children of children)', async () => { - const { ctx, getWidget, getDone } = createMockContext(); - - // First level: children have child items - const deepChildItems: WorklogBrowseItem[] = [ - { id: 'WL-003', title: 'First child', status: 'open', childCount: 1 }, - { id: 'WL-004', title: 'Second child', status: 'open' }, - ]; - - const fetchChildren = vi.fn((id: string) => { - if (id === 'WL-001') return Promise.resolve(deepChildItems); - if (id === 'WL-003') return Promise.resolve(grandchildItems); - return Promise.resolve([]); - }); - - defaultChooseWorkItem(rootItems, ctx, vi.fn(), undefined, undefined, fetchChildren); - const widget = getWidget()!; - - // Navigate into WL-001.s children using Tab - widget.handleInput!('\t'); - await vi.advanceTimersByTimeAsync(10); - - // Navigate down to "First child" (WL-003, has childCount=1) - widget.handleInput!('\u001b[B'); // move to child item (index 1, after "..") - widget.handleInput!('\t'); // Tab on First child - await vi.advanceTimersByTimeAsync(10); - - // Should now be viewing grandchildren - let lines = widget.render(80); - expect(lines.join('\n')).toContain('Grandchild'); - - // Press Escape to go back - widget.handleInput!('\u001b'); - lines = widget.render(80); - expect(lines.join('\n')).toContain('First child'); - expect(lines.join('\n')).toContain('Second child'); - - // Press Escape again to go to root - widget.handleInput!('\u001b'); - lines = widget.render(80); - expect(lines.join('\n')).toContain('Parent item'); - expect(lines.join('\n')).toContain('Standalone item'); - }); - - it('restores selection position when navigating back', async () => { - const { ctx, getWidget, getDone } = createMockContext(); - - const fetchChildren = vi.fn().mockResolvedValue(childItems); - defaultChooseWorkItem(rootItems, ctx, vi.fn(), undefined, undefined, fetchChildren); - const widget = getWidget()!; - - // Navigate down to "Standalone item" (index 1) — verification step - widget.handleInput!('\u001b[B'); - let lines = widget.render(80); - expect(getSelectionMarker(lines, 'Standalone item')).toContain('›'); - - // Navigate UP back to parent (index 0) and press Tab to see children - widget.handleInput!('\u001b[A'); - widget.handleInput!('\t'); - await vi.advanceTimersByTimeAsync(10); - - // Verify we're viewing children now - let childLines = widget.render(80); - expect(childLines.join('\n')).toContain('First child'); - - // Navigate back via Escape - widget.handleInput!('\u001b'); - lines = widget.render(80); - - // Should be back at root level showing root items - const rendered = lines.join('\n'); - expect(rendered).toContain('Parent item'); - expect(rendered).toContain('Standalone item'); - expect(rendered).not.toContain('First child'); - - // Selection should be restored to the item that was selected when Tab - // was pressed to navigate into children — that is "Parent item" (index 0) - expect(getSelectionMarker(lines, 'Parent item')).toContain('›'); - }); - - it('treats items without childCount as not having children', () => { - const { ctx, getWidget, getDone } = createMockContext(); - - const items = [ - { id: 'WL-001', title: 'No childCount field', status: 'open' }, - { id: 'WL-002', title: 'Second', status: 'open' }, - ]; - - const fetchChildren = vi.fn(); - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, undefined, fetchChildren); - const widget = getWidget()!; - const done = getDone()!; - - // Press Enter on first item (no childCount defined) - widget.handleInput!('\r'); - - // Should call done (no children to navigate to) - expect(done).toHaveBeenCalledWith(items[0]); - expect(fetchChildren).not.toHaveBeenCalled(); - }); - - it('does not render ".." entry at root level', () => { - const { ctx, getWidget } = createMockContext(); - - defaultChooseWorkItem(rootItems, ctx, vi.fn()); - const widget = getWidget()!; - - const lines = widget.render(80); - const rendered = lines.join('\n'); - // The ".." should not appear at root level - expect(rendered).not.toContain('..'); - }); - - it('preserves shortcut dispatch when viewing children', async () => { - // Import ShortcutRegistry for testing - const { ShortcutRegistry } = await import('../extensions/Worklog/shortcut-config.js'); - const entries = [ - { key: 'i', command: '/implement <id>', view: 'list' }, - ]; - const registry = new ShortcutRegistry(entries); - - const { ctx, getWidget, getDone } = createMockContext(); - - const fetchChildren = vi.fn().mockResolvedValue(childItems); - defaultChooseWorkItem(rootItems, ctx, vi.fn(), registry, undefined, fetchChildren); - const widget = getWidget()!; - const done = getDone()!; - - // Navigate into children using Tab - widget.handleInput!('\t'); - await vi.advanceTimersByTimeAsync(10); - - // Press shortcut key 'i' while viewing children - widget.handleInput!('i'); - - // Should dispatch the shortcut with the correct child item ID - expect(done).toHaveBeenCalledWith( - expect.objectContaining({ type: 'shortcut' as const }) - ); - }); - - it('preserves shortcut dispatch when viewing children (via Tab navigation)', async () => { - // Import ShortcutRegistry for testing - const { ShortcutRegistry } = await import('../extensions/Worklog/shortcut-config.js'); - const entries = [ - { key: 'i', command: '/implement <id>', view: 'list' }, - ]; - const registry = new ShortcutRegistry(entries); - - const { ctx, getWidget, getDone } = createMockContext(); - - const fetchChildren = vi.fn().mockResolvedValue(childItems); - defaultChooseWorkItem(rootItems, ctx, vi.fn(), registry, undefined, fetchChildren); - const widget = getWidget()!; - const done = getDone()!; - - // Navigate into children using Tab - widget.handleInput!('\t'); - await vi.advanceTimersByTimeAsync(10); - - // Press shortcut key 'i' while viewing children - widget.handleInput!('i'); - - // Should dispatch the shortcut with the correct child item ID - expect(done).toHaveBeenCalledWith( - expect.objectContaining({ type: 'shortcut' as const }) - ); - }); - - it('handles fetchChildren errors gracefully without crashing', async () => { - const { ctx, getWidget, getDone } = createMockContext(); - - const fetchChildren = vi.fn().mockRejectedValue(new Error('Fetch failed')); - defaultChooseWorkItem(rootItems, ctx, vi.fn(), undefined, undefined, fetchChildren); - const widget = getWidget()!; - - // Press Tab on parent item - widget.handleInput!('\t'); - await vi.advanceTimersByTimeAsync(10); - - // Press Tab on parent item - widget.handleInput!('\t'); - await vi.advanceTimersByTimeAsync(10); - - // Should not crash - should remain at root level - const lines = widget.render(80); - const rendered = lines.join('\n'); - expect(rendered).toContain('Parent item'); - expect(rendered).toContain('Standalone item'); - }); - - it('uses childCount of synthetic ".." entry as undefined (not a real work item)', async () => { - const { ctx, getWidget } = createMockContext(); - - const fetchChildren = vi.fn().mockResolvedValue(childItems); - defaultChooseWorkItem(rootItems, ctx, vi.fn(), undefined, undefined, fetchChildren); - const widget = getWidget()!; - - // Navigate into children using Tab - widget.handleInput!('\t'); - await vi.advanceTimersByTimeAsync(10); - - const lines = widget.render(80); - const rendered = lines.join('\n'); - // Should have ".." at the top (before "First child") - const parentIdx = rendered.indexOf('..'); - const firstChildIdx = rendered.indexOf('First child'); - expect(parentIdx).toBeGreaterThanOrEqual(0); - expect(firstChildIdx).toBeGreaterThan(parentIdx); - }); - - // ─── Wrap-around navigation tests ──────────────────────────────── - - it('wraps from first item to last item when Up arrow is pressed at index 0', () => { - const { ctx, getWidget } = createMockContext(); - - // Use 3 items to clearly demonstrate wrap-around - const threeItems: WorklogBrowseItem[] = [ - { id: 'WL-001', title: 'First item', status: 'open' }, - { id: 'WL-002', title: 'Middle item', status: 'open' }, - { id: 'WL-003', title: 'Last item', status: 'open' }, - ]; - - defaultChooseWorkItem(threeItems, ctx, vi.fn()); - const widget = getWidget()!; - - // Initial selection is at index 0 (First item) - let lines = widget.render(80); - const firstLine = lines.find(l => l.includes('First item')); - expect(firstLine).toBeDefined(); - expect(firstLine).toContain('\u203A'); // selected marker - - // Press Up arrow — should wrap to last item - widget.handleInput!('\u001b[A'); - lines = widget.render(80); - - // First item should no longer be selected - const firstLine2 = lines.find(l => l.includes('First item')); - expect(firstLine2).toBeDefined(); - expect(firstLine2).not.toContain('\u203A'); - - // Last item should now be selected - const lastLine = lines.find(l => l.includes('Last item')); - expect(lastLine).toBeDefined(); - expect(lastLine).toContain('\u203A'); - }); - - it('wraps from last item to first item when Down arrow is pressed at last index', () => { - const { ctx, getWidget } = createMockContext(); - - const threeItems: WorklogBrowseItem[] = [ - { id: 'WL-001', title: 'First item', status: 'open' }, - { id: 'WL-002', title: 'Middle item', status: 'open' }, - { id: 'WL-003', title: 'Last item', status: 'open' }, - ]; - - defaultChooseWorkItem(threeItems, ctx, vi.fn()); - const widget = getWidget()!; - - // Navigate down to last item (index 2) - widget.handleInput!('\u001b[B'); - widget.handleInput!('\u001b[B'); - - let lines = widget.render(80); - let lastLine = lines.find(l => l.includes('Last item')); - expect(lastLine).toContain('\u203A'); - - // Press Down arrow — should wrap to first item - widget.handleInput!('\u001b[B'); - lines = widget.render(80); - - // Last item should no longer be selected - lastLine = lines.find(l => l.includes('Last item')); - expect(lastLine).toBeDefined(); - expect(lastLine).not.toContain('\u203A'); - - // First item should now be selected - const firstLine = lines.find(l => l.includes('First item')); - expect(firstLine).toBeDefined(); - expect(firstLine).toContain('\u203A'); - }); - - it('does not affect normal up/down movement within the list', () => { - const { ctx, getWidget } = createMockContext(); - - const threeItems: WorklogBrowseItem[] = [ - { id: 'WL-001', title: 'First item', status: 'open' }, - { id: 'WL-002', title: 'Middle item', status: 'open' }, - { id: 'WL-003', title: 'Last item', status: 'open' }, - ]; - - defaultChooseWorkItem(threeItems, ctx, vi.fn()); - const widget = getWidget()!; - - // Press Down — should go to middle item - widget.handleInput!('\u001b[B'); - let lines = widget.render(80); - let middleLine = lines.find(l => l.includes('Middle item')); - expect(middleLine).toContain('\u203A'); - - // Press Down again — should go to last item - widget.handleInput!('\u001b[B'); - lines = widget.render(80); - let lastLine = lines.find(l => l.includes('Last item')); - expect(lastLine).toContain('\u203A'); - - // Press Up — should go back to middle item - widget.handleInput!('\u001b[A'); - lines = widget.render(80); - middleLine = lines.find(l => l.includes('Middle item')); - expect(middleLine).toContain('\u203A'); - }); - - it('handles empty item list gracefully (no crash on Up/Down)', () => { - const { ctx, getWidget, getDone } = createMockContext(); - - defaultChooseWorkItem([], ctx, vi.fn()); - const widget = getWidget()!; - - // Should not crash when pressing Up or Down on empty list - expect(() => { - widget.handleInput!('\u001b[A'); - widget.handleInput!('\u001b[B'); - }).not.toThrow(); - - const lines = widget.render(80); - const rendered = lines.join('\n'); - // Should show the empty state message - expect(rendered).toContain('No items to display'); - }); - - it('wraps from first to last and last to first at child hierarchy level', async () => { - const { ctx, getWidget } = createMockContext(); - - // Root items: one parent with children - const rootItems2: WorklogBrowseItem[] = [ - { id: 'WL-001', title: 'Parent with kids', status: 'open', childCount: 3 }, - ]; - - // Child items: 3 items — wrap should work in child lists too - const wrapChildItems: WorklogBrowseItem[] = [ - { id: 'WL-004', title: 'Child A', status: 'open' }, - { id: 'WL-005', title: 'Child B', status: 'open' }, - { id: 'WL-006', title: 'Child C', status: 'open' }, - ]; - - const fetchChildren = vi.fn().mockResolvedValue(wrapChildItems); - - defaultChooseWorkItem(rootItems2, ctx, vi.fn(), undefined, undefined, fetchChildren); - const widget = getWidget()!; - - // Navigate into children using Tab - widget.handleInput!('\t'); - await vi.advanceTimersByTimeAsync(10); - - // We are now in child view; items are ["..", Child A, Child B, Child C] - // Selection is at index 0 (".." entry). Navigate down to Child C (last real item) - widget.handleInput!('\u001b[B'); // Child A (index 1) - widget.handleInput!('\u001b[B'); // Child B (index 2) - widget.handleInput!('\u001b[B'); // Child C (index 3) - - let lines = widget.render(80); - let childCLine = lines.find(l => l.includes('Child C')); - expect(childCLine).toContain('\u203A'); - - // Press Down at last item — should wrap to ".." (index 0) - widget.handleInput!('\u001b[B'); - lines = widget.render(80); - const dotDotLine = lines.find(l => l.includes('..')); - childCLine = lines.find(l => l.includes('Child C')); - expect(dotDotLine).toContain('\u203A'); - expect(childCLine).not.toContain('\u203A'); - - // Press Up at first item ("..") — should wrap to Child C (index 3, last) - widget.handleInput!('\u001b[A'); - lines = widget.render(80); - childCLine = lines.find(l => l.includes('Child C')); - const dotDotLine2 = lines.find(l => l.includes('..')); - expect(childCLine).toContain('\u203A'); - expect(dotDotLine2).not.toContain('\u203A'); - }); - - it('supports two-direction wrap with single-item list (selects same item)', () => { - const { ctx, getWidget } = createMockContext(); - - const singleItem: WorklogBrowseItem[] = [ - { id: 'WL-001', title: 'Solo item', status: 'open' }, - ]; - - defaultChooseWorkItem(singleItem, ctx, vi.fn()); - const widget = getWidget()!; - - // Initial selection should be at index 0 - let lines = widget.render(80); - let soloLine = lines.find(l => l.includes('Solo item')); - expect(soloLine).toContain('\u203A'); - - // Press Up — wraps to same item (items.length - 1 = 0) - widget.handleInput!('\u001b[A'); - lines = widget.render(80); - soloLine = lines.find(l => l.includes('Solo item')); - // Should still be selected - expect(soloLine).toContain('\u203A'); - - // Press Down — wraps to same item - widget.handleInput!('\u001b[B'); - lines = widget.render(80); - soloLine = lines.find(l => l.includes('Solo item')); - expect(soloLine).toContain('\u203A'); - }); -}); diff --git a/packages/tui/tests/browse-shortcut-help.test.ts b/packages/tui/tests/browse-shortcut-help.test.ts deleted file mode 100644 index e4f06f9fa..000000000 --- a/packages/tui/tests/browse-shortcut-help.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -vi.mock('@earendil-works/pi-coding-agent', () => ({ - getAgentDir: () => '/home/test-user/.pi/agent', -})); - -vi.mock('node:fs', () => ({ - readFileSync: vi.fn((path) => { - if (String(path).endsWith('shortcuts.json')) { - return JSON.stringify([ - { key: 'n', command: '/intake <id>', view: 'both', stages: ['idea'] }, - { key: 'p', command: '/plan <id>', view: 'both', stages: ['intake_complete'] }, - { key: 'i', command: '/skill:implement <id>', view: 'both', stages: ['intake_complete', 'plan_complete', 'in_progress'] }, - { key: 'a', command: '/skill:audit <id>', view: 'both', stages: ['in_progress', 'in_review'] }, - ]); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }), - writeFileSync: vi.fn(), - mkdirSync: vi.fn(), - realpathSync: vi.fn((p) => p), - existsSync: vi.fn(() => false), -})); - -/** - - * Tests for shortcut keys display in browse list help text. - - * - - * Verifies that available shortcuts are dynamically shown in the help line - - * based on the ShortcutRegistry, and that the help text remains unchanged - - * when no registry or an empty registry is provided. - - * - - * Run: npx vitest run packages/tui/tests/browse-shortcut-help.test.ts - - */ - - - -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -import { ShortcutRegistry, type ShortcutEntry } from '../extensions/Worklog/shortcut-config.js'; -import { defaultChooseWorkItem, type WorklogBrowseItem } from '../extensions/Worklog/index.js'; - -describe('Browse list help text with shortcuts', () => { - let registry: ShortcutRegistry; - let items: WorklogBrowseItem[]; - - beforeEach(() => { - const entries: ShortcutEntry[] = [ - { key: 'i', command: '/skill:implement <id>', view: 'both' }, - { key: 'p', command: '/plan <id>', view: 'list' }, - { key: 'n', command: '/intake <id>', view: 'both' }, - { key: 'a', command: '/skill:audit <id>', view: 'detail' }, - ]; - registry = new ShortcutRegistry(entries); - items = [ - { id: 'WL-001', title: 'Test item', status: 'open' }, - ]; - }); - - /** - * Create a mock BrowseContext that captures the rendered output from the - * browse widget factory. Returns both the context and a helper to get the - * captured help line text. - */ - function createMockContext(): { ctx: { ui: any }; getHelpLine: () => string | null } { - let capturedHelpLine: string | null = null; - - const mockUi = { - custom: vi.fn(<T>( - factory: ( - tui: any, - theme: any, - _keybindings: unknown, - done: (value: T) => void, - ) => { - render: (width: number) => string[]; - invalidate: () => void; - handleInput?: (data: string) => void; - }, - ) => { - const tui = { requestRender: vi.fn() }; - const theme = { - fg: vi.fn((_color: string, text: string) => text), - bold: vi.fn((text: string) => text), - }; - const done = vi.fn(); - - const widget = factory(tui, theme, undefined, done); - - // Capture the last line of the rendered output (help line) - const lines = widget.render(80); - capturedHelpLine = lines[lines.length - 1] ?? null; - - // Return a never-resolving promise since we're not testing interactivity - return new Promise<T>(() => { /* never resolves - testing render output only */ }); - }), - notify: vi.fn(), - setEditorText: vi.fn(), - setWidget: vi.fn(), - }; - - return { - ctx: { ui: mockUi }, - getHelpLine: () => capturedHelpLine, - }; - } - - it('displays shortcut hints in help text when registry has list/both entries', async () => { - const { ctx, getHelpLine } = createMockContext(); - - // Invoke defaultChooseWorkItem - the mock custom() calls render synchronously - defaultChooseWorkItem(items, ctx, vi.fn(), registry); - - // Allow microtasks to flush - await new Promise(process.nextTick); - - const helpLine = getHelpLine(); - expect(helpLine).not.toBeNull(); - // Static navigation text has been removed; only shortcut hints remain - expect(helpLine!).not.toContain('↑↓ navigate'); - expect(helpLine!).not.toContain('enter select'); - expect(helpLine!).not.toContain('esc cancel'); - // Should include hints for 'both' and 'list' view entries - expect(helpLine!).toContain('i:implement'); - expect(helpLine!).toContain('p:plan'); - expect(helpLine!).toContain('n:intake'); - // Should NOT include 'detail' only entries - expect(helpLine!).not.toContain('a:audit'); - }); - - it('uses correct help text format with spaces', async () => { - const { ctx, getHelpLine } = createMockContext(); - defaultChooseWorkItem(items, ctx, vi.fn(), registry); - await new Promise(process.nextTick); - - const helpLine = getHelpLine(); - // Only shortcut hints remain, separated by spaces - expect(helpLine!).toMatch(/i:implement p:plan n:intake/); - }); - - it('omits shortcut hints when no registry is provided', async () => { - const { ctx, getHelpLine } = createMockContext(); - defaultChooseWorkItem(items, ctx, vi.fn(), undefined); - await new Promise(process.nextTick); - - const helpLine = getHelpLine(); - // Static navigation text removed and no shortcuts — help line is empty - expect(helpLine!).toBe(''); - expect(helpLine!).not.toMatch(/[a-z]+:/); - }); - - it('omits shortcut hints when registry has no list/both entries', async () => { - const detailOnly = new ShortcutRegistry([ - { key: 'x', command: 'detail-only <id>', view: 'detail' }, - ]); - const { ctx, getHelpLine } = createMockContext(); - defaultChooseWorkItem(items, ctx, vi.fn(), detailOnly); - await new Promise(process.nextTick); - - const helpLine = getHelpLine(); - // Static navigation text removed and no applicable shortcuts — help line is empty - expect(helpLine!).toBe(''); - expect(helpLine!).not.toMatch(/[a-z]+:/); - }); - - it('omits shortcut hints when registry has no entries', async () => { - const empty = new ShortcutRegistry([]); - const { ctx, getHelpLine } = createMockContext(); - defaultChooseWorkItem(items, ctx, vi.fn(), empty); - await new Promise(process.nextTick); - - const helpLine = getHelpLine(); - // Static navigation text removed and no shortcuts — help line is empty - expect(helpLine!).toBe(''); - expect(helpLine!).not.toMatch(/[a-z]+:/); - }); - - it('extracts clean labels from various command formats', async () => { - const variedCommands = new ShortcutRegistry([ - { key: 'i', command: '/skill:implement <id>', view: 'both' }, - { key: 'c', command: '/create\n<desc>\nPriority: medium', view: 'list' }, - { key: 'p', command: '/plan <id>', view: 'both' }, - ]); - const { ctx, getHelpLine } = createMockContext(); - defaultChooseWorkItem(items, ctx, vi.fn(), variedCommands); - await new Promise(process.nextTick); - - const helpLine = getHelpLine(); - expect(helpLine!).toContain('i:implement'); - expect(helpLine!).toContain('c:create'); - expect(helpLine!).toContain('p:plan'); - // Should NOT contain raw command parts - expect(helpLine!).not.toContain('/skill:'); - expect(helpLine!).not.toContain('<id>'); - expect(helpLine!).not.toContain('<desc>'); - }); - - it('renders help as the last line in the output', async () => { - const { ctx, getHelpLine } = createMockContext(); - defaultChooseWorkItem(items, ctx, vi.fn(), registry); - await new Promise(process.nextTick); - - const helpLine = getHelpLine(); - // Static navigation text removed; only shortcut hints remain - expect(helpLine!).not.toContain('↑↓ navigate'); - expect(helpLine!).toContain('i:implement'); - }); - - it('shows collapsed chord-family hints for u/x/f leaders with their labels', async () => { - const chordRegistry = new ShortcutRegistry([ - { key: 'i', command: '/skill:implement <id>', view: 'both', label: 'implement' }, - { chord: ['u', 'p', 'l'], command: '!!wl update <id> --priority low', view: 'both', label: 'update priority low' }, - { chord: ['u', 'p', 'h'], command: '!!wl update <id> --priority high', view: 'both', label: 'update priority high' }, - { chord: ['u', 's'], command: '!!wl update <id> --status <status> --stage <stage>', view: 'both', label: 'update stage/status' }, - { chord: ['x', 'c'], command: '!!wl close <id>', view: 'both', label: 'close done' }, - { chord: ['x', 'd'], command: '!!wl delete <id>', view: 'both', label: 'close deleted' }, - { chord: ['f', 'i'], command: '/wl idea', view: 'both', label: 'filter idea' }, - { chord: ['f', 'r'], command: '/wl review', view: 'both', label: 'filter in_review' }, - ]); - const { ctx, getHelpLine } = createMockContext(); - defaultChooseWorkItem(items, ctx, vi.fn(), chordRegistry); - await new Promise(process.nextTick); - - const helpLine = getHelpLine(); - // Chord families are collapsed to a single leader hint each - expect(helpLine!).toContain('u:update...'); - expect(helpLine!).toContain('x:close...'); - expect(helpLine!).toContain('f:filter...'); - // Single-key entries still render as key:label - expect(helpLine!).toContain('i:implement'); - }); - - it('shows stage-gated audit chords only when item is in_review', async () => { - const auditRegistry = new ShortcutRegistry([ - { key: 'i', command: '/skill:implement <id>', view: 'both', label: 'implement' }, - { chord: ['a', 'a'], command: '/skill:audit <id>', view: 'both', label: 'audit automatic', stages: ['in_review'] }, - { chord: ['a', 'y'], command: '!!wl reviewed <id> false && wl audit-set <id> --ready-to-close yes --summary \'Approved by manual review\'', view: 'both', label: 'audit approve', stages: ['in_review'] }, - ]); - const reviewItems = [{ id: 'WL-001', title: 'Test item', status: 'open', stage: 'in_review' }]; - const ideaItems = [{ id: 'WL-001', title: 'Test item', status: 'open', stage: 'idea' }]; - - const reviewCtx = createMockContext(); - defaultChooseWorkItem(reviewItems, reviewCtx.ctx, vi.fn(), auditRegistry); - await new Promise(process.nextTick); - expect(reviewCtx.getHelpLine()!).toContain('a:audit...'); - - const ideaCtx = createMockContext(); - defaultChooseWorkItem(ideaItems, ideaCtx.ctx, vi.fn(), auditRegistry); - await new Promise(process.nextTick); - expect(ideaCtx.getHelpLine()!).not.toContain('a:audit'); - }); -}); diff --git a/packages/tui/tests/browse-total-count.test.ts b/packages/tui/tests/browse-total-count.test.ts deleted file mode 100644 index 0f031a10f..000000000 --- a/packages/tui/tests/browse-total-count.test.ts +++ /dev/null @@ -1,413 +0,0 @@ -vi.mock('@earendil-works/pi-coding-agent', () => ({ - getAgentDir: () => '/home/test-user/.pi/agent', -})); - -// Wrap fetchTotalActionableCount so auto-refresh re-fetch behavior is -// observable and controllable (ESM live bindings make vi.spyOn on the module -// namespace insufficient for modules that already imported the function). -let mockTotalCount: number | undefined; -vi.mock('../extensions/Worklog/lib/tools.js', async (importOriginal) => { - const actual: any = await importOriginal(); - return { - ...actual, - fetchTotalActionableCount: vi.fn(async () => mockTotalCount), - }; -}); - -vi.mock('node:fs', () => ({ - readFileSync: vi.fn((path) => { - if (String(path).endsWith('shortcuts.json')) { - return JSON.stringify([ - { key: 'n', command: '/intake <id>', view: 'both', stages: ['idea'] }, - { key: 'p', command: '/plan <id>', view: 'both', stages: ['intake_complete'] }, - { key: 'i', command: '/skill:implement <id>', view: 'both', stages: ['intake_complete', 'plan_complete', 'in_progress'] }, - { key: 'a', command: '/skill:audit <id>', view: 'both', stages: ['in_progress', 'in_review'] }, - ]); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }), - writeFileSync: vi.fn(), - mkdirSync: vi.fn(), - realpathSync: vi.fn((p) => p), - existsSync: vi.fn(() => false), -})); - -/** - * Tests for the total item count display in the browse selection list title. - * - * Verifies that: - * - The title shows "top X of Y" when a totalCount is provided - * - The title falls back to "top X" (without "of Y") when totalCount is undefined - * - Both the ctx.ui.select() fallback path and custom overlay render() path - * display the total count correctly - * - Graceful degradation when totalCount is 0 - * - When browseItemCount > totalCount, the displayed count is capped to totalCount - * - When browseItemCount <= totalCount, the current behavior is unchanged - * - * Run: npx vitest run packages/tui/tests/browse-total-count.test.ts - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { defaultChooseWorkItem, type WorklogBrowseItem } from '../extensions/Worklog/index.js'; -import { ShortcutRegistry } from '../extensions/Worklog/shortcut-config.js'; - -describe('Browse list total count in title', () => { - let items: WorklogBrowseItem[]; - - beforeEach(() => { - items = [ - { id: 'WL-001', title: 'First item', status: 'open' }, - { id: 'WL-002', title: 'Second item', status: 'in_progress' }, - ]; - }); - - /** - * Create a mock BrowseContext that captures the rendered output from the - * custom overlay render path. Returns helpers to inspect the title line. - */ - function createMockCustomContext(): { ctx: { ui: any }; getTitle: () => string | null } { - let capturedTitle: string | null = null; - - const mockUi = { - custom: vi.fn(<T>( - factory: ( - tui: any, - theme: any, - _keybindings: unknown, - done: (value: T) => void, - ) => { - render: (width: number) => string[]; - invalidate: () => void; - handleInput?: (data: string) => void; - }, - ) => { - const theme = { - fg: vi.fn((_color: string, text: string) => text), - bold: vi.fn((text: string) => text), - }; - const done = vi.fn(); - const tui = { requestRender: vi.fn() }; - - const widget = factory(tui, theme, undefined, done); - - // Capture the title (first rendered line) - const lines = widget.render(80); - capturedTitle = lines[0] ?? null; - - // Return a never-resolving promise since we're not testing interactivity - return new Promise<T>(() => { /* never resolves - testing render output only */ }); - }), - notify: vi.fn(), - setEditorText: vi.fn(), - setWidget: vi.fn(), - select: vi.fn(), - }; - - return { - ctx: { ui: mockUi }, - getTitle: () => capturedTitle, - }; - } - - /** - * Create a mock BrowseContext for the select() fallback path. - * Does NOT provide a custom() function, so defaultChooseWorkItem uses - * ctx.ui.select() instead. - */ - function createMockSelectContext(): { ctx: { ui: any }; getSelectTitle: () => string | null } { - let capturedSelectTitle: string | null = null; - - const mockUi = { - select: vi.fn((title: string) => { - capturedSelectTitle = title; - // Return a promise that never resolves to match expected behavior - return new Promise<string | undefined>((resolve) => { - // Store the title but don't resolve (simulates the user hasn't selected yet) - // We'll return undefined immediately to unblock the test - setTimeout(() => resolve(undefined), 0); - }); - }), - custom: undefined, - notify: vi.fn(), - setEditorText: vi.fn(), - setWidget: vi.fn(), - }; - - return { - ctx: { ui: mockUi }, - getSelectTitle: () => capturedSelectTitle, - }; - } - - // ── Custom overlay render path (Pi TUI) tests ──────────────────── - - it('shows "top X of Y" in the custom overlay title when totalCount is provided', async () => { - const { ctx, getTitle } = createMockCustomContext(); - - // Provide a total count of 42 actionable items - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, undefined, undefined, 42); - await new Promise(process.nextTick); - - const title = getTitle(); - expect(title).not.toBeNull(); - expect(title).toContain('Browse Worklog next items (top 2 of 42)'); - }); - - it('shows "top X" (without "of Y") in the custom overlay title when totalCount is undefined', async () => { - const { ctx, getTitle } = createMockCustomContext(); - - // No totalCount provided — should fall back to "top X" - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, undefined, undefined, undefined); - await new Promise(process.nextTick); - - const title = getTitle(); - expect(title).not.toBeNull(); - expect(title).toContain('Browse Worklog next items (top 2)'); - expect(title).not.toContain('of'); - }); - - it('shows "top X of 0" in the custom overlay title when totalCount is 0', async () => { - const { ctx, getTitle } = createMockCustomContext(); - - // Edge case: total count is 0 - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, undefined, undefined, 0); - await new Promise(process.nextTick); - - const title = getTitle(); - expect(title).not.toBeNull(); - expect(title).toContain('Browse Worklog next items (top 2 of 0)'); - }); - - it('handles large totalCount values in the custom overlay title', async () => { - const { ctx, getTitle } = createMockCustomContext(); - - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, undefined, undefined, 9999); - await new Promise(process.nextTick); - - const title = getTitle(); - expect(title).not.toBeNull(); - expect(title).toContain('Browse Worklog next items (top 2 of 9999)'); - }); - - it('caps displayed count to totalCount when browseItemCount > totalCount in custom overlay title', async () => { - const { ctx, getTitle } = createMockCustomContext(); - - // browseItemCount defaults to 5, so with totalCount=2 it should cap to 2 - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, undefined, undefined, 2); - await new Promise(process.nextTick); - - const title = getTitle(); - expect(title).not.toBeNull(); - // Should show "top 2 of 2", not "top 5 of 2" - expect(title).toContain('Browse Worklog next items (top 2 of 2)'); - }); - - it('does not cap when browseItemCount <= totalCount in custom overlay title', async () => { - const { ctx, getTitle } = createMockCustomContext(); - - // browseItemCount defaults to 5, so with totalCount=10 the value is unchanged - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, undefined, undefined, 10); - await new Promise(process.nextTick); - - const title = getTitle(); - expect(title).not.toBeNull(); - expect(title).toContain('Browse Worklog next items (top 2 of 10)'); - }); - - it('shows the actual displayed count when the list exceeds browseItemCount (mandatory set)', async () => { - const { ctx, getTitle } = createMockCustomContext(); - - // Smart selection may return MORE items than browseItemCount when the - // mandatory critical/completed-in_review set is shown. The heading must - // reflect the actual displayed count (items.length), not browseItemCount. - const manyItems: WorklogBrowseItem[] = Array.from({ length: 22 }, (_, i) => ({ - id: `WL-${String(i + 1).padStart(3, '0')}`, - title: `Item ${i + 1}`, - status: 'open', - })); - defaultChooseWorkItem(manyItems, ctx, vi.fn(), undefined, undefined, undefined, 100); - await new Promise(process.nextTick); - - const title = getTitle(); - expect(title).not.toBeNull(); - // browseItemCount defaults to 5, but 22 items are displayed → "top 22 of 100". - expect(title).toContain('Browse Worklog next items (top 22 of 100)'); - }); - - it('does not under-report N when the mandatory set exceeds the actionable total', async () => { - const { ctx, getTitle } = createMockCustomContext(); - - // 7 items displayed (mandatory critical/completed-in_review set), but the - // actionable total counts only open/in-progress/blocked → total=3. - // N must reflect the actual displayed count (7), not be capped to M (3). - const sevenItems: WorklogBrowseItem[] = Array.from({ length: 7 }, (_, i) => ({ - id: `WL-M${String(i + 1).padStart(3, '0')}`, - title: `Mandatory ${i + 1}`, - status: 'completed', - stage: 'in_review', - })); - defaultChooseWorkItem(sevenItems, ctx, vi.fn(), undefined, undefined, undefined, 3); - await new Promise(process.nextTick); - - const title = getTitle(); - expect(title).not.toBeNull(); - expect(title).toContain('Browse Worklog next items (top 7 of 3)'); - }); - - // ── select() fallback path (non-TUI) tests ─────────────────────── - - it('shows "top X of Y" in the select() fallback title when totalCount is provided', async () => { - const { ctx, getSelectTitle } = createMockSelectContext(); - - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, undefined, undefined, 42); - // The select() call is made synchronously inside defaultChooseWorkItem - await new Promise(process.nextTick); - - const title = getSelectTitle(); - expect(title).not.toBeNull(); - expect(title).toContain('Browse Worklog next items (top 2 of 42)'); - }); - - it('shows "top X" (without "of Y") in the select() fallback title when totalCount is undefined', async () => { - const { ctx, getSelectTitle } = createMockSelectContext(); - - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, undefined, undefined, undefined); - await new Promise(process.nextTick); - - const title = getSelectTitle(); - expect(title).not.toBeNull(); - expect(title).toContain('Browse Worklog next items (top 2)'); - expect(title).not.toContain('of'); - }); - - it('shows "top X of 0" in the select() fallback title when totalCount is 0', async () => { - const { ctx, getSelectTitle } = createMockSelectContext(); - - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, undefined, undefined, 0); - await new Promise(process.nextTick); - - const title = getSelectTitle(); - expect(title).not.toBeNull(); - expect(title).toContain('Browse Worklog next items (top 2 of 0)'); - }); - - it('caps displayed count to totalCount when browseItemCount > totalCount in select() fallback title', async () => { - const { ctx, getSelectTitle } = createMockSelectContext(); - - // browseItemCount defaults to 5, so with totalCount=2 it should cap to 2 - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, undefined, undefined, 2); - await new Promise(process.nextTick); - - const title = getSelectTitle(); - expect(title).not.toBeNull(); - // Should show "top 2 of 2", not "top 5 of 2" - expect(title).toContain('Browse Worklog next items (top 2 of 2)'); - }); - - it('does not cap when browseItemCount <= totalCount in select() fallback title', async () => { - const { ctx, getSelectTitle } = createMockSelectContext(); - - // browseItemCount defaults to 5, so with totalCount=10 the value is unchanged - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, undefined, undefined, 10); - await new Promise(process.nextTick); - - const title = getSelectTitle(); - expect(title).not.toBeNull(); - expect(title).toContain('Browse Worklog next items (top 2 of 10)'); - }); - - // ── Regression: existing tests still pass ──────────────────────── - - it('still renders items correctly in custom overlay when totalCount is provided', async () => { - const { ctx } = createMockCustomContext(); - - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, undefined, undefined, 42); - await new Promise(process.nextTick); - - // The widget was created without errors — that's the regression check - expect(ctx.ui.custom).toHaveBeenCalledTimes(1); - }); - - it('still renders items correctly in custom overlay when totalCount is undefined', async () => { - const { ctx } = createMockCustomContext(); - - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, undefined, undefined, undefined); - await new Promise(process.nextTick); - - expect(ctx.ui.custom).toHaveBeenCalledTimes(1); - }); - - it('select() fallback still works when totalCount is provided', async () => { - const { ctx } = createMockSelectContext(); - - defaultChooseWorkItem(items, ctx, vi.fn(), undefined, undefined, undefined, 42); - await new Promise(process.nextTick); - - // Should have called select() with the title and never thrown - expect(ctx.ui.select).toHaveBeenCalledTimes(1); - }); - - it('re-fetches the total actionable count on auto-refresh and updates the title', async () => { - vi.useFakeTimers(); - try { - mockTotalCount = 7; - const tools = await import('../extensions/Worklog/lib/tools.js'); - const fetchSpy = (tools as any).fetchTotalActionableCount; - fetchSpy.mockClear(); - - const reFetchItems = vi.fn().mockResolvedValue([ - { id: 'WL-001', title: 'First item', status: 'open' }, - { id: 'WL-002', title: 'Second item', status: 'in_progress' }, - { id: 'WL-003', title: 'Third item', status: 'open' }, - ]); - - let capturedWidget: { render: (w: number) => string[] } | null = null; - const ctx = { - ui: { - custom: vi.fn(<T>( - factory: ( - tui: any, - theme: any, - _keybindings: unknown, - done: (value: T) => void, - ) => { - render: (width: number) => string[]; - invalidate: () => void; - handleInput?: (data: string) => void; - }, - ) => { - const tui = { requestRender: vi.fn() }; - const theme = { fg: vi.fn((_c: string, t: string) => t), bold: vi.fn((t: string) => t) }; - const done = vi.fn(); - capturedWidget = factory(tui, theme, undefined, done); - return new Promise<T>(() => { /* never resolves */ }); - }), - notify: vi.fn(), - setEditorText: vi.fn(), - setWidget: vi.fn(), - select: vi.fn(), - }, - }; - - defaultChooseWorkItem(items, ctx as any, vi.fn(), undefined, reFetchItems, undefined, 7); - - // Initial render shows "top 2 of 7" (2 initial items, total 7) - let title = capturedWidget!.render(80)[0]; - expect(title).toContain('top 2 of 7'); - - // Auto-refresh fires after 5s and re-fetches the count - await vi.advanceTimersByTimeAsync(5000); - expect(fetchSpy).toHaveBeenCalled(); - expect(reFetchItems).toHaveBeenCalledTimes(1); - - // Change the count and trigger another refresh; re-render shows new total - mockTotalCount = 9; - await vi.advanceTimersByTimeAsync(5000); - title = capturedWidget!.render(80)[0]; - expect(title).toContain('top 3 of 9'); - } finally { - vi.useRealTimers(); - mockTotalCount = undefined; - } - }); -}); diff --git a/packages/tui/tests/build-selection-widget.test.ts b/packages/tui/tests/build-selection-widget.test.ts deleted file mode 100644 index f837cf862..000000000 --- a/packages/tui/tests/build-selection-widget.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * Unit tests for buildSelectionWidget. - * - * Verifies that the selection preview widget renders a single-line summary - * in the format: WL-123456 | tags: tui, ui | GH #608 - * - * The existing preview content (icon prefix, coloured title, priority text, - * stage, risk/effort) is entirely replaced — the preview shows only the new - * ID/Tags/GitHub ID line. - * - * Run: npx vitest run packages/tui/tests/build-selection-widget.test.ts - */ - -import { describe, it, expect, vi } from 'vitest'; - -vi.mock('@earendil-works/pi-coding-agent', () => ({ - getAgentDir: () => '/home/test-user/.pi/agent', -})); - -import { buildSelectionWidget, type WorklogBrowseItem } from '../extensions/Worklog/index.js'; -import { type PiTheme } from '../extensions/Worklog/worklog-helpers.js'; -import { type Settings } from '../extensions/Worklog/settings-config.js'; - -const mockTheme: PiTheme = { - fg: (color, text) => `[${color}]${text}[/${color}]`, - bold: (text) => `**${text}**`, -}; - -const mockSettings: Settings = { - browseItemCount: 5, - showIcons: true, -}; - -const mockItem: WorklogBrowseItem = { - id: 'WL-001', - title: 'Implement chat pane', - status: 'in_progress', - priority: 'high', - stage: 'in_progress', - risk: 'Medium', - effort: 'S', - tags: ['tui', 'ui'], - githubIssueNumber: 608, -}; - -describe('buildSelectionWidget', () => { - it('returns a single rendered line', () => { - const factory = buildSelectionWidget(mockItem); - const widget = factory(null, mockTheme); - const lines = widget.render(120); - expect(lines).toHaveLength(1); - }); - - it('displays ID, tags, GitHub issue number, and effort/risk icons in the expected format', () => { - const factory = buildSelectionWidget(mockItem, mockSettings); - const widget = factory(null, mockTheme); - const line = widget.render(120)[0]; - - // Expected format: WL-123456 | tags: tui, ui | GH #608 | 🐇 🌱 - expect(line).toContain('WL-001'); - expect(line).toContain('tags: tui, ui'); - expect(line).toContain('GH #608'); - // Effort (S) and risk (Medium) icons - expect(line).toContain('🐇'); // S effort - expect(line).toContain('\u{26A0}\u{FE0F}'); // ⚠️ Medium risk - }); - - it('includes pipe separators between all segments including effort/risk', () => { - const factory = buildSelectionWidget(mockItem, mockSettings); - const widget = factory(null, mockTheme); - const line = widget.render(120)[0]; - - // Should have three pipe separators for four segments (ID | tags | GH | effort_risk) - const pipeCount = (line.match(/\|/g) || []).length; - expect(pipeCount).toBe(3); - }); - - it('shows "tags: —" when tags array is empty', () => { - const noTagsItem: WorklogBrowseItem = { - ...mockItem, - tags: [], - }; - const factory = buildSelectionWidget(noTagsItem, mockSettings); - const widget = factory(null, mockTheme); - const line = widget.render(120)[0]; - - expect(line).toContain('tags: —'); - expect(line).toContain('GH #608'); - expect(line).toContain('WL-001'); - // Still shows effort/risk icons - expect(line).toContain('🐇'); - expect(line).toContain('\u{26A0}\u{FE0F}'); - }); - - it('shows "tags: —" when tags is undefined', () => { - const noTagsItem: WorklogBrowseItem = { - ...mockItem, - tags: undefined, - }; - const factory = buildSelectionWidget(noTagsItem, mockSettings); - const widget = factory(null, mockTheme); - const line = widget.render(120)[0]; - - expect(line).toContain('tags: —'); - expect(line).toContain('GH #608'); - }); - - it('omits the GH # segment when githubIssueNumber is undefined', () => { - const noGithubItem: WorklogBrowseItem = { - ...mockItem, - githubIssueNumber: undefined, - }; - const factory = buildSelectionWidget(noGithubItem, mockSettings); - const widget = factory(null, mockTheme); - const line = widget.render(120)[0]; - - expect(line).toContain('WL-001'); - expect(line).toContain('tags: tui, ui'); - expect(line).not.toContain('GH #'); - // Still shows effort/risk icons - expect(line).toContain('🐇'); - expect(line).toContain('\u{26A0}\u{FE0F}'); - }); - - it('omits the GH # segment when githubIssueNumber is 0', () => { - const zeroGithubItem: WorklogBrowseItem = { - ...mockItem, - githubIssueNumber: 0, - }; - const factory = buildSelectionWidget(zeroGithubItem, mockSettings); - const widget = factory(null, mockTheme); - const line = widget.render(120)[0]; - - expect(line).toContain('WL-001'); - expect(line).toContain('tags: tui, ui'); - expect(line).not.toContain('GH #'); - // Still shows effort/risk icons - expect(line).toContain('🐇'); - expect(line).toContain('\u{26A0}\u{FE0F}'); - }); - - it('shows only ID, tags, and effort/risk when both tags and githubIssueNumber are missing', () => { - const minimalItem: WorklogBrowseItem = { - id: 'WL-000', - title: 'Minimal', - status: 'open', - risk: 'Low', - effort: 'M', - tags: undefined, - githubIssueNumber: undefined, - }; - const factory = buildSelectionWidget(minimalItem, mockSettings); - const widget = factory(null, mockTheme); - const line = widget.render(120)[0]; - - expect(line).toContain('WL-000'); - expect(line).toContain('tags: —'); - expect(line).not.toContain('GH #'); - // Should still show effort/risk - expect(line).toContain('🐕'); // M effort - expect(line).toContain('🌱'); // Low risk - // Two pipe separators (ID | tags | effort+risk) - const pipeCount = (line.match(/\|/g) || []).length; - expect(pipeCount).toBe(2); - }); - - it('handles a single tag correctly', () => { - const singleTagItem: WorklogBrowseItem = { - ...mockItem, - tags: ['bug'], - }; - const factory = buildSelectionWidget(singleTagItem, mockSettings); - const widget = factory(null, mockTheme); - const line = widget.render(120)[0]; - - expect(line).toContain('tags: bug'); - }); - - it('truncates line when it exceeds width', () => { - const factory = buildSelectionWidget(mockItem, mockSettings); - const widget = factory(null, mockTheme); - const line = widget.render(15)[0]; - // Should be truncated with ellipsis - expect(line.length).toBeLessThanOrEqual(20); // 15 + '…' - expect(line).toContain('…'); - }); - - it('does not wrap content in theme colours', () => { - const factory = buildSelectionWidget(mockItem, mockSettings); - const widget = factory(null, mockTheme); - const line = widget.render(120)[0]; - - // The new preview is plain text — no colour tags - expect(line).not.toContain('[warning]'); - expect(line).not.toContain('[error]'); - expect(line).not.toContain('[/warning]'); - expect(line).not.toContain('[/error]'); - }); - - it('does not include status icons, stage icons, priority text, or stage', () => { - const factory = buildSelectionWidget(mockItem, mockSettings); - const widget = factory(null, mockTheme); - const line = widget.render(120)[0]; - - // The old content should not be present - expect(line).not.toContain('🔄'); - expect(line).not.toContain('🛠️'); - expect(line).not.toContain('❓'); - expect(line).not.toContain('⭐'); - expect(line).not.toContain('HIGH'); - expect(line).not.toContain('Medium/Small'); - }); - - it('does not include title text in the preview', () => { - const factory = buildSelectionWidget(mockItem, mockSettings); - const widget = factory(null, mockTheme); - const line = widget.render(120)[0]; - - // The title should NOT appear in the preview (only ID, tags, GH, effort/risk) - expect(line).not.toContain('Implement chat pane'); - }); - - // ─── Risk/Effort icon tests ──────────────────────────────────────────── - - it('shows effort icon before risk icon in the combined segment', () => { - const factory = buildSelectionWidget(mockItem, mockSettings); - const widget = factory(null, mockTheme); - const line = widget.render(120)[0]; - - const effortIndex = line.indexOf('🐇'); - const riskIndex = line.indexOf('\u{26A0}\u{FE0F}'); - expect(effortIndex).toBeGreaterThan(0); - expect(riskIndex).toBeGreaterThan(effortIndex); - }); - - it('omits effort segment when effort is missing', () => { - const noEffortItem: WorklogBrowseItem = { - ...mockItem, - effort: undefined, - }; - const factory = buildSelectionWidget(noEffortItem, mockSettings); - const widget = factory(null, mockTheme); - const line = widget.render(120)[0]; - - // Risk icon should still appear - expect(line).toContain('\u{26A0}\u{FE0F}'); // ⚠️ Medium risk - // No effort icon - expect(line).not.toContain('🐇'); - }); - - it('omits risk segment when risk is missing', () => { - const noRiskItem: WorklogBrowseItem = { - ...mockItem, - risk: undefined, - }; - const factory = buildSelectionWidget(noRiskItem, mockSettings); - const widget = factory(null, mockTheme); - const line = widget.render(120)[0]; - - // Effort icon should still appear - expect(line).toContain('🐇'); // S effort - // No risk icon - expect(line).not.toContain('\u{26A0}\u{FE0F}'); - }); - - it('omits both effort and risk segments when both are missing', () => { - const noEffortRiskItem: WorklogBrowseItem = { - ...mockItem, - effort: undefined, - risk: undefined, - }; - const factory = buildSelectionWidget(noEffortRiskItem, mockSettings); - const widget = factory(null, mockTheme); - const line = widget.render(120)[0]; - - // Should have only two pipes (ID | tags | GH) = 2 pipes - expect(line).not.toContain('🐇'); - expect(line).not.toContain('\u{26A0}\u{FE0F}'); - const pipeCount = (line.match(/\|/g) || []).length; - expect(pipeCount).toBe(2); - }); - - it('shows text fallback when icons are disabled', () => { - const settingsNoIcons: Settings = { - browseItemCount: 5, - showIcons: false, - }; - const factory = buildSelectionWidget(mockItem, settingsNoIcons); - const widget = factory(null, mockTheme); - const line = widget.render(120)[0]; - - // Should show fallback text instead of emoji - expect(line).toContain('[S]'); // S effort fallback - expect(line).toContain('[MED]'); // Medium risk fallback - // Should NOT contain emoji - expect(line).not.toContain('🐇'); - expect(line).not.toContain('\u{26A0}\u{FE0F}'); - }); -}); diff --git a/packages/tui/tests/extension-label.test.ts b/packages/tui/tests/extension-label.test.ts deleted file mode 100644 index 62974ef04..000000000 --- a/packages/tui/tests/extension-label.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Extension label test — validates that the worklog extension entry point - * path resolves to a label of "Worklog" when processed by Pi's label - * derivation logic. - * - * Pi derives non-package extension display labels from the file path: - * 1. Split the path into segments - * 2. Strip "index.ts" or "index.js" from the end if present - * 3. Find the shortest unique suffix among all extensions - * - * After restructuring, the entry point is at: - * .../extensions/Worklog/index.ts - * - * After stripping index.ts, the last segment is "Worklog", which becomes - * the display label. - * - * This test validates: - * 1. The entry point exists at the expected path - * 2. The package.json manifest is correct - * 3. The path-derived label would be "Worklog" - * 4. All canonical exports from the module resolve correctly - */ - -import { describe, it, expect } from 'vitest'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { existsSync } from 'node:fs'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const PROJECT_ROOT = path.resolve(__dirname, '../../..'); -const EXTENSIONS_DIR = path.resolve(PROJECT_ROOT, 'packages/tui/extensions'); -const WORKLOG_ENTRY = path.resolve(EXTENSIONS_DIR, 'Worklog/index.ts'); -const MANIFEST_PATH = path.resolve(EXTENSIONS_DIR, 'package.json'); - -describe('Extension label derivation', () => { - it('entry point exists at extensions/Worklog/index.ts', () => { - expect(existsSync(WORKLOG_ENTRY)).toBe(true); - }); - - it('package.json manifest exists in extensions directory', () => { - expect(existsSync(MANIFEST_PATH)).toBe(true); - }); - - it('pi manifest declares Worklog/index.ts as extension entry', () => { - const manifest = JSON.parse( - require('fs').readFileSync(MANIFEST_PATH, 'utf-8') - ); - expect(manifest.pi).toBeDefined(); - expect(manifest.pi.extensions).toBeInstanceOf(Array); - expect(manifest.pi.extensions).toContain('./Worklog/index.ts'); - }); - - it('path-derived label resolves to Worklog', () => { - // Simulate Pi's getCompactNonPackageExtensionLabel logic: - // Split path, strip index.ts, last segment is the label - const segments = WORKLOG_ENTRY - .replace(/\\/g, '/') - .split('/') - .filter(s => s.length > 0); - - // Remove index.ts from end - const last = segments[segments.length - 1]; - if (segments.length > 1 && (last === 'index.ts' || last === 'index.js')) { - segments.pop(); - } - - const lastSegment = segments[segments.length - 1]; - expect(lastSegment).toBe('Worklog'); - }); -}); - -describe('Canonical exports', () => { - it('all exports resolve from Worklog/index.ts', async () => { - const mod = await import('../extensions/Worklog/index.ts'); - expect(mod.createWorklogBrowseExtension).toBeDefined(); - expect(mod.default).toBeDefined(); - expect(mod.defaultChooseWorkItem).toBeDefined(); - expect(mod.buildSelectionWidget).toBeDefined(); - expect(mod.getIconPrefix).toBeDefined(); - expect(mod.formatBrowseOption).toBeDefined(); - expect(mod.createScrollableWidget).toBeDefined(); - expect(mod.STAGE_MAP).toBeDefined(); - expect(mod.createDefaultListWorkItems).toBeDefined(); - expect(mod.createListWorkItemsWithStage).toBeDefined(); - expect(typeof mod.createWorklogBrowseExtension).toBe('function'); - }); - - it('no index.ts exists at the legacy extensions path', () => { - // The re-export shim was removed to prevent Pi from auto-discovering - // it as a separate extension alongside Worklog/index.ts - const legacyPath = path.resolve(EXTENSIONS_DIR, 'index.ts'); - expect(existsSync(legacyPath)).toBe(false); - }); -}); diff --git a/packages/tui/tests/icons-import-path.test.ts b/packages/tui/tests/icons-import-path.test.ts deleted file mode 100644 index 1d61dc93d..000000000 --- a/packages/tui/tests/icons-import-path.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Regression test for WL-0MQMFMACS0059UUC: Extension loads but cannot find icons.js. - * - * The Worklog Pi extension at ~/.pi/agent/extensions/worklog is a symlink to - * packages/tui/extensions/. Pi loads Worklog/index.ts via the package.json manifest, - * the import `../../../src/icons.js` resolves to <project>/src/icons.js which - * does NOT exist (only src/icons.ts exists). The fix changes the import to - * point to the compiled output at `../../../dist/icons.js`. - * - * This test verifies that the extension module can be loaded and that icon - * functions (used internally via the import chain) work correctly. - */ - -import { describe, it, expect, vi } from 'vitest'; - -vi.mock('@earendil-works/pi-coding-agent', () => ({ - getAgentDir: () => '/home/test-user/.pi/agent', -})); - -import { getIconPrefix, createWorklogBrowseExtension, STAGE_MAP } from '../extensions/Worklog/index.js'; - -describe('extension module loads with valid icons import (regression: WL-0MQMFMACS0059UUC)', () => { - it('extension module exports expected symbols', () => { - // If the icons import in index.ts fails, the entire module won't load. - // These exports verify the module loaded successfully. - expect(STAGE_MAP).toBeDefined(); - expect(typeof STAGE_MAP).toBe('object'); - expect(STAGE_MAP.idea).toBe('idea'); - expect(typeof createWorklogBrowseExtension).toBe('function'); - expect(typeof getIconPrefix).toBe('function'); - }); - - it('getIconPrefix uses icon functions without errors', () => { - // getIconPrefix internally calls priorityIcon, statusIcon, stageIcon, - // auditIcon, epicIcon, iconsEnabled, riskIcon, effortIcon — all imported - // via the icons.js path. If the import resolves incorrectly, this will fail. - const mockItem = { - id: 'TEST-001', - title: 'Test item', - status: 'open', - stage: 'idea', - priority: 'high', - }; - const result = getIconPrefix(mockItem as any, false); - expect(result).toBeDefined(); - expect(typeof result).toBe('string'); - // Should contain icons (emoji) or text fallbacks - expect(result.length).toBeGreaterThan(0); - }); -}); diff --git a/packages/tui/tests/runWl-init-detection.test.ts b/packages/tui/tests/runWl-init-detection.test.ts deleted file mode 100644 index 1aa675afc..000000000 --- a/packages/tui/tests/runWl-init-detection.test.ts +++ /dev/null @@ -1,603 +0,0 @@ -vi.mock('@earendil-works/pi-coding-agent', () => ({ - getAgentDir: () => '/home/test-user/.pi/agent', -})); - -/** - - * Unit and integration tests for runWl initialization error detection. - - * - - * These tests verify that: - - * 1. runWl detects the known "not initialized" pattern in CLI stderr and - - * surfaces a friendly, actionable message - - * 2. Unrelated CLI errors pass through unchanged (no false positives) - - * 3. runBrowseFlow shows the friendly TUI notification when runWl encounters - - * the initialization error - - * - - * 4. The detection also works when the init error arrives via stdout (JSON mode), - - * not just stderr (non-JSON mode) - - * 5. The original error text is preserved for debugging (via Error.cause) - - * - - * Run: npx vitest run packages/tui/tests/runWl-init-detection.test.ts - - * - - * Run: npx vitest run packages/tui/tests/runWl-init-detection.test.ts - - */ - - - -import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; - - -// ── Module-level mocks ────────────────────────────────────────────────── -// Mock child_process.execFile so we can simulate CLI error output without -// requiring a real .worklog directory or installed worklog CLI. -// -// The mock instance lives in the shared globalThis store installed by -// tests/setup-tests.ts (which registers both `child_process` and -// `node:child_process` to the same instances). tools.ts resolves execFile -// lazily (promisify inside runWl), so the mock is observable regardless of -// module-load order. -const mockExecFile = vi.hoisted(() => { - const store = (globalThis as any).__sharedChildProcessMocks; - return store?.mockExecFile ?? vi.fn(); -}); - -// ── Imports (resolved after mock is installed) ────────────────────────── - -import { createDefaultListWorkItems, createWorklogBrowseExtension } from '../extensions/Worklog/index.js'; - -// ── Helpers ───────────────────────────────────────────────────────────── - -/** - * Simulate a callback-based execFile failure. - * - * The real execFile(file, args, options, callback) invokes callback(err, result) - * on completion. promisify(execFile) wraps this so calling execFileAsync() returns - * a Promise that rejects when the callback is called with an error. - */ -function mockExecFailure(errorProps: Record<string, unknown>): void { - mockExecFile.mockImplementationOnce( - (_binary: string, _args: string[], _options: object, callback: (err: Error | null) => void) => { - const err = Object.assign(new Error('Command failed'), errorProps); - callback(err); - }, - ); -} - -/** - * Simulate a successful execFile call returning stdout. - */ -function mockExecSuccess(stdout: string): void { - mockExecFile.mockImplementationOnce( - ( - _binary: string, - _args: string[], - _options: object, - callback: (err: Error | null, result: { stdout: string }) => void, - ) => { - callback(null, { stdout }); - }, - ); -} - -// ── Tests ─────────────────────────────────────────────────────────────── - -describe('runWl initialization error detection (unit)', () => { - beforeEach(() => { - mockExecFile.mockReset(); - // Restore real execFile default so other files sharing the store - // (e.g. tests/cli/mock-timeout.test.ts) keep real CLI behavior. - const realExecFile = (globalThis as any).__sharedChildProcessMocks?.realExecFile; - if (realExecFile) { - mockExecFile.mockImplementation(realExecFile); - } - }); - - describe('detecting known not-initialized pattern', () => { - it('transforms the known init-error stderr into a friendly message (wl not found, worklog fails)', async () => { - // First binary (wl) fails with ENOENT — runWl continues to next binary - mockExecFailure({ code: 'ENOENT' }); - // Second binary (worklog) fails with the known init pattern - mockExecFailure({ - stderr: - 'worklog: not initialized in this checkout/worktree. Run "wl init" to set up this location.', - }); - - const listItems = createDefaultListWorkItems(); - await expect(listItems()).rejects.toThrow( - 'Worklog is not initialized in this checkout/worktree. Run "wl init" to set up this location.', - ); - }); - - it('transforms the known init-error stderr when only worklog binary is tried (wl skipped)', async () => { - // Only one call — worklog binary fails with init error - mockExecFailure({ - stderr: - 'worklog: not initialized in this checkout/worktree. Run "wl init" to set up this location.', - }); - - const listItems = createDefaultListWorkItems(); - await expect(listItems()).rejects.toThrow( - 'Worklog is not initialized in this checkout/worktree.', - ); - }); - - it('handles the error with different character casing (case-insensitive)', async () => { - mockExecFailure({ - stderr: - 'Worklog: Not Initialized in this checkout/worktree. Run "wl init" to set up this location.', - }); - - const listItems = createDefaultListWorkItems(); - await expect(listItems()).rejects.toThrow( - 'Worklog is not initialized', - ); - }); - }); - - describe('pass-through for unrelated CLI errors', () => { - it('passes through unrelated CLI errors unchanged', async () => { - mockExecFailure({ - stderr: 'wl: unknown command. Use --help to see available commands.', - }); - - const listItems = createDefaultListWorkItems(); - await expect(listItems()).rejects.toThrow( - 'wl: unknown command. Use --help to see available commands.', - ); - }); - - it('passes through missing .worklog directory errors unchanged when pattern does not match', async () => { - // A different error about .worklog that is NOT the known init pattern - mockExecFailure({ - stderr: '.worklog not found in current directory tree.', - }); - - const listItems = createDefaultListWorkItems(); - await expect(listItems()).rejects.toThrow( - '.worklog not found in current directory tree.', - ); - }); - - it('passes through JSON parsing errors unchanged', async () => { - mockExecFailure({ - stderr: 'Error: Failed to parse JSON output', - }); - - const listItems = createDefaultListWorkItems(); - await expect(listItems()).rejects.toThrow( - 'Error: Failed to parse JSON output', - ); - }); - - it('passes through stderr with binary name mismatch errors unchanged', async () => { - mockExecFailure({ - stderr: 'wl sync: cannot find remote branch', - }); - - const listItems = createDefaultListWorkItems(); - await expect(listItems()).rejects.toThrow( - 'wl sync: cannot find remote branch', - ); - }); - - it('passes through errors where stderr is absent and falls back to message', async () => { - mockExecFailure({ - stderr: '', - message: 'generic error without stderr', - }); - - const listItems = createDefaultListWorkItems(); - await expect(listItems()).rejects.toThrow( - 'generic error without stderr', - ); - }); - - it('passes through errors with only non-matching stderr content', async () => { - mockExecFailure({ - stderr: 'TypeError: Cannot read properties of undefined', - }); - - const listItems = createDefaultListWorkItems(); - await expect(listItems()).rejects.toThrow( - 'TypeError: Cannot read properties of undefined', - ); - }); - }); - - describe('detection via stdout (JSON mode)', () => { - it('detects the init error when it arrives via stdout (JSON mode, --json flag)', async () => { - // Simulate error where stderr is empty and error is in stdout (JSON mode) - mockExecFailure({ - stderr: '', - stdout: JSON.stringify({ - success: false, - initialized: false, - error: 'Worklog system is not initialized. Run "worklog init" first.', - }), - }); - - const listItems = createDefaultListWorkItems(); - await expect(listItems()).rejects.toThrow( - 'Worklog is not initialized in this checkout/worktree. Run "wl init" to set up this location.', - ); - }); - - it('preserves the original error text in Error.cause for debugging', async () => { - const originalStdout = JSON.stringify({ - success: false, - initialized: false, - error: 'Worklog system is not initialized. Run "worklog init" first.', - }); - - mockExecFailure({ - stderr: '', - stdout: originalStdout, - }); - - const listItems = createDefaultListWorkItems(); - try { - await listItems(); - expect.unreachable('should have thrown'); - } catch (err: any) { - expect(err.cause).toBeDefined(); - expect(err.cause.stdout).toBe(originalStdout); - } - }); - - it('passes through unrelated JSON errors in stdout unchanged (no false positive)', async () => { - mockExecFailure({ - stderr: '', - stdout: JSON.stringify({ - success: false, - error: 'Some unrelated JSON error', - }), - }); - - const listItems = createDefaultListWorkItems(); - await expect(listItems()).rejects.toThrow( - 'Some unrelated JSON error', - ); - }); - - it('passes through stdout with only non-matching JSON error', async () => { - mockExecFailure({ - stderr: '', - stdout: JSON.stringify({ success: false, error: 'Unknown work item ID' }), - }); - - const listItems = createDefaultListWorkItems(); - await expect(listItems()).rejects.toThrow( - 'Unknown work item ID', - ); - }); - - it('detects the CLI non-JSON stderr format (Error: Worklog system is not initialized.)', async () => { - mockExecFailure({ - stderr: 'Error: Worklog system is not initialized.\nRun "worklog init" to initialize the system.', - }); - - const listItems = createDefaultListWorkItems(); - await expect(listItems()).rejects.toThrow( - 'Worklog is not initialized in this checkout/worktree. Run "wl init" to set up this location.', - ); - }); - }); - - describe('edge cases', () => { - it('passes through when both binaries are not found (ENOENT for both)', async () => { - mockExecFailure({ code: 'ENOENT' }); - mockExecFailure({ code: 'ENOENT' }); - - const listItems = createDefaultListWorkItems(); - await expect(listItems()).rejects.toThrow( - /Unable to execute wl\/worklog CLI/, - ); - }); - - it('passes --root-only to mandatory-subset wl list queries (WL-0MS964SIA0057ABR)', async () => { - // Success responses for: next, critical list, completed/in_review list. - mockExecSuccess(JSON.stringify({ results: [] })); - mockExecSuccess(JSON.stringify({ workItems: [] })); - mockExecSuccess(JSON.stringify({ workItems: [] })); - - const listItems = createDefaultListWorkItems(); - const items = await listItems(); - expect(items).toEqual([]); - - // Every wl list invocation for the mandatory subsets must carry - // --root-only so child items never appear in the top-level list. - const listCalls = mockExecFile.mock.calls - .map((c: any) => c[1]) - .filter((args: string[]) => args[0] === 'list'); - expect(listCalls.length).toBeGreaterThanOrEqual(2); - for (const args of listCalls) { - expect(args).toContain('--root-only'); - } - }); - }); -}); - -describe('stdout / JSON mode detection (stdout fallback)', () => { - beforeEach(() => { - mockExecFile.mockReset(); - // Restore real execFile default so other files sharing the store - // (e.g. tests/cli/mock-timeout.test.ts) keep real CLI behavior. - const realExecFile = (globalThis as any).__sharedChildProcessMocks?.realExecFile; - if (realExecFile) { - mockExecFile.mockImplementation(realExecFile); - } - }); - - it('detects init error when it arrives via stdout (JSON mode)', async () => { - // Simulate the CLI's JSON-mode output: error goes to stdout - mockExecFailure({ - stdout: JSON.stringify({ - success: false, - initialized: false, - error: 'Worklog system is not initialized. Run "worklog init" first.', - }, null, 2), - stderr: '', - }); - - const listItems = createDefaultListWorkItems(); - await expect(listItems()).rejects.toThrow( - 'Worklog is not initialized in this checkout/worktree. Run "wl init" to set up this location.', - ); - }); - - it('passes through unrelated JSON errors when they arrive via stdout', async () => { - mockExecFailure({ - stdout: JSON.stringify({ - success: false, - error: 'Some other error message entirely.', - }), - stderr: '', - }); - - const listItems = createDefaultListWorkItems(); - await expect(listItems()).rejects.toThrow( - 'Some other error message entirely.', - ); - }); - - it('passes through unrelated stdout when first binary ENOENT, second returns empty JSON', async () => { - mockExecFailure({ code: 'ENOENT' }); - mockExecFailure({ - stdout: '{}', - stderr: '', - }); - - const listItems = createDefaultListWorkItems(); - // The worklog binary ran but returned `{}` — this is passed through as-is - // since it doesn't contain the not-initialized pattern. - await expect(listItems()).rejects.toThrow('{}'); - }); - - it('handles stdout-only error with non-JSON known pattern (edge case)', async () => { - // Simulate a scenario where the known init message somehow lands in stdout - // without stdout being valid JSON (unlikely but should be handled) - mockExecFailure({ - stdout: 'worklog: not initialized in this checkout/worktree. Run "wl init" to set up this location.', - stderr: '', - }); - - const listItems = createDefaultListWorkItems(); - await expect(listItems()).rejects.toThrow( - 'Worklog is not initialized in this checkout/worktree. Run "wl init" to set up this location.', - ); - }); -}); - -describe('runBrowseFlow notification path (integration)', () => { - beforeEach(() => { - mockExecFile.mockReset(); - // Restore real execFile default so other files sharing the store - // (e.g. tests/cli/mock-timeout.test.ts) keep real CLI behavior. - const realExecFile = (globalThis as any).__sharedChildProcessMocks?.realExecFile; - if (realExecFile) { - mockExecFile.mockImplementation(realExecFile); - } - }); - - it('shows the friendly notification when runWl encounters the initialization error', async () => { - // Both binaries fail for each runWl invocation. - // runBrowseFlow calls fetchTotalActionableCount first (2 calls), - // then listWorkItems in the while loop (2 more calls via fallback path). - mockExecFailure({ code: 'ENOENT' }); - mockExecFailure({ - stderr: - 'worklog: not initialized in this checkout/worktree. Run "wl init" to set up this location.', - }); - // Second invocation (listWorkItems in the while loop) - mockExecFailure({ code: 'ENOENT' }); - mockExecFailure({ - stderr: - 'worklog: not initialized in this checkout/worktree. Run "wl init" to set up this location.', - }); - - const notify = vi.fn(); - const registerCommand = vi.fn(); - const registerShortcut = vi.fn(); - const on = vi.fn(); - - // Create extension instance and register with mock Pi API - const ext = createWorklogBrowseExtension(); - ext({ - registerCommand, - registerShortcut, - on, - } as any); - - // Find the registered /wl command handler - const wlCommand = registerCommand.mock.calls.find( - (call: [string]) => call[0] === 'wl', - ); - expect(wlCommand).toBeDefined(); - const handler = wlCommand[1].handler; - - // Invoke the handler with a mock context - await handler('', { ui: { notify } }); - - // Should show the friendly notification - expect(notify).toHaveBeenCalledTimes(1); - expect(notify).toHaveBeenCalledWith( - expect.stringContaining('Worklog is not initialized'), - 'error', - ); - }); - - it('shows raw error text for unrelated CLI errors (no false positive)', async () => { - // fetchTotalActionableCount consumes 1 mock (non-ENOENT error from 'wl', throws immediately) - mockExecFailure({ stderr: 'wl: unknown command' }); - // listWorkItems (fallback path) needs its own mock - mockExecFailure({ stderr: 'wl: unknown command' }); - - const notify = vi.fn(); - const registerCommand = vi.fn(); - const registerShortcut = vi.fn(); - const on = vi.fn(); - - const ext = createWorklogBrowseExtension(); - ext({ registerCommand, registerShortcut, on } as any); - - const wlCommand = registerCommand.mock.calls.find( - (call: [string]) => call[0] === 'wl', - ); - const handler = wlCommand[1].handler; - - await handler('', { ui: { notify } }); - - expect(notify).toHaveBeenCalledTimes(1); - expect(notify).toHaveBeenCalledWith( - expect.stringContaining('wl: unknown command'), - 'error', - ); - }); - - it('shows the friendly notification when init error arrives via stdout (JSON mode)', async () => { - const initErrorPayload = { - success: false, - initialized: false, - error: 'Worklog system is not initialized. Run "worklog init" first.', - }; - // fetchTotalActionableCount consumes 1 mock (non-ENOENT error containing init pattern) - mockExecFailure({ - stderr: '', - stdout: JSON.stringify(initErrorPayload), - }); - // listWorkItems (fallback path) needs its own mock - mockExecFailure({ - stderr: '', - stdout: JSON.stringify(initErrorPayload), - }); - - const notify = vi.fn(); - const registerCommand = vi.fn(); - const registerShortcut = vi.fn(); - const on = vi.fn(); - - const ext = createWorklogBrowseExtension(); - ext({ registerCommand, registerShortcut, on } as any); - - const wlCommand = registerCommand.mock.calls.find( - (call: [string]) => call[0] === 'wl', - ); - const handler = wlCommand[1].handler; - - await handler('', { ui: { notify } }); - - expect(notify).toHaveBeenCalledTimes(1); - expect(notify).toHaveBeenCalledWith( - expect.stringContaining('Worklog is not initialized'), - 'error', - ); - }); - - it('shows raw error text for unrelated JSON errors in stdout (no false positive)', async () => { - // fetchTotalActionableCount consumes 1 mock - mockExecFailure({ - stderr: '', - stdout: JSON.stringify({ success: false, error: 'Unknown work item ID' }), - }); - // listWorkItems (fallback path) needs its own mock - mockExecFailure({ - stderr: '', - stdout: JSON.stringify({ success: false, error: 'Unknown work item ID' }), - }); - - const notify = vi.fn(); - const registerCommand = vi.fn(); - const registerShortcut = vi.fn(); - const on = vi.fn(); - - const ext = createWorklogBrowseExtension(); - ext({ registerCommand, registerShortcut, on } as any); - - const wlCommand = registerCommand.mock.calls.find( - (call: [string]) => call[0] === 'wl', - ); - const handler = wlCommand[1].handler; - - await handler('', { ui: { notify } }); - - expect(notify).toHaveBeenCalledTimes(1); - expect(notify).toHaveBeenCalledWith( - expect.stringContaining('Unknown work item ID'), - 'error', - ); - }); - - it('does not crash the TUI when the extension is run in an initialized checkout', async () => { - // First mock: fetchTotalActionableCount calls wl list --status open,in-progress,blocked - mockExecSuccess(JSON.stringify({ count: 10 })); - - // Second mock: listWorkItems calls wl next - const validOutput = JSON.stringify({ - results: [{ workItem: { id: 'WL-001', title: 'Test', status: 'open' } }], - }); - mockExecSuccess(validOutput); - - // Third/fourth mocks: listWorkItems also fetches the mandatory subsets - // (wl list --priority critical, wl list --status completed --stage in_review) - mockExecSuccess(JSON.stringify({ workItems: [] })); - mockExecSuccess(JSON.stringify({ workItems: [] })); - - const notify = vi.fn(); - const registerCommand = vi.fn(); - const registerShortcut = vi.fn(); - const on = vi.fn(); - const setWidget = vi.fn(); - - const ext = createWorklogBrowseExtension(); - ext({ registerCommand, registerShortcut, on } as any); - - const wlCommand = registerCommand.mock.calls.find( - (call: [string]) => call[0] === 'wl', - ); - const handler = wlCommand[1].handler; - - await handler('', { ui: { notify, setWidget, custom: vi.fn(), setEditorText: vi.fn() } }); - - // Should NOT show any error notification - const errorNotifications = notify.mock.calls.filter( - (call: [string, string]) => call[1] === 'error', - ); - expect(errorNotifications).toHaveLength(0); - }); -}); diff --git a/packages/tui/tests/worklog-widgets.test.ts b/packages/tui/tests/worklog-widgets.test.ts deleted file mode 100644 index f34753f2b..000000000 --- a/packages/tui/tests/worklog-widgets.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -/** - * Unit tests for worklog widget helper functions. - * - * Run: npx vitest run packages/tui/tests/worklog-widgets.test.ts - */ - -import { describe, it, expect } from 'vitest'; - -// Import the widget helper functions -import { - buildWorklogWidgetLines, - buildWorklogDetailsLines, - getStatusIcon, - truncate, - stageColourToken, - applyStageColour, - type WorkItem, - type PiTheme, -} from '../extensions/Worklog/worklog-helpers.js'; - -const mockItems = [ - { - id: 'WL-001', - title: 'Implement chat pane', - status: 'in_progress', - priority: 'high', - assignee: 'alice', - stage: 'in_progress', - issueType: 'feature', - description: 'Build the chat pane UI component', - }, - { - id: 'WL-002', - title: 'Fix bug in action palette', - status: 'open', - priority: 'medium', - assignee: 'bob', - issueType: 'bug', - description: 'The action palette crashes when there are no items', - }, - { - id: 'WL-003', - title: 'Update documentation', - status: 'open', - priority: 'low', - issueType: 'task', - description: '', - }, -]; - -describe('buildWorklogWidgetLines', () => { - it('returns a no-items message when given an empty array', () => { - const lines = buildWorklogWidgetLines(80, [], 0); - expect(lines.length).toBeGreaterThan(0); - expect(lines.join('\n')).toContain('No work items'); - }); - - it('renders items with numbered indices', () => { - const lines = buildWorklogWidgetLines(80, mockItems, 0); - expect(lines.some(l => l.includes('1:'))).toBe(true); - expect(lines.some(l => l.includes('2:'))).toBe(true); - expect(lines.some(l => l.includes('3:'))).toBe(true); - }); - - it('marks the selected item with a pointer', () => { - const lines = buildWorklogWidgetLines(80, mockItems, 1); - const selectedIndexLine = lines.find(l => l.includes('2:')); - expect(selectedIndexLine).toBeDefined(); - expect(selectedIndexLine).toContain('▸'); - // Non-selected items should not have the pointer - const nonSelectedLine = lines.find(l => l.includes('1:') && !l.includes('▸')); - expect(nonSelectedLine).toBeDefined(); - }); - - it('includes status icons', () => { - const lines = buildWorklogWidgetLines(80, mockItems, 0); - const joined = lines.join('\n'); - expect(joined).toContain('🔄'); // in_progress - expect(joined).toContain('🔓'); // open - }); - - it('truncates long titles', () => { - const longItem = { - ...mockItems[0], - title: 'This is an extremely long work item title that should be truncated to fit the available width of the terminal', - }; - const lines = buildWorklogWidgetLines(40, [longItem], 0); - const titleLine = lines.find(l => l.includes('1:')); - expect(titleLine).toBeDefined(); - expect(titleLine!.length).toBeLessThanOrEqual(40); - expect(titleLine).toContain('...'); - }); - - it('limits display to 9 items with a "more" note', () => { - const manyItems = Array.from({ length: 15 }, (_, i) => ({ - ...mockItems[0], - id: `WL-${i + 1}`, - title: `Item ${i + 1}`, - })); - const lines = buildWorklogWidgetLines(80, manyItems, 0); - // Should have header + 9 items + "more" note - expect(lines.length).toBeLessThanOrEqual(12); - expect(lines.some(l => l.includes('more'))).toBe(true); - }); - - it('handles narrow width constraints by truncating titles', () => { - const lines = buildWorklogWidgetLines(30, mockItems, 0); - // Item lines (not header) should be truncated to fit - const itemLines = lines.filter(l => l.match(/^\s+\d:/)); - for (const line of itemLines) { - expect(line.length).toBeLessThanOrEqual(30); - } - }); -}); - -describe('buildWorklogDetailsLines', () => { - it('returns a no-selection message when given null', () => { - const lines = buildWorklogDetailsLines(80, null as any); - expect(lines.some(l => l.includes('No item selected'))).toBe(true); - }); - - it('renders item id, title, status, and priority', () => { - const lines = buildWorklogDetailsLines(80, mockItems[0]); - const joined = lines.join('\n'); - expect(joined).toContain('WL-001'); - expect(joined).toContain('Implement chat pane'); - expect(joined).toContain('in_progress'); - expect(joined).toContain('high'); - }); - - it('includes optional fields when present', () => { - const lines = buildWorklogDetailsLines(80, mockItems[0]); - const joined = lines.join('\n'); - expect(joined).toContain('alice'); - expect(joined).toContain('feature'); - }); - - it('omits optional fields when not present', () => { - const lines = buildWorklogDetailsLines(80, mockItems[2]); - const joined = lines.join('\n'); - expect(joined).not.toContain('Assignee:'); - expect(joined).not.toContain('Stage:'); - }); - - it('includes description summary when present', () => { - const lines = buildWorklogDetailsLines(80, mockItems[0]); - expect(lines.some(l => l.includes('Summary:'))).toBe(true); - }); - - it('truncates long descriptions', () => { - const longDescItem = { - ...mockItems[0], - description: 'A'.repeat(500), - }; - const lines = buildWorklogDetailsLines(40, longDescItem); - const summaryLine = lines.find(l => l.includes('Summary:')); - expect(summaryLine).toBeDefined(); - expect(summaryLine!.length).toBeLessThanOrEqual(40); - }); - - it('handles empty description gracefully', () => { - const lines = buildWorklogDetailsLines(80, mockItems[2]); - expect(lines.some(l => l.includes('Summary:'))).toBe(false); - }); -}); - -describe('getStatusIcon', () => { - it('returns a progress icon for in_progress', () => { - expect(getStatusIcon('in_progress')).toBe('🔄'); - }); - - it('returns a check icon for completed', () => { - expect(getStatusIcon('completed')).toBe('✔️'); - }); - - it('returns a blocked icon for blocked', () => { - expect(getStatusIcon('blocked')).toBe('⛔'); - }); - - it('returns a circle icon for unknown statuses', () => { - expect(getStatusIcon('unknown')).toBe('○'); - expect(getStatusIcon('open')).toBe('🔓'); - }); -}); - -describe('truncate', () => { - it('returns the original text when it fits', () => { - expect(truncate('short', 10)).toBe('short'); - }); - - it('truncates and adds ellipsis when text is too long', () => { - const result = truncate('hello world', 8); - expect(result).toBe('hello...'); - expect(result.length).toBe(8); - }); - - it('handles exact length match', () => { - expect(truncate('exact', 5)).toBe('exact'); - }); - - it('handles empty string', () => { - expect(truncate('', 10)).toBe(''); - }); -}); - -describe('stageColourToken', () => { - it('returns dim for idea stage', () => { - expect(stageColourToken('idea')).toBe('dim'); - }); - - it('returns mdLink for intake_complete stage (blue-like)', () => { - expect(stageColourToken('intake_complete')).toBe('mdLink'); - }); - - it('returns accent for plan_complete stage (cyan-like)', () => { - expect(stageColourToken('plan_complete')).toBe('accent'); - }); - - it('returns warning for in_progress stage', () => { - expect(stageColourToken('in_progress')).toBe('warning'); - }); - - it('returns success for in_review stage', () => { - expect(stageColourToken('in_review')).toBe('success'); - }); - - it('returns text for done stage', () => { - expect(stageColourToken('done')).toBe('text'); - }); - - it('returns dim for undefined stage', () => { - expect(stageColourToken(undefined)).toBe('dim'); - }); - - it('returns dim for empty stage', () => { - expect(stageColourToken('')).toBe('dim'); - }); - - it('returns dim for unknown stage', () => { - expect(stageColourToken('unknown')).toBe('dim'); - }); - - it('handles case-insensitive stage values', () => { - expect(stageColourToken('IN_PROGRESS')).toBe('warning'); - expect(stageColourToken('In_Progress')).toBe('warning'); - }); - - it('handles whitespace in stage values', () => { - expect(stageColourToken(' in_progress ')).toBe('warning'); - }); -}); - -describe('applyStageColour', () => { - const mockTheme: PiTheme = { - fg: (color, text) => `[${color}]${text}[/${color}]`, - bold: (text) => `**${text}**`, - }; - - it('returns plain text when no theme is provided', () => { - expect(applyStageColour('Test', 'in_progress', 'open', undefined)).toBe('Test'); - }); - - it('applies error colour for blocked status regardless of stage', () => { - const result = applyStageColour('Test Title', 'in_progress', 'blocked', mockTheme); - expect(result).toBe('[error]Test Title[/error]'); - }); - - it('applies dim colour for idea stage', () => { - const result = applyStageColour('Test Title', 'idea', 'open', mockTheme); - expect(result).toBe('[dim]Test Title[/dim]'); - }); - - it('applies mdLink colour (blue-like) for intake_complete stage', () => { - const result = applyStageColour('Test Title', 'intake_complete', 'open', mockTheme); - expect(result).toBe('[mdLink]Test Title[/mdLink]'); - }); - - it('applies accent colour (cyan-like) for plan_complete stage', () => { - const result = applyStageColour('Test Title', 'plan_complete', 'open', mockTheme); - expect(result).toBe('[accent]Test Title[/accent]'); - }); - - it('applies warning colour for in_progress stage', () => { - const result = applyStageColour('Test Title', 'in_progress', 'open', mockTheme); - expect(result).toBe('[warning]Test Title[/warning]'); - }); - - it('applies success colour for in_review stage', () => { - const result = applyStageColour('Test Title', 'in_review', 'open', mockTheme); - expect(result).toBe('[success]Test Title[/success]'); - }); - - it('applies text colour for done stage', () => { - const result = applyStageColour('Test Title', 'done', 'open', mockTheme); - expect(result).toBe('[text]Test Title[/text]'); - }); - - it('applies dim colour for undefined stage', () => { - const result = applyStageColour('Test Title', undefined, 'open', mockTheme); - expect(result).toBe('[dim]Test Title[/dim]'); - }); -}); diff --git a/scripts/install-herdr.sh b/scripts/install-herdr.sh new file mode 100755 index 000000000..173146a90 --- /dev/null +++ b/scripts/install-herdr.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# install-herdr.sh — idempotent Herdr integration installer for Worklog +# +# Links the `worklog-selection-list` Herdr plugin and registers the +# `prefix+l` keybinding in the Herdr config, so the worklist works out of +# the box after `npm run build` (invoked via the root `postbuild` hook). +# +# Safe to run on every build: +# - Re-linking the plugin is a no-op (herdr's own idempotence). +# - The keybinding block is inserted only when a binding for +# `worklog-selection-list.open-podcast-editor-tab` is not already present. +# - A legacy `open-worklist` prefix+l binding is migrated in-place to the +# new open-podcast-editor-tab action (never duplicates the key). +# - Missing herdr binary or an unwritable config only warn (exit 0), so +# `npm run build` never fails in CI/offline environments. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# Config resolution matches herdr itself: HERDR_CONFIG_PATH overrides, +# otherwise ~/.config/herdr/config.toml. +CONFIG_PATH="${HERDR_CONFIG_PATH:-${HOME}/.config/herdr/config.toml}" + +# The keybinding block to insert (only when the binding is absent). +KEYBINDING_BLOCK='[[keys.command]] +key = "prefix+l" +command = "herdr plugin action invoke worklog-selection-list.open-podcast-editor-tab" +description = "Open the Podcast Editing tab (Worklog work item selection pane)."' + +# Legacy binding command (v0.1.x) that prefix+l previously pointed at. +# Migrated in-place to the new action so re-running a build never leaves +# a stale duplicate keybinding. +LEGACY_BINDING='herdr plugin action invoke worklog-selection-list.open-worklist' +NEW_BINDING='herdr plugin action invoke worklog-selection-list.open-podcast-editor-tab' + +# ── 1. Link the plugin (no-op when already linked) ────────────────────── +if command -v herdr >/dev/null 2>&1; then + if herdr plugin link "${REPO_ROOT}/packages/herdr/herdr-plugin.toml"; then + echo "Linked herdr plugin: worklog-selection-list" + else + echo "Warning: 'herdr plugin link' failed (plugin may already be linked, or herdr is busy)." >&2 + fi +else + echo "Warning: 'herdr' not found on PATH — skipping plugin link (npm run build continues)." >&2 +fi + +# ── 2. Insert/replace the keybinding (idempotent) ────────────────────── +if grep -qF "${NEW_BINDING}" "${CONFIG_PATH}" 2>/dev/null; then + echo "herdr keybinding already present: ${CONFIG_PATH}" + exit 0 +fi + +# Migrate a legacy prefix+l binding (open-worklist) to the new action so +# the podcast editing tab name takes effect without a manual config edit. +if grep -qF "${LEGACY_BINDING}" "${CONFIG_PATH}" 2>/dev/null; then + if python3 - "${CONFIG_PATH}" <<'PY' +import sys +path = sys.argv[1] +with open(path) as f: + text = f.read() +new = "herdr plugin action invoke worklog-selection-list.open-podcast-editor-tab" +old = "herdr plugin action invoke worklog-selection-list.open-worklist" +if old in text: + with open(path, "w") as f: + f.write(text.replace(old, new)) +PY + then + echo "Migrated herdr keybinding (prefix+l -> worklog-selection-list.open-podcast-editor-tab) in ${CONFIG_PATH}" + exit 0 + else + echo "Warning: cannot migrate herdr config '${CONFIG_PATH}' — skipping keybinding update." >&2 + exit 0 + fi +fi + +if ! mkdir -p "$(dirname "${CONFIG_PATH}")"; then + echo "Warning: cannot create herdr config directory '$(dirname "${CONFIG_PATH}")' — skipping keybinding insert." >&2 + exit 0 +fi + +# Append a leading newline when the existing file does not end with one, +# so the block always starts on its own line. No other edits are made. +if [[ -s "${CONFIG_PATH}" ]] && [[ "$(tail -c 1 "${CONFIG_PATH}"; printf x)" != $'\n'x ]]; then + printf '\n' >> "${CONFIG_PATH}" || { + echo "Warning: cannot write herdr config '${CONFIG_PATH}' — skipping keybinding insert." >&2 + exit 0 + } +fi + +if ! printf '%s\n' "${KEYBINDING_BLOCK}" >> "${CONFIG_PATH}"; then + echo "Warning: cannot write herdr config '${CONFIG_PATH}' — skipping keybinding insert." >&2 + exit 0 +fi + +echo "Inserted herdr keybinding (prefix+l -> worklog-selection-list.open-podcast-editor-tab) into ${CONFIG_PATH}" diff --git a/scripts/wl-process-healthcheck.sh b/scripts/wl-process-healthcheck.sh new file mode 100755 index 000000000..d28b60409 --- /dev/null +++ b/scripts/wl-process-healthcheck.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# wl-process-healthcheck — detect wl process accumulation +# +# Lightweight, dependency-free watchdog that counts concurrent node +# `wl`/`worklog` CLI processes and alerts when the count exceeds a threshold +# for a sustained number of consecutive checks. Designed to run from cron / +# systemd timer every 5 minutes. +# +# Levels (see docs/dev/wl-process-spawning-investigation.md §6.2): +# OK count < watch-threshold (default 20) → exit 0 +# WATCH watch <= count <= alert-threshold, or count → exit 0 +# above the alert threshold but not yet sustained +# ALERT count > alert-threshold (default 50) for N → exit 2 +# consecutive checks (N defaults to 3) +# +# The current count is written to --count-file (default +# /tmp/wl-healthcheck-count) on OK and WATCH levels. On ALERT the process +# tree is emitted to stderr and the exit code is 2. +# +# Usage: +# wl-process-healthcheck.sh [options] +# +# Options: +# --alert-threshold N Alert above N concurrent wl processes (default 50) +# --watch-threshold N Watch at N concurrent wl processes (default 20) +# --sustained N Require N consecutive high readings (default 3) +# --count-file PATH File receiving the current count (default /tmp/wl-healthcheck-count) +# --ticks-file PATH File tracking consecutive high readings (default /tmp/wl-healthcheck-ticks) +# --json Emit machine-readable JSON to stdout +# -h, --help Show this help and exit +# +# Exit codes: 0 = OK/WATCH, 2 = ALERT (sustained high count). + +set -uo pipefail + +ALERT_THRESHOLD=50 +WATCH_THRESHOLD=20 +SUSTAINED_TICKS=3 +COUNT_FILE=/tmp/wl-healthcheck-count +TICKS_FILE=/tmp/wl-healthcheck-ticks +JSON_OUTPUT=0 +SELF_SCRIPT="$(basename "$0")" + +usage() { + cat <<'EOF' +Usage: wl-process-healthcheck.sh [options] + +Detect wl process accumulation. Counts concurrent node `wl`/`worklog` CLI +processes and alerts when the count exceeds a threshold for a sustained +number of consecutive checks. + +Options: + --alert-threshold N Alert above N concurrent wl processes (default 50) + --watch-threshold N Watch at N concurrent wl processes (default 20) + --sustained N Require N consecutive high readings (default 3) + --count-file PATH File receiving the current count (default /tmp/wl-healthcheck-count) + --ticks-file PATH File tracking consecutive high readings (default /tmp/wl-healthcheck-ticks) + --json Emit machine-readable JSON to stdout + -h, --help Show this help and exit + +Exit codes: 0 = OK/WATCH, 2 = ALERT (sustained high count) +EOF +} + +# Print the next positional arg after a flag, validating presence. +next_arg() { + if [[ $# -lt 2 ]]; then + echo "Error: $1 requires a value" >&2 + exit 1 + fi + printf '%s' "$2" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --alert-threshold) ALERT_THRESHOLD="$(next_arg "$@")"; shift 2 ;; + --watch-threshold) WATCH_THRESHOLD="$(next_arg "$@")"; shift 2 ;; + --sustained) SUSTAINED_TICKS="$(next_arg "$@")"; shift 2 ;; + --count-file) COUNT_FILE="$(next_arg "$@")"; shift 2 ;; + --ticks-file) TICKS_FILE="$(next_arg "$@")"; shift 2 ;; + --json) JSON_OUTPUT=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;; + esac +done + +# Atomically write a small file (avoids torn reads when cron runs overlap). +write_atomic() { + local file="$1" value="$2" + local tmp="${file}.tmp.$$" + printf '%s\n' "$value" > "$tmp" + mv "$tmp" "$file" +} + +# Count concurrent node wl/worklog CLI processes (ps-based, no external deps). +# Pattern matches: +# node /usr/local/bin/wl list --json (bin symlink) +# node /usr/local/bin/worklog sync (bin) +# node .../node_modules/worklog/dist/cli.js (direct node invocation) +count=$( + ps -eo args \ + | grep -E '^node .*/(wl|worklog)([ /]|$)' \ + | grep -v grep \ + | grep -v "$SELF_SCRIPT" \ + | wc -l +) || true +count=$(printf '%s' "$count" | tr -d ' ') +count=${count:-0} + +level=OK +ticks=0 + +if (( count > ALERT_THRESHOLD )); then + ticks=$(cat "$TICKS_FILE" 2>/dev/null || echo 0) + ticks=$((ticks + 1)) + write_atomic "$TICKS_FILE" "$ticks" + if (( ticks >= SUSTAINED_TICKS )); then + level=ALERT + else + level=WATCH + fi +elif (( count >= WATCH_THRESHOLD )); then + level=WATCH + rm -f "$TICKS_FILE" +else + level=OK + rm -f "$TICKS_FILE" +fi + +if [[ "$level" == "ALERT" ]]; then + { + echo "[ALERT] $count wl processes sustained for $SUSTAINED_TICKS checks (threshold: $ALERT_THRESHOLD)" + echo "PID PPID ELAPSED COMMAND" + ps -eo pid,ppid,etime,args | grep -E 'node .*/(wl|worklog)([ /]|$)' | grep -v grep || true + } >&2 + rm -f "$TICKS_FILE" + if (( JSON_OUTPUT )); then + printf '{"level":"ALERT","count":%d,"watch_threshold":%d,"alert_threshold":%d,"sustained":%d,"ticks":%d,"count_file":"%s","ticks_file":"%s","message":"[ALERT] %d wl processes sustained for %d checks"}\n' \ + "$count" "$WATCH_THRESHOLD" "$ALERT_THRESHOLD" "$SUSTAINED_TICKS" "$ticks" "$COUNT_FILE" "$TICKS_FILE" "$count" "$SUSTAINED_TICKS" + fi + exit 2 +fi + +write_atomic "$COUNT_FILE" "$count" + +if (( count > ALERT_THRESHOLD )); then + remaining=$((SUSTAINED_TICKS - ticks)) + msg="WATCH: $count wl processes (alert after $remaining more check(s), threshold: $ALERT_THRESHOLD)" +elif (( count >= WATCH_THRESHOLD )); then + msg="WATCH: $count wl processes (watch threshold: $WATCH_THRESHOLD, alert above $ALERT_THRESHOLD)" +else + msg="OK: $count wl processes" +fi + +if (( JSON_OUTPUT )); then + printf '{"level":"%s","count":%d,"watch_threshold":%d,"alert_threshold":%d,"sustained":%d,"ticks":%d,"count_file":"%s","ticks_file":"%s","message":"%s"}\n' \ + "$level" "$count" "$WATCH_THRESHOLD" "$ALERT_THRESHOLD" "$SUSTAINED_TICKS" "$ticks" "$COUNT_FILE" "$TICKS_FILE" "$msg" +else + echo "wl-process-healthcheck: $msg" +fi +exit 0 diff --git a/src/cli.ts b/src/cli.ts index bd113f4cb..9a4fa8431 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -28,8 +28,6 @@ import commentCommand from './commands/comment.js'; import closeCommand from './commands/close.js'; import recentCommand from './commands/recent.js'; import pluginsCommand from './commands/plugins.js'; -import tuiCommand from './commands/tui.js'; -import pimanCommand from './commands/piman.js'; import migrateCommand from './commands/migrate.js'; import depCommand from './commands/dep.js'; import reSortCommand from './commands/re-sort.js'; @@ -43,6 +41,8 @@ import completionCommand from './commands/completion.js'; import cleanupWorktreeCommand from './commands/cleanup-worktree.js'; import { detectWorktreeFromCwd, registerCurrentProcess } from './process-lifecycle.js'; import { applyWorklogDirOverrideFromArgv, setWorklogDirOverride } from './worklog-paths.js'; +import { ReadCacheCli, shouldCacheReadInvocation, extractCommandFromArgv } from './read-cache-cli.js'; +import { recordSpawn } from './spawn-counter.js'; // Watch flag parsing - supports -w, -wN, --watch, --watch=N function parseWatchFlag(argv: string[]) { @@ -244,6 +244,51 @@ applyWorklogDirOverrideFromArgv(process.argv.slice(2)); // Create shared plugin context const ctx = createPluginContext(program); +// ── Read-cache wiring (F2 — WL-0MSGAEC5N006W5QA) ───────────────────────── +// Serve repeat JSON read queries (list/next/show/search/status) from the +// shared on-disk cache so herdr panes / pi-agent polling don't re-query the +// SQLite DB for byte-identical invocations. Env-gated config: +// WL_CACHE_DISABLED=1 — bypass the cache entirely (baseline behaviour) +// WL_CACHE_TTL_MS=<n> — override the TTL safety net (tests/ops) +// WL_CACHE_DIR=<path> — override the cache dir (see read-cache.ts) +// WL_SPAWN_COUNT_FILE — spawn instrumentation (see spawn-counter.ts) +const readCacheEnabled = process.env.WL_CACHE_DISABLED !== '1'; +const _cacheTtl = parseInt(process.env.WL_CACHE_TTL_MS ?? '', 10); +const readCacheCli = new ReadCacheCli({ + ttlMs: Number.isFinite(_cacheTtl) && _cacheTtl > 0 ? _cacheTtl : undefined, +}); + +// Backfill: capture the JSON payload each command emits via output.json so +// the next identical invocation can be served from cache. Wrapped once; the +// wrapper is a no-op unless a cacheable read armed a backfill. +{ + const origJson = ctx.output.json; + ctx.output.json = (data: any) => { + if (readCacheEnabled) { + try { + readCacheCli.onJsonOutput(data); + } catch { + // The cache must never break the CLI. + } + } + origJson(data); + }; +} + +// Write commands (and non-cacheable reads, e.g. search --rebuild-index) +// invalidate the cache for the worklog dir before their action mutates the +// DB, so no stale result can be served after a write. +program.hook('preAction', (thisCommand, actionCommand) => { + if (!readCacheEnabled) return; + const command = actionCommand.name(); + if (shouldCacheReadInvocation(command, process.argv.slice(2))) return; + try { + readCacheCli.invalidateOnWrite(); + } catch { + // Best-effort. + } +}); + // If watch mode was requested we already handled spawning a watcher // earlier; commander should still expose the option on help, but the // watcher logic is implemented outside of the command registration so @@ -268,8 +313,6 @@ const builtInCommands = [ closeCommand, recentCommand, pluginsCommand, - tuiCommand, - pimanCommand, migrateCommand, depCommand, reSortCommand, @@ -302,8 +345,6 @@ const builtInCommandNames = new Set([ 'close', 'recent', 'plugins', - 'tui', - 'piman', 'migrate', 'dep', 're-sort', @@ -475,5 +516,32 @@ if (worktreePath) { registerCurrentProcess(worktreePath); } -// Parse command line arguments -program.parse(); +// Read-cache serve: for a cacheable read invocation, try the cache BEFORE +// commander dispatches. On a hit, print the cached payload (byte-identical +// to output.json formatting) and exit without ever calling `program.parse()` +// — so no action runs and no DB is opened. The write+callback exit guarantees +// piped stdout is flushed before the process terminates. When the cache is +// disabled (baseline), cacheable reads still record a work spawn so the +// spawn-reduction metric has a denominator. +let cacheServed = false; +const cacheArgv = process.argv.slice(2); +const cacheCommand = extractCommandFromArgv(cacheArgv); +if (cacheCommand !== null && shouldCacheReadInvocation(cacheCommand, cacheArgv)) { + if (readCacheEnabled) { + const { served, value } = readCacheCli.lookup(cacheCommand, cacheArgv); + if (served) { + cacheServed = true; + const text = `${JSON.stringify(value, null, 2)}\n`; + process.stdout.write(text, () => process.exit(0)); + // Safety net if the flush callback never fires. + setTimeout(() => process.exit(0), 2000).unref(); + } + } else { + recordSpawn('read-work'); // baseline: every read does DB work + } +} + +// Parse command line arguments (skipped entirely when served from cache). +if (!cacheServed) { + program.parse(); +} diff --git a/src/commands/grouping.ts b/src/commands/grouping.ts index ed964f53c..79d8cb59b 100644 --- a/src/commands/grouping.ts +++ b/src/commands/grouping.ts @@ -11,7 +11,7 @@ * ``` * **Key Files:** * - `src/commands/next.ts` - * - `packages/tui/extensions/lib/browse.ts` + * - `src/commands/list.ts` * ``` * * See docs/FILE_PATH_CONVENTION.md for the full specification. @@ -108,15 +108,16 @@ export function groupItemsByFilePaths( * Grouping rules (display order — most actionable first): * - Items with priority `critical` → partitioned by file-path conflicts using the * greedy first-fit algorithm, labeled `Critical Group 1..x`, placed first. - * - Non-critical items with stage `plan_complete` or `intake_complete` → partitioned - * by file-path conflicts using the greedy first-fit algorithm, labeled `Group 1..x`. + * - Non-critical items with stage `in_progress`, `plan_complete` or `intake_complete` + * → partitioned by file-path conflicts using the greedy first-fit algorithm, + * labeled `Group 1..x` (no stage prefix in the label). * - Items with stage `idea` → all placed in one group labeled "Idea" (no conflict checking). - * - All remaining non-critical items (unknown/other stages, `in_progress`, etc.) → all - * placed in a single group labeled "Other". + * - All remaining non-critical items (unknown/other/custom stages, `done`, etc.) → all + * placed in a single group labeled "Other" (safety net for unknown/custom stages). * - Items with stage `in_review` → all placed in one group labeled "In Review", placed last. * - * Within-group ordering (stage sub-sort `plan_complete` → `intake_complete` → remaining - * stages, then priority high → medium → low) is applied by callers via + * Within-group ordering (stage sub-sort `in_progress` → `plan_complete` → `intake_complete` + * → remaining stages, then priority high → medium → low) is applied by callers via * `compareGroupableItems` / `compareGroupedItems`. * * @param items - Array of items with id, priority, stage, and extracted file paths @@ -160,18 +161,20 @@ export function assignItemGroups( nextGroup += count; } - // 1. Group N — non-critical plan_complete + intake_complete items partitioned - // by file-path conflicts (no stage prefix in the label). - const planIntakeItems = items.filter( - item => item.priority !== 'critical' && (item.stage === 'plan_complete' || item.stage === 'intake_complete'), + // 1. Group N — non-critical in_progress + plan_complete + intake_complete items + // partitioned by file-path conflicts (no stage prefix in the label). + const groupNItems = items.filter( + item => + item.priority !== 'critical' && + (item.stage === 'in_progress' || item.stage === 'plan_complete' || item.stage === 'intake_complete'), ); - if (planIntakeItems.length > 0) { - const planIntakeGroups = groupItemsByFilePaths(planIntakeItems, maxFilePathGroups); - const { map: groupNumMap, count } = remapToSequential(planIntakeGroups, nextGroup); - const categoryGroupNumbers = [...new Set(planIntakeGroups.values())].sort((a, b) => a - b); + if (groupNItems.length > 0) { + const groupNGroups = groupItemsByFilePaths(groupNItems, maxFilePathGroups); + const { map: groupNumMap, count } = remapToSequential(groupNGroups, nextGroup); + const categoryGroupNumbers = [...new Set(groupNGroups.values())].sort((a, b) => a - b); const labelNumByFileGroup = new Map<number, number>(); categoryGroupNumbers.forEach((g, i) => labelNumByFileGroup.set(g, i + 1)); - for (const [id, g] of planIntakeGroups) { + for (const [id, g] of groupNGroups) { const groupNum = groupNumMap.get(g)!; result.set(id, { group: groupNum, groupLabel: `Group ${labelNumByFileGroup.get(g)}` }); } @@ -188,11 +191,12 @@ export function assignItemGroups( } // 3. Other — single group for all remaining non-critical items (unknown/other - // stages such as in_progress, done, undefined, ...). + // custom stages such as done, undefined, custom, ...). Safety net only. const otherItems = items.filter( item => item.priority !== 'critical' && item.stage !== 'in_review' && + item.stage !== 'in_progress' && item.stage !== 'plan_complete' && item.stage !== 'intake_complete' && item.stage !== 'idea', @@ -219,15 +223,17 @@ export function assignItemGroups( // ── Within-group ordering ──────────────────────────────────────────── /** - * Stage sub-order within a group: plan_complete first, then intake_complete, - * then all remaining stages. No headings are rendered between sub-groups. + * Stage sub-order within a group: in_progress first (actively being worked), + * then plan_complete, then intake_complete, then all remaining stages. + * No headings are rendered between sub-groups. */ const WITHIN_GROUP_STAGE_ORDER: Record<string, number> = { - plan_complete: 0, - intake_complete: 1, + in_progress: 0, + plan_complete: 1, + intake_complete: 2, }; -// Any stage not listed above (including undefined/empty) → 2 (remaining). -const REMAINING_STAGE_ORDER = 2; +// Any stage not listed above (including undefined/empty) → 3 (remaining). +const REMAINING_STAGE_ORDER = 3; /** * Priority order for within-group sorting: high → medium → low. @@ -242,8 +248,9 @@ const DEFAULT_PRIORITY_ORDER = WITHIN_GROUP_PRIORITY_ORDER.medium; /** * Compare two items for within-group display order: - * stage sub-sort (plan_complete → intake_complete → remaining stages), then - * priority (high → medium → low), then id as a deterministic tie-break. + * stage sub-sort (in_progress → plan_complete → intake_complete → remaining + * stages), then priority (high → medium → low), then id as a deterministic + * tie-break. */ export function compareGroupableItems(a: GroupableItem, b: GroupableItem): number { const stageA = WITHIN_GROUP_STAGE_ORDER[a.stage ?? ''] ?? REMAINING_STAGE_ORDER; diff --git a/src/commands/next.ts b/src/commands/next.ts index 922b26d06..f5c0c579e 100644 --- a/src/commands/next.ts +++ b/src/commands/next.ts @@ -9,6 +9,7 @@ import { normalizeActionArgs } from './cli-utils.js'; import { loadStatusStageRules } from '../status-stage-rules.js'; import { extractFilePaths, type GroupAssignment } from './helpers.js'; import { assignItemGroups, compareGroupedItems } from './grouping.js'; +import { invalidateCacheForWrite } from '../read-cache-cli.js'; export default function register(ctx: PluginContext): void { const { program, output, utils } = ctx; @@ -70,8 +71,19 @@ export default function register(ctx: PluginContext): void { } try { if (typeof (db as any).reSort === 'function') { - if (options.reSortSync) (db as any).reSort(recencyPolicy as 'prefer' | 'avoid' | 'ignore'); - else void Promise.resolve().then(() => (db as any).reSort(recencyPolicy as 'prefer' | 'avoid' | 'ignore')); + if (options.reSortSync) { + const result = (db as any).reSort(recencyPolicy as 'prefer' | 'avoid' | 'ignore'); + // The re-sort is a DB write: bump the read-cache state counter + // so cached `next` results reflect the re-sorted order. + if (result?.updated > 0) invalidateCacheForWrite(); + } else { + void Promise.resolve() + .then(() => (db as any).reSort(recencyPolicy as 'prefer' | 'avoid' | 'ignore')) + .then((result: any) => { + if (result?.updated > 0) invalidateCacheForWrite(); + }) + .catch(() => {}); + } } } catch (_e) {} } diff --git a/src/commands/piman.ts b/src/commands/piman.ts deleted file mode 100644 index 347164700..000000000 --- a/src/commands/piman.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Piman command - Pi-based TUI for work items. - * - * Launches the Pi coding agent's interactive TUI with ContextHub worklog - * extensions pre-loaded, providing a unified agent chat + work item management - * experience. The extensions auto-run the /wl browse flow on `session_start` - * when launched from this command (detected via the WL_PIMAN env var). - * - * Usage: - * wl piman # Launch Pi TUI → worklog browse flow - * wl piman --in-progress # forwarded via WL_PIMAN_IN_PROGRESS - * wl piman --all # forwarded via WL_PIMAN_ALL - */ - -import { spawn } from 'child_process'; -import { resolve, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import type { PluginContext } from '../plugin-types.js'; - -/** - * Resolve the path to a worklog extension file relative to this source file. - * At runtime the source is at <project>/dist/commands/piman.js, so we go up - * two levels to reach the project root, then into packages/tui/extensions/. - */ -function resolveExtension(extFile: string): string { - const currentDir = dirname(fileURLToPath(import.meta.url)); - // dist/commands/ -> dist/ -> project root - const projectRoot = resolve(currentDir, '..', '..'); - return resolve(projectRoot, 'packages', 'tui', 'extensions', extFile); -} - -export default function register(ctx: PluginContext): void { - const { program } = ctx; - - program - .command('piman') - .alias('pi') - .description('Pi-based TUI: browse and manage work items with agent chat, worklog commands, and keyboard-driven preview') - .option('--in-progress', 'Show only in-progress items') - .option('--all', 'Include completed/deleted items in the list') - .option('--prefix <prefix>', 'Override the default prefix') - .option('--perf', 'Enable performance instrumentation') - .action(async (options: PimanOptions) => { - const browseExt = resolveExtension('Worklog/index.ts'); - - const piArgs: string[] = [ - '-e', browseExt, - ]; - - if (options.perf) { - piArgs.push('--verbose'); - } - - // Signal the extension to auto-run /wl on session_start - const env: Record<string, string> = { ...process.env, WL_PIMAN: '1' }; - if (options.inProgress) env.WL_PIMAN_IN_PROGRESS = '1'; - if (options.all) env.WL_PIMAN_ALL = '1'; - if (options.prefix) env.WL_PIMAN_PREFIX = options.prefix; - - // Inherit stdio so Pi has direct terminal access for its TUI - const child = spawn('pi', piArgs, { - stdio: 'inherit', - env, - cwd: process.cwd(), - }); - - return new Promise<void>((resolvePromise, reject) => { - child.on('error', (err) => reject(new Error(`Failed to launch pi: ${err.message}`))); - child.on('exit', () => resolvePromise()); - }); - }); -} - -interface PimanOptions { - inProgress?: boolean; - all?: boolean; - prefix?: string; - perf?: boolean; -} diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 1b0252c98..e8a848392 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -19,6 +19,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { promisify } from 'util'; import { contextExec } from '../process-lifecycle.js'; +import { invalidateCacheForWrite } from '../read-cache-cli.js'; const execAsync = contextExec; @@ -405,6 +406,17 @@ export default function register(ctx: PluginContext): void { }), options.ifIdle ? { skipIfLocked: true } : undefined ); + + // Post-sync read-cache invalidation (F3 — WL-0MSGAEJQA005QG3W): a + // successful sync pulled and merged remote data into the DB. Bump the + // read-cache state counter so entries cached BEFORE or DURING the + // sync window are never served — the next identical read must return + // post-pull data (AC1). Only reached on success: skipped (lock busy) + // and failed syncs exit before this line and do not refresh the + // sync heartbeat either (AC3). + if (process.env.WL_CACHE_DISABLED !== '1') { + invalidateCacheForWrite(); + } } catch (error) { // Lock-aware guard: another sync holds the lock — skip gracefully // (exit 0, no error) so auto-sync spawners do not pile up waiting. diff --git a/src/commands/tui.ts b/src/commands/tui.ts deleted file mode 100644 index 48c51625d..000000000 --- a/src/commands/tui.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * TUI command - alias for `wl piman`. - * - * Launches the Pi coding agent's interactive TUI with ContextHub worklog - * extensions pre-loaded. This is identical to `wl piman` — the commands - * are aliases for each other. - * - * Usage: - * wl tui # Launch Pi TUI → worklog browse flow - * wl tui --in-progress # Show only in-progress items - * wl tui --all # Include completed/deleted items - * wl tui --prefix <p> # Override default prefix - * wl tui --perf # Enable performance instrumentation - */ - -import { spawn } from 'child_process'; -import { resolve, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import type { PluginContext } from '../plugin-types.js'; - -/** - * Resolve the path to a worklog extension file relative to this source file. - * At runtime the source is at <project>/dist/commands/tui.js, so we go up - * two levels to reach the project root, then into packages/tui/extensions/. - */ -function resolveExtension(extFile: string): string { - const currentDir = dirname(fileURLToPath(import.meta.url)); - // dist/commands/ -> dist/ -> project root - const projectRoot = resolve(currentDir, '..', '..'); - return resolve(projectRoot, 'packages', 'tui', 'extensions', extFile); -} - -export default function register(ctx: PluginContext): void { - const { program } = ctx; - - program - .command('tui') - .description('Pi-based TUI: browse and manage work items (alias for wl piman)') - .option('--in-progress', 'Show only in-progress items') - .option('--all', 'Include completed/deleted items in the list') - .option('--prefix <prefix>', 'Override the default prefix') - .option('--perf', 'Enable performance instrumentation') - .action(async (options: PimanOptions) => { - const browseExt = resolveExtension('Worklog/index.ts'); - - const piArgs: string[] = [ - '-e', browseExt, - ]; - - if (options.perf) { - piArgs.push('--verbose'); - } - - // Signal the extension to auto-run /wl on session_start - const env: Record<string, string> = { ...process.env, WL_PIMAN: '1' }; - if (options.inProgress) env.WL_PIMAN_IN_PROGRESS = '1'; - if (options.all) env.WL_PIMAN_ALL = '1'; - if (options.prefix) env.WL_PIMAN_PREFIX = options.prefix; - - // Inherit stdio so Pi has direct terminal access for its TUI - const child = spawn('pi', piArgs, { - stdio: 'inherit', - env, - cwd: process.cwd(), - }); - - return new Promise<void>((resolvePromise, reject) => { - child.on('error', (err) => reject(new Error(`Failed to launch pi: ${err.message}`))); - child.on('exit', () => resolvePromise()); - }); - }); -} - -interface PimanOptions { - inProgress?: boolean; - all?: boolean; - prefix?: string; - perf?: boolean; -} diff --git a/src/read-cache-cli.ts b/src/read-cache-cli.ts new file mode 100644 index 000000000..77a45cbb2 --- /dev/null +++ b/src/read-cache-cli.ts @@ -0,0 +1,224 @@ +/** + * wl CLI read-cache wiring (F2 — WL-0MSGAEC5N006W5QA). + * + * Routes `list`, `next`, `show`, `search`, `status` (JSON mode only) through + * the shared on-disk read cache so herdr panes / pi-agent polling don't + * re-query the SQLite DB for byte-identical invocations. + * + * Fingerprint model (see read-cache.ts): the SQLite files are rewritten by + * the app on every open/close (`journal_mode = WAL` + schema init touch the + * header), so file-based fingerprints are not stable across processes. The + * CLI therefore fingerprints DB state with a monotonic per-worklog-dir write + * counter in the cache dir: stable across reads, bumped on every write. + * + * Orchestration model (see src/cli.ts for the hook wiring): + * 1. preAction — for a cacheable read invocation, `lookup()` serves the + * cached payload (if fresh) or arms a backfill that records the state + * counter captured *before* the action reads the DB. + * 2. post-output — the JSON payload each command emits via `output.json` + * is routed through `onJsonOutput()`, which stores it keyed by + * (worklog-dir, argv) only if the state counter is unchanged since the + * lookup (a concurrent write would have bumped it). + * 3. write commands — `invalidateOnWrite()` bumps the state counter (and + * drops entry files), so no stale result can be served after a mutation. + * 4. read write-byproducts (`next`'s auto re-sort) — `invalidateCacheForWrite()` + * bumps the counter when the byproduct lands (see src/commands/next.ts). + * + * The TTL safety net bounds staleness from writes the counter misses (e.g. + * external tools writing the DB directly). + */ + +import { + ReadCache, + bumpStateCounter, + counterFingerprint, + fingerprintsEqual, + resolveCacheDir, + type DbFingerprint, +} from './read-cache.js'; +import { resolveWorklogDir } from './worklog-paths.js'; +import { recordSpawn } from './spawn-counter.js'; +import * as path from 'path'; + +/** Commands whose JSON output is a deterministic function of (DB state, argv). */ +export const READ_CACHE_COMMANDS: ReadonlySet<string> = new Set(['list', 'next', 'show', 'search', 'status']); + +/** + * Read-command flags that make an invocation non-cacheable: + * - `--rebuild-index` (search): rebuilds the FTS index — a write. + * - `--semantic` / `--semantic-only` (search): external embedding API, + * non-deterministic output. + */ +const NON_CACHEABLE_READ_FLAGS: ReadonlySet<string> = new Set(['--rebuild-index', '--semantic', '--semantic-only']); + +/** True when the command is one of the read commands covered by the cache. */ +export function isCacheableReadCommand(command: string): boolean { + return READ_CACHE_COMMANDS.has(command); +} + +/** True when the raw argv requests JSON output. */ +export function argvIsJsonMode(argv: string[]): boolean { + return argv.includes('--json'); +} + +/** True when the argv contains a flag that makes a read non-cacheable. */ +export function hasNonCacheableReadFlags(argv: string[]): boolean { + return argv.some((a) => NON_CACHEABLE_READ_FLAGS.has(a)); +} + +/** + * True when a (command, argv) invocation should be served from / backfilled + * into the read cache. Requires a covered command, JSON mode, and no + * write-byproduct / non-deterministic flags. + */ +export function shouldCacheReadInvocation(command: string, argv: string[]): boolean { + if (!isCacheableReadCommand(command)) return false; + if (!argvIsJsonMode(argv)) return false; + if (hasNonCacheableReadFlags(argv)) return false; + return true; +} + +/** Canonical argv for cache keys: process.argv minus the node + script prefix. */ +export function cacheKeyArgv(): string[] { + return process.argv.slice(2); +} + +/** Program-level options that take a value (skipped with their value). */ +const GLOBAL_VALUE_OPTS = new Set(['--worklog-dir', '--format', '-F']); + +/** + * Extract the subcommand name from raw argv, skipping program-level options + * and their values (`--json`, `--verbose`, `-F/--format <v>`, `--worklog-dir + * <v>`, help/version flags). Returns null when no command token is found + * (bare `wl`, `wl --json`, etc.). + */ +export function extractCommandFromArgv(argv: string[]): string | null { + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--json' || a === '--verbose' || a === '--version' || a === '-h' || a === '--help' || a === '-w' || a === '--watch') { + continue; + } + if (GLOBAL_VALUE_OPTS.has(a)) { + i++; // skip the option's value + continue; + } + if (a.startsWith('--format=') || a.startsWith('--worklog-dir=')) { + continue; + } + if (a.startsWith('-')) { + continue; // unknown/other flag — skip + } + return a; + } + return null; +} + +/** State captured when a read lookup arms a backfill. */ +interface BackfillInfo { + worklogDir: string; + argv: string[]; + fpBefore: DbFingerprint; +} + +/** + * Invalidate the read cache after a write that happens outside the normal + * write-command flow (e.g. `next`'s auto re-sort byproduct). Bumps the state + * counter so every previously cached entry for the worklog dir goes stale. + * Uses the env-resolved cache dir, so it is safe to call from command + * modules that never constructed a ReadCacheCli. + */ +export function invalidateCacheForWrite(): void { + try { + bumpStateCounter(resolveCacheDir(), resolveWorklogDir()); + } catch { + // Best-effort: the TTL safety net bounds any missed invalidation. + } +} + +/** + * Stateful cache orchestration for one CLI process: one shared cache, one + * armed backfill at a time (a CLI process runs exactly one command). + */ +export class ReadCacheCli { + private readonly cacheDir: string; + private readonly cache: ReadCache; + private backfill: BackfillInfo | null = null; + + constructor(options: { ttlMs?: number; maxEntries?: number; cacheDir?: string } = {}) { + this.cacheDir = options.cacheDir ? path.resolve(options.cacheDir) : resolveCacheDir(); + this.cache = new ReadCache({ + cacheDir: this.cacheDir, + ...(options.ttlMs !== undefined ? { ttlMs: options.ttlMs } : {}), + ...(options.maxEntries !== undefined ? { maxEntries: options.maxEntries } : {}), + fingerprint: (worklogDir) => counterFingerprint(this.cacheDir, worklogDir), + }); + } + + /** + * Attempt to serve a cacheable read from cache; otherwise arm a backfill. + * + * Returns `{ served: true, value }` when a fresh cached payload exists + * (the caller prints it and exits without any DB work), or + * `{ served: false }` otherwise (the command runs normally and its JSON + * output is routed through `onJsonOutput()` for backfill). + */ + lookup(command: string, argv: string[]): { served: boolean; value?: unknown } { + const worklogDir = resolveWorklogDir(); + if (!shouldCacheReadInvocation(command, argv)) { + return { served: false }; + } + const fpBefore = this.cache.fingerprintFor(worklogDir); + const cached = this.cache.get(worklogDir, argv); + if (cached !== null) { + recordSpawn('cache-hit'); + return { served: true, value: cached }; + } + recordSpawn('read-work'); + this.backfill = { worklogDir, argv, fpBefore }; + return { served: false }; + } + + /** + * Route a produced JSON payload into the cache. + * + * Called from the wrapped `output.json`. Caches only when a backfill was + * armed by `lookup()` AND the state counter is unchanged since then (a + * mid-action write — e.g. `next`'s re-sort — means the payload is not + * representative of a single stable DB state, so it is skipped). + */ + onJsonOutput(data: unknown): void { + const b = this.backfill; + this.backfill = null; + if (b === null || data === null || typeof data !== 'object') return; + const fpNow = this.cache.fingerprintFor(b.worklogDir); + if (!fingerprintsEqual(fpNow, b.fpBefore)) return; + try { + this.cache.set(b.worklogDir, b.argv, data, { dbFingerprint: b.fpBefore }); + } catch { + // The cache must never break the CLI. + } + } + + /** + * Drop every cached entry for the current worklog dir (after a write): + * bumps the state counter (freshness) and removes entry files (tidiness). + */ + invalidateOnWrite(): void { + const worklogDir = resolveWorklogDir(); + try { + bumpStateCounter(this.cacheDir, worklogDir); + } catch { + // Best-effort. + } + try { + this.cache.invalidate(worklogDir); + } catch { + // Best-effort. + } + } + + /** Hit/miss counters (spawn-reduction reporting). */ + stats(): { hits: number; misses: number } { + return this.cache.stats(); + } +} diff --git a/src/read-cache.ts b/src/read-cache.ts new file mode 100644 index 000000000..9fcf0a5db --- /dev/null +++ b/src/read-cache.ts @@ -0,0 +1,522 @@ +/** + * On-disk read cache for the wl CLI (option A — primary win from + * WL-0MSAZQEQB008O7H3). + * + * Pure-read commands (`list`, `next`, `show`, `search`, `status` in JSON + * mode) cache their JSON result keyed by (absolute worklog-dir, full argv, + * wl version) in the XDG cache dir (`~/.cache/wl/`). Freshness is WAL-aware: + * the cached entry stores a fingerprint (mtime+size) of `worklog.db`, + * `worklog.db-wal` and `worklog.db-shm`, and is invalidated when any of them + * change. A TTL (default 30s) acts as a bounding safety net, and the cache is + * bounded via LRU purge. + * + * Concurrency safety: entries are written atomically (temp file + rename) so + * concurrent readers/writers across processes never observe partial entries. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { createHash } from 'crypto'; +import { WORKLOG_VERSION } from './version.js'; + +// --------------------------------------------------------------------------- +// Constants / defaults +// --------------------------------------------------------------------------- + +/** Bounding safety net: entries older than this are never served (30s). */ +export const DEFAULT_TTL_MS = 30_000; + +/** LRU bound on the number of entry files kept in the cache dir. */ +export const DEFAULT_MAX_ENTRIES = 1_000; + +/** Entry files live in `<cache-dir>/<64-hex-sha256>.json`. */ +const ENTRY_FILE_RE = /^[0-9a-f]{64}\.json$/; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ReadCacheOptions { + /** Cache root dir override (default: XDG `~/.cache/wl`). Tests use this. */ + cacheDir?: string; + /** TTL in ms (default 30_000). */ + ttlMs?: number; + /** Max entry files before LRU purge (default 1_000). */ + maxEntries?: number; + /** Injectable clock for TTL/LRU tests (default `Date.now`). */ + now?: () => number; + /** Version string included in the cache key (default `WORKLOG_VERSION`). */ + version?: string; + /** + * Custom DB-state fingerprint provider (default: WAL-aware file snapshot). + * + * The wl CLI (F2) uses a stable counter-based fingerprint instead: the + * SQLite files are rewritten by the app on every open/close cycle + * (`journal_mode = WAL` + schema init touch the header), so file-based + * fingerprints are not stable across processes. The counter is monotonic + * per worklog dir and bumped on every write, so it is stable across reads + * yet changes exactly when the data does. + */ + fingerprint?: (worklogDir: string) => DbFingerprint; +} + +/** + * Snapshot of the DB state for a worklog dir. + * + * The file-pair fields capture the WAL-mode SQLite files (default provider; + * `[mtimeMs, size]` per file, `[0, 0]` when missing). The optional + * `stateCounter` field is the F2 CLI fingerprint: a monotonic per-worklog-dir + * write counter stored in the cache dir (see `bumpStateCounter`). + */ +export interface DbFingerprint { + /** worklog.db — rewritten on WAL checkpoint */ + db: [number, number]; + /** worklog.db-wal — grows/shrinks on every write commit */ + wal: [number, number]; + /** worklog.db-shm — shared-memory index, touched on connections */ + shm: [number, number]; + /** F2 CLI fingerprint: monotonic per-worklog-dir write counter. */ + stateCounter?: number; +} + +/** A single cache entry: header (key + freshness metadata) + cached value. */ +interface CacheEntry { + key: string; + worklogDir: string; + argv: string[]; + version: string; + dbFingerprint: DbFingerprint; + createdAt: number; + accessedAt: number; + value: unknown; +} + +// --------------------------------------------------------------------------- +// Path + key helpers +// --------------------------------------------------------------------------- + +/** The WAL-mode SQLite files that make up the DB state for a worklog dir. */ +export function dbFilesForWorklogDir(worklogDir: string): { db: string; wal: string; shm: string } { + return { + db: path.join(worklogDir, 'worklog.db'), + wal: path.join(worklogDir, 'worklog.db-wal'), + shm: path.join(worklogDir, 'worklog.db-shm'), + }; +} + +function statPair(p: string): [number, number] { + try { + const st = fs.statSync(p); + return [st.mtimeMs, st.size]; + } catch { + return [0, 0]; + } +} + +/** + * WAL-aware DB-state fingerprint for a worklog dir. + * + * `worklog.db` mtime alone is unreliable in WAL mode (writes land in the WAL, + * and the main file only changes on checkpoint), so we fingerprint all three + * files: db + `-wal` + `-shm`. A fingerprint mismatch on read invalidates the + * entry. Missing files fingerprint as zeros, so a DB that is later created + * (files appear) also invalidates. + */ +export function computeDbFingerprint(worklogDir: string): DbFingerprint { + const { db, wal, shm } = dbFilesForWorklogDir(worklogDir); + return { db: statPair(db), wal: statPair(wal), shm: statPair(shm) }; +} + +/** Structural equality for fingerprints. */ +export function fingerprintsEqual(a: DbFingerprint, b: DbFingerprint): boolean { + return ( + a.db[0] === b.db[0] && + a.db[1] === b.db[1] && + a.wal[0] === b.wal[0] && + a.wal[1] === b.wal[1] && + a.shm[0] === b.shm[0] && + a.shm[1] === b.shm[1] && + (a.stateCounter ?? 0) === (b.stateCounter ?? 0) + ); +} + +// --------------------------------------------------------------------------- +// F2 counter-based fingerprint (stable across process open/close churn) +// --------------------------------------------------------------------------- + +/** Path of the per-worklog-dir state counter file within a cache dir. */ +export function stateCounterFilePath(cacheDir: string, worklogDir: string): string { + const hash = createHash('sha256').update(path.resolve(worklogDir)).digest('hex'); + return path.join(cacheDir, 'state', `${hash}.json`); +} + +/** + * Read the monotonic write-counter for a worklog dir (0 when absent — a + * fresh cache dir means no writes have been observed, which is the safe + * baseline for entries written from the same counter value). + */ +export function readStateCounter(cacheDir: string, worklogDir: string): number { + try { + const raw = fs.readFileSync(stateCounterFilePath(cacheDir, worklogDir), 'utf-8'); + const parsed = JSON.parse(raw) as { state?: number }; + const state = Number(parsed?.state); + return Number.isFinite(state) && state >= 0 ? Math.floor(state) : 0; + } catch { + return 0; + } +} + +/** + * Atomically increment the per-worklog-dir write counter. Returns the new + * value. Concurrent increments may coalesce (both write N+1) but can never + * regress the counter, which is all freshness checking needs. + */ +export function bumpStateCounter(cacheDir: string, worklogDir: string): number { + const file = stateCounterFilePath(cacheDir, worklogDir); + const next = readStateCounter(cacheDir, worklogDir) + 1; + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`; + fs.writeFileSync(tmp, JSON.stringify({ state: next }), 'utf-8'); + fs.renameSync(tmp, file); + } catch (err) { + debugLog(`failed to bump state counter: ${(err as Error).message}`); + } + return next; +} + +/** + * The F2 CLI fingerprint: file fields are inert, `stateCounter` carries the + * per-worklog-dir write counter. + */ +export function counterFingerprint(cacheDir: string, worklogDir: string): DbFingerprint { + return { db: [0, 0], wal: [0, 0], shm: [0, 0], stateCounter: readStateCounter(cacheDir, worklogDir) }; +} + +/** + * Resolve the read-cache directory. + * + * Precedence: `WL_CACHE_DIR` (explicit override, e.g. tests) → XDG cache home + * (`$XDG_CACHE_HOME/wl`) → `~/.cache/wl`. + */ +export function resolveCacheDir(): string { + const override = process.env.WL_CACHE_DIR; + if (override) return path.resolve(override); + const xdg = process.env.XDG_CACHE_HOME; + const base = xdg && xdg.trim() !== '' ? path.resolve(xdg) : path.join(os.homedir(), '.cache'); + return path.join(base, 'wl'); +} + +/** + * Deterministic cache key: SHA-256 over (wl version, resolved worklog dir, + * argv elements in order). Includes the wl version so a binary upgrade with a + * changed output schema never serves stale-shaped entries. + */ +export function deriveCacheKey(worklogDir: string, argv: string[], version: string): string { + const hash = createHash('sha256'); + hash.update(version); + hash.update('\0'); + hash.update(path.resolve(worklogDir)); + for (const arg of argv) { + hash.update('\0'); + hash.update(arg); + } + return hash.digest('hex'); +} + +// --------------------------------------------------------------------------- +// ReadCache +// --------------------------------------------------------------------------- + +/** Emit a debug line to stderr when `WL_CACHE_DEBUG` is set. */ +function debugLog(...args: unknown[]): void { + if (process.env.WL_CACHE_DEBUG) { + process.stderr.write(`[wl:cache] ${args.map(String).join(' ')}\n`); + } +} + +export class ReadCache { + private readonly cacheDir: string; + private readonly ttlMs: number; + private readonly maxEntries: number; + private readonly now: () => number; + private readonly version: string; + private readonly fingerprint: (worklogDir: string) => DbFingerprint; + private hits = 0; + private misses = 0; + + constructor(options: ReadCacheOptions = {}) { + this.cacheDir = options.cacheDir ? path.resolve(options.cacheDir) : resolveCacheDir(); + this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; + this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + this.now = options.now ?? (() => Date.now()); + this.version = options.version ?? WORKLOG_VERSION; + this.fingerprint = options.fingerprint ?? computeDbFingerprint; + } + + getCacheDir(): string { + return this.cacheDir; + } + + /** DB-state fingerprint for a worklog dir under this cache's provider. */ + fingerprintFor(worklogDir: string): DbFingerprint { + return this.fingerprint(path.resolve(worklogDir)); + } + + /** Cache key for a (worklog-dir, argv) pair under this cache's version. */ + keyFor(worklogDir: string, argv: string[]): string { + return deriveCacheKey(worklogDir, argv, this.version); + } + + /** Hit/miss counters — used by spawn-reduction instrumentation (F2). */ + stats(): { hits: number; misses: number } { + return { hits: this.hits, misses: this.misses }; + } + + /** + * Read a cached value for (worklog-dir, argv). + * + * Returns the cached JSON value on a hit, or `null` on a miss. A miss + * happens when the entry is absent, corrupt, header-mismatched, TTL-expired, + * or when the WAL-aware DB fingerprint no longer matches (a write happened). + * Stale/corrupt entries are removed as a side effect. + */ + get(worklogDir: string, argv: string[]): unknown | null { + const dir = path.resolve(worklogDir); + const key = this.keyFor(dir, argv); + const entryPath = this.entryPath(key); + + let raw: string; + try { + raw = fs.readFileSync(entryPath, 'utf-8'); + } catch { + this.misses++; + return null; + } + + let entry: CacheEntry; + try { + entry = JSON.parse(raw) as CacheEntry; + } catch { + debugLog(`corrupt entry ${key}; removing`); + this.removeFile(entryPath); + this.misses++; + return null; + } + + // Header invariants: key, version, argv and worklog dir must all match. + if ( + entry.key !== key || + entry.version !== this.version || + !arraysEqual(entry.argv, argv) || + path.resolve(entry.worklogDir) !== dir + ) { + debugLog(`header mismatch for ${key}; removing`); + this.removeFile(entryPath); + this.misses++; + return null; + } + + const now = this.now(); + + // TTL bounding safety net. + if (now - entry.createdAt > this.ttlMs) { + debugLog(`ttl expired for ${key}; removing`); + this.removeFile(entryPath); + this.misses++; + return null; + } + + // WAL-aware freshness: any change to db/-wal/-shm invalidates. + const fp = this.fingerprintFor(dir); + if (!fingerprintsEqual(fp, entry.dbFingerprint)) { + debugLog(`db state changed for ${key}; removing`); + this.removeFile(entryPath); + this.misses++; + return null; + } + + // Hit. Refresh the access time (LRU ordering) without churning when the + // clock has not advanced (rapid repeat queries within the same ms). + this.hits++; + if (now !== entry.accessedAt) { + entry.accessedAt = now; + this.safeAtomicWrite(entryPath, entry); + } + return entry.value; + } + + /** + * Store a JSON value for (worklog-dir, argv). + * + * The DB fingerprint is captured at write time; a later DB change makes the + * entry stale on read. Writes are atomic (temp file + rename). + * + * Callers that read the DB to produce `value` may pass a fingerprint they + * captured BEFORE the read (options.dbFingerprint); the entry is then only + * consistent with that state. `set()` does not re-verify, so the caller + * should skip caching (or re-check) if the DB changed mid-query. + */ + set(worklogDir: string, argv: string[], value: unknown, options?: { dbFingerprint?: DbFingerprint }): void { + const dir = path.resolve(worklogDir); + const key = this.keyFor(dir, argv); + const now = this.now(); + const entry: CacheEntry = { + key, + worklogDir: dir, + argv: [...argv], + version: this.version, + dbFingerprint: options?.dbFingerprint ?? this.fingerprintFor(dir), + createdAt: now, + accessedAt: now, + value, + }; + fs.mkdirSync(this.cacheDir, { recursive: true }); + this.atomicWrite(this.entryPath(key), entry); + this.purgeIfNeeded(now); + } + + /** + * Remove every entry belonging to a worklog dir (used after writes/sync). + * Returns the number of entries removed. + */ + invalidate(worklogDir: string): number { + const dir = path.resolve(worklogDir); + let removed = 0; + for (const entryPath of this.listEntryPaths()) { + try { + const entry = JSON.parse(fs.readFileSync(entryPath, 'utf-8')) as CacheEntry; + if (path.resolve(entry.worklogDir) === dir && this.removeFile(entryPath)) removed++; + } catch { + // Unparseable entries are not attributed to a dir; leave for get()/purge. + } + } + return removed; + } + + /** Remove all cache entries. Returns the number removed. */ + clear(): number { + let removed = 0; + for (const entryPath of this.listEntryPaths()) { + if (this.removeFile(entryPath)) removed++; + } + return removed; + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + private entryPath(key: string): string { + return path.join(this.cacheDir, `${key}.json`); + } + + private listEntryPaths(): string[] { + try { + return fs + .readdirSync(this.cacheDir) + .filter((f) => ENTRY_FILE_RE.test(f)) + .map((f) => path.join(this.cacheDir, f)); + } catch { + return []; + } + } + + /** Atomic write: temp file + rename so readers never see partial data. */ + private atomicWrite(target: string, entry: CacheEntry): void { + const tmp = `${target}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`; + fs.writeFileSync(tmp, JSON.stringify(entry), 'utf-8'); + try { + fs.renameSync(tmp, target); + } catch (err: any) { + if (err && (err.code === 'EEXIST' || err.code === 'EPERM')) { + // Windows cannot rename over an existing file — unlink then retry. + try { + fs.unlinkSync(target); + } catch { + /* ignore */ + } + try { + fs.renameSync(tmp, target); + } catch (err2) { + this.removeFile(tmp); + throw err2; + } + } else { + this.removeFile(tmp); + throw err; + } + } + } + + /** Best-effort atomic write (used for LRU touch on hit — never fails reads). */ + private safeAtomicWrite(target: string, entry: CacheEntry): void { + try { + this.atomicWrite(target, entry); + } catch (err) { + debugLog(`failed to touch ${target}: ${(err as Error).message}`); + } + } + + /** Remove a file, ignoring missing-file errors. Returns true if removed. */ + private removeFile(p: string): boolean { + try { + fs.unlinkSync(p); + return true; + } catch (err: any) { + if (err && err.code === 'ENOENT') return false; + debugLog(`failed to remove ${p}: ${(err as Error).message}`); + return false; + } + } + + /** + * Keep the cache bounded: evict TTL-expired entries and, if still over the + * max-entry bound, evict least-recently-accessed entries. Only runs when the + * entry count exceeds `maxEntries`, so the header-parsing cost is bounded + * and rare. + */ + private purgeIfNeeded(now: number): void { + const entries = this.listEntryPaths(); + if (entries.length <= this.maxEntries) return; + + const parsed = entries.map((p): { file: string; accessedAt: number; createdAt: number } => { + try { + const h = JSON.parse(fs.readFileSync(p, 'utf-8')) as CacheEntry; + return { file: p, accessedAt: h.accessedAt ?? 0, createdAt: h.createdAt ?? 0 }; + } catch { + // Unparseable entries sort as oldest → evicted first. + return { file: p, accessedAt: 0, createdAt: 0 }; + } + }); + + // Pass 1: drop TTL-expired entries. + for (const p of parsed) { + if (now - p.createdAt > this.ttlMs) this.removeFile(p.file); + } + + // Pass 2: LRU eviction until at (or under) the bound. + const remaining = this.listEntryPaths(); + if (remaining.length <= this.maxEntries) return; + const alive = new Set(remaining); + const sorted = parsed.filter((p) => alive.has(p.file)).sort((a, b) => a.accessedAt - b.accessedAt); + let toRemove = sorted.length - this.maxEntries; + for (const p of sorted) { + if (toRemove <= 0) break; + if (this.removeFile(p.file)) toRemove--; + } + } +} + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- + +function arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} diff --git a/src/spawn-counter.ts b/src/spawn-counter.ts new file mode 100644 index 000000000..8481789b6 --- /dev/null +++ b/src/spawn-counter.ts @@ -0,0 +1,72 @@ +/** + * Env-gated spawn instrumentation (F2 — WL-0MSGAEC5N006W5QA). + * + * Records `wl` process spawns so the herdr refresh spawn-reduction target + * (parent AC7: ≥60%) can be measured. A "work spawn" is a `wl` process that + * performed real read work (a read-cache miss); cache-hit processes exit + * without touching the DB and are recorded with a distinct kind. + * + * Configuration: + * - `WL_SPAWN_COUNT_FILE=<path>` — append one `<kind>\t<ts>\t<pid>` line + * per recorded spawn (append-only, so concurrent processes never corrupt + * each other's records). + * - `WL_SPAWN_COUNT=1` (without a file) — log `[wl:spawn] <kind> <pid>` + * lines to stderr. + * + * Instrumentation is best-effort and must never break the CLI. + */ + +import * as fs from 'fs'; + +/** A single recorded spawn: kind, wall-clock timestamp, process pid. */ +export interface SpawnRecord { + kind: string; + ts: number; + pid: number; +} + +/** + * Record one spawn (or cache-hit) observation. + * + * No-op unless `WL_SPAWN_COUNT_FILE` or `WL_SPAWN_COUNT` is set. Never + * throws — instrumentation must not break command execution. + */ +export function recordSpawn(kind: string): void { + const file = process.env.WL_SPAWN_COUNT_FILE; + if (file) { + try { + fs.appendFileSync(file, `${kind}\t${Date.now()}\t${process.pid}\n`, 'utf-8'); + } catch { + // Ignore: instrumentation must never break the CLI. + } + return; + } + if (process.env.WL_SPAWN_COUNT) { + process.stderr.write(`[wl:spawn] ${kind} ${process.pid}\n`); + } +} + +/** Parse a spawn-count file back into records (for tests and reports). */ +export function readSpawnRecords(file: string): SpawnRecord[] { + let raw: string; + try { + raw = fs.readFileSync(file, 'utf-8'); + } catch { + return []; + } + const records: SpawnRecord[] = []; + for (const line of raw.split('\n')) { + if (!line.trim()) continue; + const [kind, ts, pid] = line.split('\t'); + if (kind === undefined) continue; + records.push({ kind, ts: Number(ts) || 0, pid: Number(pid) || 0 }); + } + return records; +} + +/** Count recorded spawns, optionally filtered by kind. */ +export function countSpawnRecords(file: string, kind?: string): number { + const records = readSpawnRecords(file); + if (kind === undefined) return records.length; + return records.filter((r) => r.kind === kind).length; +} diff --git a/src/sync.ts b/src/sync.ts index f6b62d820..c10a2e9cf 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -935,7 +935,26 @@ async function withTempWorktree<T>( const baseRef = hasRemote ? remoteTrackingRef : 'HEAD'; try { - await execAsync(`git worktree add --detach ${escapeShellArg(worktreePath)} ${escapeShellArg(baseRef)}`); + try { + await execAsync(`git worktree add --detach ${escapeShellArg(worktreePath)} ${escapeShellArg(baseRef)}`); + } catch (err) { + // Unborn-HEAD translation (SA-0MSG57UNY009DE51): when the worktree + // would be created from local HEAD on a repo with no commits yet, git + // fails with a cryptic error. Surface an actionable message naming the + // cause and remedy instead. + if (baseRef === 'HEAD') { + const raw = (err as Error).message || ''; + if (/HEAD/.test(raw) && /(not a commit|invalid reference|cannot be created from|not a valid object name)/i.test(raw)) { + throw new Error( + 'Cannot sync: this repository has no commits yet, so git cannot create ' + + 'a temporary worktree from HEAD. Create an initial commit first ' + + '(e.g. `git commit --allow-empty -m "chore: initial"`), or run ' + + '`wl sync --no-push` to keep worklog data local.' + ); + } + } + throw err; + } // If remote branch doesn't exist, create an orphan branch in the temp worktree. if (!hasRemote) { diff --git a/src/version.ts b/src/version.ts index 1f6eae974..d0f04f93a 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ // Auto-generated; do not edit. -export const WORKLOG_VERSION = '1.0.7'; +export const WORKLOG_VERSION = '1.0.8'; diff --git a/templates/AGENTS.md b/templates/AGENTS.md index 6389e6e46..ad66d0b4e 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -40,8 +40,9 @@ IMPORTANT: This project uses Worklog (wl) for ALL work-item tracking. Do NOT use Track work-item types with `--issue-type`: - bug - Something broken +- docs - Documentation (guides, README, API docs, podcast scripts) - feature - New functionality -- task - Work item (tests, docs, refactoring) +- task - Work item (tests, refactoring) - epic - Large feature with subtasks - chore - Maintenance (dependencies, tooling) diff --git a/tests/README.md b/tests/README.md index 3c3c7eb28..2859b9611 100644 --- a/tests/README.md +++ b/tests/README.md @@ -14,9 +14,6 @@ npm run test:watch # Run tests with coverage report npm run test:coverage -# Run TUI tests only (CI/headless) -npm run test:tui - # Generate per-test timings npm run test:timings # or @@ -219,40 +216,19 @@ Running subsets of tests - Run integration tests only (tests under `test/`): - `npx vitest run test` -- Run TUI/headless tests (CI helper): - - `npm run test:tui` - ## E2E Tests -End-to-end tests live under `tests/e2e/`. They exercise the real `wl` CLI and verify agent-driven flows: +End-to-end tests exercise the real `wl` CLI and verify agent-driven flows: ```bash # Run all E2E tests -npx vitest run tests/e2e/ - -# Run a specific E2E test file -npx vitest run tests/e2e/agent-flow.test.ts -``` - -The E2E tests in `tests/e2e/agent-flow.test.ts` verify: - -- Chat pane natural language routing (list, next, show, create, update, close) -- Action palette with default actions and filtering -- `runWl` CLI integration layer with real `wl` commands -- Full chat pane to wl CLI pipeline for create/update flows - -The E2E tests in `tests/e2e/headless-tui.test.ts` exercise the **built** CLI -(`dist/cli.js`) with `wl list`/`next`/`show`, plus TUI module loading. They -require a build first (`npm run build`) and self-initialize the worklog -(`.worklog/initialized`) in a `beforeAll` hook so they can be run in isolation: - -```bash -# Build, then run headless TUI E2E tests -npm run build -npx vitest run tests/e2e/headless-tui.test.ts +npx vitest run tests ``` -These tests are also gated in CI via `.github/workflows/install-and-smoke-test.yml`. +The Pi-based TUI E2E tests (`tests/e2e/headless-tui.test.ts` — built-CLI +`wl list`/`next`/`show` smoke tests, and `tests/e2e/agent-flow.test.ts` — +chat-pane/action-palette routing) were removed together with the TUI; work +item browsing now lives in the Herdr plugin (`packages/herdr/`). Guidance for authors diff --git a/tests/cli/cli-inproc.ts b/tests/cli/cli-inproc.ts index 6a38d532f..203e89420 100644 --- a/tests/cli/cli-inproc.ts +++ b/tests/cli/cli-inproc.ts @@ -40,7 +40,6 @@ import closeCommand from '../../src/commands/close.js'; import recentCommand from '../../src/commands/recent.js'; import pluginsCommand from '../../src/commands/plugins.js'; import reviewedCommand from '../../src/commands/reviewed.js'; -import tuiCommand from '../../src/commands/tui.js'; import migrateCommand from '../../src/commands/migrate.js'; import depCommand from '../../src/commands/dep.js'; import reSortCommand from '../../src/commands/re-sort.js'; @@ -70,7 +69,6 @@ const builtInCommands = [ recentCommand, pluginsCommand, reviewedCommand, - tuiCommand, migrateCommand, depCommand, reSortCommand, diff --git a/tests/cli/completion.test.ts b/tests/cli/completion.test.ts index 2929ec83a..48eeaaba8 100644 --- a/tests/cli/completion.test.ts +++ b/tests/cli/completion.test.ts @@ -16,7 +16,7 @@ const CLI_PATH = resolve(__dirname, '../../dist/cli.js'); const KNOWN_COMMANDS = [ 'init', 'status', 'create', 'list', 'show', 'update', 'delete', 'export', 'import', 'next', 'in-progress', 'sync', 'github', - 'comment', 'close', 'recent', 'plugins', 'tui', 'migrate', 'dep', + 'comment', 'close', 'recent', 'plugins', 'migrate', 'dep', 're-sort', 'doctor', 'reviewed', 'search', 'unlock', 'audit', 'completion', ]; diff --git a/tests/cli/init.test.ts b/tests/cli/init.test.ts index 365821c47..b5ad81b8f 100644 --- a/tests/cli/init.test.ts +++ b/tests/cli/init.test.ts @@ -272,4 +272,73 @@ describe('CLI Init Tests', () => { cleanupTempDir(tempDir); } }, 45000); + + // --------------------------------------------------------------------- + // Standalone workflow behavior (AC3 of WL-0MSIXMKOX0052514): without a + // SorraAgents global install, wl init's N/B/M workflow choices must keep + // their current behavior. These tests lock in the standalone path so the + // future delegation change cannot silently alter it. + // --------------------------------------------------------------------- + it('should inline WORKFLOW content into AGENTS.md with --workflow-inline yes (basic)', async () => { + const tempState = enterTempDir(); + try { + const existing = '## Project Rules\n\n- Local rule\n'; + fs.writeFileSync('AGENTS.md', existing, 'utf-8'); + + await execAsync( + `tsx ${cliPath} init --project-name "Test Project" --prefix TEST --auto-export yes --auto-sync no --workflow-inline yes --agents-template skip --stats-plugin-overwrite no` + ); + + const updated = fs.readFileSync('AGENTS.md', 'utf-8'); + expect(updated).toContain('<!-- WORKFLOW: start -->'); + expect(updated).toContain('<!-- WORKFLOW: end -->'); + // Pre-existing content is preserved below the inlined workflow block. + expect(updated).toContain(existing.trim()); + expect(updated.indexOf('<!-- WORKFLOW: start -->')).toBeLessThan( + updated.indexOf(existing.trim()) + ); + } finally { + leaveTempDir(tempState); + } + }, 45000); + + it('should not write WORKFLOW content with --workflow-inline no (none)', async () => { + const tempState = enterTempDir(); + try { + const existing = '## Project Rules\n\n- Local rule\n'; + fs.writeFileSync('AGENTS.md', existing, 'utf-8'); + + await execAsync( + `tsx ${cliPath} init --project-name "Test Project" --prefix TEST --auto-export yes --auto-sync no --workflow-inline no --agents-template skip --stats-plugin-overwrite no` + ); + + const updated = fs.readFileSync('AGENTS.md', 'utf-8'); + expect(updated).not.toContain('<!-- WORKFLOW: start -->'); + expect(updated).not.toContain('<!-- WORKFLOW: end -->'); + // Standalone 'none' leaves the project AGENTS.md untouched. + expect(updated).toBe(existing); + } finally { + leaveTempDir(tempState); + } + }, 45000); + + it('should not duplicate the WORKFLOW marker when --workflow-inline yes is re-run', async () => { + const tempState = enterTempDir(); + try { + const existing = '## Project Rules\n\n- Local rule\n'; + fs.writeFileSync('AGENTS.md', existing, 'utf-8'); + + const cmd = `tsx ${cliPath} init --project-name "Test Project" --prefix TEST --auto-export yes --auto-sync no --workflow-inline yes --agents-template skip --stats-plugin-overwrite no`; + await execAsync(cmd); + await execAsync(cmd); + + const updated = fs.readFileSync('AGENTS.md', 'utf-8'); + const markers = updated + .split(/\r?\n/) + .filter(line => line.trim() === '<!-- WORKFLOW: start -->').length; + expect(markers).toBe(1); + } finally { + leaveTempDir(tempState); + } + }, 45000); }); diff --git a/tests/cli/json-output-shape.test.ts b/tests/cli/json-output-shape.test.ts index 443e0666b..5ca5a6510 100644 --- a/tests/cli/json-output-shape.test.ts +++ b/tests/cli/json-output-shape.test.ts @@ -19,14 +19,16 @@ import { execAsync, enterTempDir, leaveTempDir, writeConfig, writeInitSemaphore, describe('JSON output shape consistency', () => { let state: { tempDir: string; originalCwd: string }; - beforeEach(() => { + beforeEach(async () => { state = enterTempDir(); writeConfig(state.tempDir, 'Test Project', 'TEST'); writeInitSemaphore(state.tempDir); - // Seed a few work items for list/search/next tests - execAsync(`tsx ${cliPath} create -t "First item" -d "Description for first item"`).catch(() => {}); - execAsync(`tsx ${cliPath} create -t "Second item" -d "Description for second item" -p high`).catch(() => {}); - execAsync(`tsx ${cliPath} create -t "Third item" -d "Description for third item" -p low`).catch(() => {}); + // Seed a few work items for list/search/next tests. These MUST be + // awaited: 'wl next' needs an item to exist, and fire-and-forget + // spawning raced the CLI startup (flaky workItem: null under load). + await execAsync(`tsx ${cliPath} create -t "First item" -d "Description for first item"`).catch(() => {}); + await execAsync(`tsx ${cliPath} create -t "Second item" -d "Description for second item" -p high`).catch(() => {}); + await execAsync(`tsx ${cliPath} create -t "Third item" -d "Description for third item" -p low`).catch(() => {}); }); afterEach(() => { diff --git a/tests/cli/mock-bin/README.md b/tests/cli/mock-bin/README.md index 449cb3dfd..24a4ac60a 100644 --- a/tests/cli/mock-bin/README.md +++ b/tests/cli/mock-bin/README.md @@ -36,8 +36,14 @@ The timeout uses: ## Debugging - **Mock git logs:** Set `WORKLOG_GIT_MOCK_DEBUG=1` to write debug traces to `/tmp/worklog-mock.log`. -- **Timeout guard:** Set `WORKLOG_MOCK_TIMEOUT=0` to disable the timeout guard - (not recommended — may cause orphaned processes). +- **Timeout guard:** `WORKLOG_MOCK_TIMEOUT=0` behaves differently per mock + implementation: the bash mocks (`git`, `gh`) treat `0` as an instantaneous + timeout — every execution exceeds the budget, so the dispatch guard + self-terminates with exit 124 immediately (this is what + `mock-timeout.test.ts` uses to verify the guard deterministically). The Node + `wl` mock treats `0` (and any non-positive value) as *disabled* — the guard + is not installed. A non-numeric value falls back to the 5s default in all + mocks. ## Notes & guidance - The mocks intentionally implement a tiny surface area. Extend them only for diff --git a/tests/cli/mock-timeout.test.ts b/tests/cli/mock-timeout.test.ts index 273421929..a6046efeb 100644 --- a/tests/cli/mock-timeout.test.ts +++ b/tests/cli/mock-timeout.test.ts @@ -33,18 +33,38 @@ describe('mock-bin/git timeout guard', () => { expect(result.stdout).toContain('git (mock): --help') }, 15000) - it('self-terminates when execution exceeds the configured timeout', async () => { - // Simulate a long-running command by asking git to do init (which creates a .git dir) - // Then run rev-parse with a very short timeout + it('does not trip the timeout guard during normal execution', async () => { + // Regression for the flake that motivated this suite: a NORMAL rev-parse + // under full-suite parallel load must never exceed the guard's budget and + // self-terminate (exit 124). Use a generous WORKLOG_MOCK_TIMEOUT so bash + // startup slowness alone can never trip the guard. const result = await execFile(gitMockPath, ['rev-parse', '--show-toplevel'], { - env: { ...process.env, WORKLOG_MOCK_TIMEOUT: '1' }, + env: { ...process.env, WORKLOG_MOCK_TIMEOUT: '30' }, timeout: 10000, }) - // A timeout of 1s should be enough for normal execution, but the timeout - // mechanism must not cause premature exit. This test validates that basic - // operations work within the timeout window. - // Just verify it completes normally - no error - expect(result.status).not.toBe(124) // 124 = timeout from execFile wrapper + // A normal rev-parse resolves (execFile does not reject) and prints the + // repo root to stdout with nothing on stderr. + expect(result.stderr).toBe('') + expect(result.stdout.trim().length).toBeGreaterThan(0) + }, 15000) + + it('self-terminates when execution exceeds the configured timeout', async () => { + // WORKLOG_MOCK_TIMEOUT=0 means every execution exceeds the configured + // budget instantly, so the bash dispatch guard self-terminates with exit + // code 124 before the mock performs any work. This deterministically + // verifies the guard's self-termination contract without depending on + // machine load (a 1s budget flaked under full-suite parallel load). + let captured: any = null + try { + await execFile(gitMockPath, ['--help'], { + env: { ...process.env, WORKLOG_MOCK_TIMEOUT: '0' }, + timeout: 10000, + }) + } catch (e: any) { + captured = e + } + expect(captured).toBeTruthy() + expect(captured.code).toBe(124) // 124 = guard self-termination exit code }, 15000) it('supports WORKLOG_MOCK_TIMEOUT environment variable with custom value', async () => { diff --git a/tests/cli/read-cache-e2e.test.ts b/tests/cli/read-cache-e2e.test.ts new file mode 100644 index 000000000..aad449457 --- /dev/null +++ b/tests/cli/read-cache-e2e.test.ts @@ -0,0 +1,237 @@ +/** + * End-to-end tests for the wl CLI read cache (F2 — WL-0MSGAEC5N006W5QA). + * + * These spawn the REAL CLI (tsx src/cli.ts) as a subprocess against temp + * worklog dirs, with an isolated cache dir and the spawn counter enabled, + * and assert the cache behaviour through observable spawn records: + * - 'read-work' = the process did real DB work (cache miss) + * - 'cache-hit' = the process was served from cache (no DB work) + * + * Note: `execAsync` from cli-helpers runs `tsx src/cli.ts` in-process via + * cli-inproc.ts, which bypasses src/cli.ts's cache wiring — so these tests + * spawn the CLI directly instead. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { writeConfig, writeInitSemaphore } from './cli-helpers.js'; +import { createTempDir, cleanupTempDir, resolveTsxBin } from '../test-utils.js'; +import { countSpawnRecords } from '../../src/spawn-counter.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..', '..'); +const cliPath = path.join(projectRoot, 'src', 'cli.ts'); +const tsxBin = resolveTsxBin(__dirname); + +/** Run the real CLI as a subprocess; returns stdout, stderr, exit status. */ +function runCli(args: string[], cwd: string, env: Record<string, string>): { stdout: string; stderr: string; status: number | null } { + const result = spawnSync(process.execPath, [tsxBin, cliPath, ...args], { + cwd, + env: { ...process.env, ...env }, + encoding: 'utf-8', + timeout: 60000, + maxBuffer: 20 * 1024 * 1024, + }); + return { stdout: result.stdout ?? '', stderr: result.stderr ?? '', status: result.status }; +} + +/** Setup: temp worklog + isolated cache dir + spawn counter file. */ +function setup(): { tempDir: string; cacheDir: string; spawnFile: string; env: Record<string, string> } { + const tempDir = createTempDir(); + writeConfig(tempDir, 'Read Cache E2E', 'RCE'); + writeInitSemaphore(tempDir); + const cacheDir = path.join(tempDir, 'cache'); + const spawnFile = path.join(tempDir, 'spawns.log'); + const env: Record<string, string> = { + WL_CACHE_DIR: cacheDir, + WL_SPAWN_COUNT_FILE: spawnFile, + WL_TUI_MODE: '1', + }; + return { tempDir, cacheDir, spawnFile, env }; +} + +function workSpawns(spawnFile: string): number { + return countSpawnRecords(spawnFile, 'read-work'); +} + +function hitSpawns(spawnFile: string): number { + return countSpawnRecords(spawnFile, 'cache-hit'); +} + +function createItem(tempDir: string, env: Record<string, string>, title: string, extra: string[] = []): void { + const res = runCli(['--json', 'create', '-t', title, ...extra], tempDir, env); + expect(res.status).toBe(0); +} + +describe('wl CLI read cache (e2e)', () => { + let tempDir: string; + let cacheDir: string; + let spawnFile: string; + let env: Record<string, string>; + + beforeEach(() => { + const s = setup(); + tempDir = s.tempDir; + cacheDir = s.cacheDir; + spawnFile = s.spawnFile; + env = s.env; + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('serves repeat identical reads from cache (AC1): list + show', () => { + createItem(tempDir, env, 'Alpha'); + createItem(tempDir, env, 'Beta'); + + const first = runCli(['list', '--json'], tempDir, env); + expect(first.status).toBe(0); + const second = runCli(['list', '--json'], tempDir, env); + expect(second.status).toBe(0); + expect(second.stdout).toBe(first.stdout); // byte-identical + expect(workSpawns(spawnFile)).toBe(1); + expect(hitSpawns(spawnFile)).toBe(1); + + // show <id> is also covered + const id = (JSON.parse(first.stdout) as { workItems: { id: string }[] }).workItems[0].id; + const s1 = runCli(['show', id, '--json'], tempDir, env); + expect(s1.status).toBe(0); + const s2 = runCli(['show', id, '--json'], tempDir, env); + expect(s2.stdout).toBe(s1.stdout); + expect(workSpawns(spawnFile)).toBe(2); + expect(hitSpawns(spawnFile)).toBe(2); + }); + + it('a write invalidates: identical read after write returns fresh data (AC2)', () => { + createItem(tempDir, env, 'Alpha'); + const before = runCli(['list', '--json'], tempDir, env); + expect(before.status).toBe(0); + expect(JSON.parse(before.stdout).workItems).toHaveLength(1); + + createItem(tempDir, env, 'Gamma'); // write + const after = runCli(['list', '--json'], tempDir, env); + expect(after.status).toBe(0); + const items = JSON.parse(after.stdout).workItems; + expect(items).toHaveLength(2); // fresh — not served from cache + expect(items.map((i: any) => i.title)).toContain('Gamma'); + expect(workSpawns(spawnFile)).toBe(2); // both reads missed (second was invalidated) + expect(hitSpawns(spawnFile)).toBe(0); + }); + + it('arg variants produce separate cache entries (AC3)', () => { + createItem(tempDir, env, 'Alpha', ['--priority', 'high']); + createItem(tempDir, env, 'Beta', ['--priority', 'low']); + + const variants: string[][] = [ + ['list', '--json'], + ['list', '--status', 'open', '--json'], + ['list', '-n', '1', '--json'], + ]; + for (const v of variants) { + const r1 = runCli(v, tempDir, env); + expect(r1.status).toBe(0); + const r2 = runCli(v, tempDir, env); + expect(r2.stdout).toBe(r1.stdout); + } + // One miss per distinct variant; every repeat is a hit. + expect(workSpawns(spawnFile)).toBe(3); + expect(hitSpawns(spawnFile)).toBe(3); + }); + + it("next's auto re-sort counts as a write and invalidates the cache (AC4)", () => { + // Create in a deliberately non-canonical order (no re-sort) so the next + // re-sort actually changes sort indices → bumps the state counter. + createItem(tempDir, env, 'Low', ['--priority', 'low', '--no-re-sort']); + createItem(tempDir, env, 'High', ['--priority', 'high', '--no-re-sort']); + createItem(tempDir, env, 'Mid', ['--priority', 'medium', '--no-re-sort']); + + const run1 = runCli(['next', '-n', '3', '--json'], tempDir, env); + expect(run1.status).toBe(0); + const run2 = runCli(['next', '-n', '3', '--json'], tempDir, env); + expect(run2.status).toBe(0); + // The first run's re-sort byproduct invalidated the cache → run2 missed. + expect(workSpawns(spawnFile)).toBe(2); + // Run 2's re-sort was a no-op (order already canonical) → run3 hits. + const run3 = runCli(['next', '-n', '3', '--json'], tempDir, env); + expect(run3.status).toBe(0); + expect(hitSpawns(spawnFile)).toBe(1); + expect(run3.stdout).toBe(run2.stdout); + }); + + it('search is cached, but --rebuild-index is a write that invalidates (AC4)', () => { + createItem(tempDir, env, 'Searchable Widget'); + const q = ['search', 'Widget', '--json']; + + const r1 = runCli(q, tempDir, env); + expect(r1.status).toBe(0); + const r2 = runCli(q, tempDir, env); + expect(r2.stdout).toBe(r1.stdout); + expect(workSpawns(spawnFile)).toBe(1); + expect(hitSpawns(spawnFile)).toBe(1); + + // --rebuild-index is never served from cache and invalidates (write). + // It is excluded from the spawn metric (non-cacheable), so no extra + // read-work is recorded — but it must invalidate the cached search. + const rb = runCli(['search', 'Widget', '--json', '--rebuild-index'], tempDir, env); + expect(rb.status).toBe(0); + expect(hitSpawns(spawnFile)).toBe(1); // rebuild never hits + + const r3 = runCli(q, tempDir, env); + expect(r3.status).toBe(0); + expect(workSpawns(spawnFile)).toBe(2); // r1 + r3 (r3 invalidated by rebuild) + }); + + it('status is covered and write commands invalidate it (sync counts as a write)', () => { + createItem(tempDir, env, 'Alpha'); + const s1 = runCli(['status', '--json'], tempDir, env); + expect(s1.status).toBe(0); + const s2 = runCli(['status', '--json'], tempDir, env); + expect(s2.stdout).toBe(s1.stdout); + expect(workSpawns(spawnFile)).toBe(1); + expect(hitSpawns(spawnFile)).toBe(1); + }); + + it('WL_CACHE_DISABLED bypasses the cache entirely (baseline)', () => { + createItem(tempDir, env, 'Alpha'); + const disabledEnv = { ...env, WL_CACHE_DISABLED: '1' }; + const r1 = runCli(['list', '--json'], tempDir, disabledEnv); + expect(r1.status).toBe(0); + const r2 = runCli(['list', '--json'], tempDir, disabledEnv); + expect(r2.status).toBe(0); + // Baseline: every cacheable read records a work spawn, none hit. + expect(workSpawns(spawnFile)).toBe(2); + expect(hitSpawns(spawnFile)).toBe(0); + }); + + it('does not cache text (non-JSON) output', () => { + createItem(tempDir, env, 'Alpha'); + const r1 = runCli(['list'], tempDir, env); + expect(r1.status).toBe(0); + const r2 = runCli(['list'], tempDir, env); + expect(r2.status).toBe(0); + expect(hitSpawns(spawnFile)).toBe(0); // text mode never cached + expect(workSpawns(spawnFile)).toBe(0); // and excluded from the spawn metric + // No cache ENTRY files were written (the dir itself may exist from + // write-invalidation state counters). + if (fs.existsSync(cacheDir)) { + const entries = fs.readdirSync(cacheDir).filter((f) => f.endsWith('.json')); + expect(entries).toEqual([]); + } + }); + + it('a fresh cache dir misses once then hits (no cross-test pollution)', () => { + // Second test file uses its own temp dirs; this just re-verifies the + // miss→hit cycle in a pristine cache dir. + createItem(tempDir, env, 'Alpha'); + const r1 = runCli(['list', '--json'], tempDir, env); + const r2 = runCli(['list', '--json'], tempDir, env); + expect(r1.stdout).toBe(r2.stdout); + expect(workSpawns(spawnFile)).toBe(1); + expect(hitSpawns(spawnFile)).toBe(1); + }); +}); diff --git a/tests/cli/read-cache-spawn-reduction.test.ts b/tests/cli/read-cache-spawn-reduction.test.ts new file mode 100644 index 000000000..b2105d15f --- /dev/null +++ b/tests/cli/read-cache-spawn-reduction.test.ts @@ -0,0 +1,130 @@ +/** + * Spawn-reduction simulation (F2 — WL-0MSGAEC5N006W5QA, parent AC7). + * + * Simulates a 6-pane herdr refresh against one shared worklog: each pane + * issues the same query set (the herdr fetcher's real read commands), all + * byte-identical across panes. With the shared on-disk read cache, repeat + * queries are served from cache without DB work, so the number of wl + * processes that do real read work must drop by ≥60% vs the baseline + * (cache disabled). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawnSync } from 'child_process'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { writeConfig, writeInitSemaphore } from './cli-helpers.js'; +import { createTempDir, cleanupTempDir, resolveTsxBin } from '../test-utils.js'; +import { countSpawnRecords } from '../../src/spawn-counter.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..', '..'); +const cliPath = path.join(projectRoot, 'src', 'cli.ts'); +const tsxBin = resolveTsxBin(__dirname); + +const PANES = 6; + +/** The herdr fetcher's query set (see parent epic WL-0MSAZQEQB008O7H3). */ +const QUERIES: string[][] = [ + ['next', '-n', '5', '--include-in-progress', '--json'], + ['list', '--priority', 'critical', '--root-only', '--json'], + ['list', '--status', 'completed', '--stage', 'in_review', '--root-only', '--json'], + ['list', '--status', 'open,in-progress,blocked', '--json'], + ['status', '--json'], +]; + +function runCli(args: string[], cwd: string, env: Record<string, string>): { status: number | null } { + const result = spawnSync(process.execPath, [tsxBin, cliPath, ...args], { + cwd, + env: { ...process.env, ...env }, + encoding: 'utf-8', + timeout: 60000, + maxBuffer: 20 * 1024 * 1024, + }); + return { status: result.status }; +} + +describe('read-cache spawn reduction (6-pane simulation)', () => { + let tempDir: string; + let spawnFile: string; + let envBase: Record<string, string>; + + beforeEach( + () => { + tempDir = createTempDir(); + writeConfig(tempDir, 'Spawn Reduction', 'SPR'); + writeInitSemaphore(tempDir); + spawnFile = path.join(tempDir, 'spawns.log'); + // WL_CACHE_TTL_MS: the 30s default TTL (a bounding safety net) can + // expire cache entries mid-run on loaded machines where each tsx + // subprocess spawn takes seconds; raise it so the simulation measures + // cache behaviour, not wall-clock timing. + envBase = { WL_TUI_MODE: '1', WL_SPAWN_COUNT_FILE: spawnFile, WL_CACHE_TTL_MS: '300000' }; + + // Seed a realistic worklog (open/critical/completed-in-review/blocked). + const seed: Array<[string, string[]]> = [ + ['Critical Open', ['--priority', 'critical']], + ['High Open', ['--priority', 'high']], + ['Completed Review', ['--priority', 'medium', '--status', 'completed', '--stage', 'in_review']], + ['Blocked Item', ['--priority', 'low', '--status', 'blocked']], + ['In Progress', ['--priority', 'high', '--status', 'in-progress']], + ]; + for (const [title, extra] of seed) { + const res = runCli(['--json', 'create', '-t', title, ...extra], tempDir, envBase); + expect(res.status).toBe(0); + } + }, + // Seeding runs 5 `tsx src/cli.ts create` subprocesses (cold tsx starts + // can take seconds each on a loaded machine); the default 10s hook + // timeout makes this beforeEach flake out. Matches the it() budget. + 180_000, + ); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it( + 'cuts work spawns by ≥60% across a simulated 6-pane refresh', + () => { + // Baseline: cache disabled — every pane × query does real DB work. + const baselineEnv = { ...envBase, WL_CACHE_DISABLED: '1' }; + for (let pane = 0; pane < PANES; pane++) { + for (const q of QUERIES) { + const res = runCli(q, tempDir, baselineEnv); + expect(res.status).toBe(0); + } + } + const baselineWork = countSpawnRecords(spawnFile, 'read-work'); + expect(baselineWork).toBe(PANES * QUERIES.length); // 30 + + // Cached: fresh cache dir — pane 0 backfills, panes 1-5 hit. + const cacheDir = path.join(tempDir, 'cache'); + const cachedEnv = { ...envBase, WL_CACHE_DIR: cacheDir }; + // Start the spawn counter file fresh for the cached phase. + const cachedSpawnFile = path.join(tempDir, 'cached-spawns.log'); + cachedEnv.WL_SPAWN_COUNT_FILE = cachedSpawnFile; + for (let pane = 0; pane < PANES; pane++) { + for (const q of QUERIES) { + const res = runCli(q, tempDir, cachedEnv); + expect(res.status).toBe(0); + } + } + const cachedWork = countSpawnRecords(cachedSpawnFile, 'read-work'); + const cachedHits = countSpawnRecords(cachedSpawnFile, 'cache-hit'); + + const reduction = 1 - cachedWork / baselineWork; + // eslint-disable-next-line no-console + console.log( + `[spawn-reduction] panes=${PANES} queries=${QUERIES.length} ` + + `baselineWork=${baselineWork} cachedWork=${cachedWork} cacheHits=${cachedHits} ` + + `reduction=${(reduction * 100).toFixed(1)}% (target ≥60%)` + ); + + expect(cachedWork).toBeLessThanOrEqual(0.4 * baselineWork); + expect(cachedHits).toBeGreaterThan(0); // panes 1-5 were served from cache + }, + 300_000 // slow: 60+ tsx subprocess spawns (180s was too tight on loaded dev machines) + ); +}); diff --git a/tests/cli/sync-cache-invalidation.test.ts b/tests/cli/sync-cache-invalidation.test.ts new file mode 100644 index 000000000..2850466e0 --- /dev/null +++ b/tests/cli/sync-cache-invalidation.test.ts @@ -0,0 +1,211 @@ +/** + * E2E tests for sync → read-cache invalidation (F3 — WL-0MSGAEJQA005QG3W, AC1). + * + * Uses REAL git + a real bare remote (mock-bin stripped from PATH, mirroring + * tests/cli/sync-concurrent.test.ts) so the pull/merge/push path actually + * runs against the compiled CLI (dist/cli.js). + * + * AC1: after a successful `wl sync`, identical reads return post-pull data + * (no stale cache served). The sync command bumps the read-cache state + * counter AFTER the merge lands (preAction already bumped it before the + * action), so entries cached before/during the sync window are never served. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync, execFile } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { createTempDir, cleanupTempDir } from '../test-utils.js'; +import { readStateCounter } from '../../src/read-cache.js'; + +const __dirname = path.dirname(new URL(import.meta.url).pathname); +const projectRoot = path.resolve(__dirname, '..', '..'); +const cliPath = path.join(projectRoot, 'dist', 'cli.js'); +const mockBinDir = path.join(__dirname, 'mock-bin'); + +/** PATH without the test mock-bin so we run the real git binary. */ +function realGitPath(): string { + return (process.env.PATH || '') + .split(path.delimiter) + .filter((p) => path.resolve(p) !== path.resolve(mockBinDir)) + .join(path.delimiter); +} + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf-8', env: { ...process.env, PATH: realGitPath() } }); +} + +interface CliResult { + code: number | null; + stdout: string; + stderr: string; +} + +function runCli(cwd: string, args: string[], env: Record<string, string>): Promise<CliResult> { + return new Promise((resolve, reject) => { + const child = execFile( + process.execPath, + [cliPath, ...args], + { + cwd, + env: { ...process.env, ...env }, + maxBuffer: 20 * 1024 * 1024, + }, + (error, stdout, stderr) => { + if (error) { + resolve({ code: (error as any).code ?? 1, stdout: String(stdout), stderr: String(stderr) }); + } else { + resolve({ code: 0, stdout: String(stdout), stderr: String(stderr) }); + } + } + ); + child.on('error', reject); + }); +} + +describe('sync → read-cache invalidation (F3 AC1)', () => { + let repoA: string; // primary repo + let repoB: string; // second clone (remote-side changes) + let remoteDir: string; + let cacheDir: string; + + function envFor(cwd: string): Record<string, string> { + return { + PATH: realGitPath(), + WL_CACHE_DIR: cacheDir, + WL_TUI_MODE: '1', + }; + } + + function cli(cwd: string, ...args: string[]): Promise<CliResult> { + return runCli(cwd, args, envFor(cwd)); + } + + beforeEach(() => { + repoA = createTempDir(); + remoteDir = createTempDir(); + git(repoA, 'init', '-q'); + git(repoA, 'config', 'user.email', 'test@example.com'); + git(repoA, 'config', 'user.name', 'Test User'); + git(repoA, 'init', '-q', '--bare', remoteDir); + git(repoA, 'remote', 'add', 'origin', remoteDir); + fs.writeFileSync(path.join(repoA, 'README.md'), '# Sync Cache Test\n', 'utf-8'); + git(repoA, 'add', 'README.md'); + git(repoA, 'commit', '-m', 'initial commit'); + + // Worklog config (syncRemote/syncBranch like sync-concurrent.test.ts). + const worklogDir = path.join(repoA, '.worklog'); + fs.mkdirSync(worklogDir, { recursive: true }); + fs.writeFileSync( + path.join(worklogDir, 'config.json'), + JSON.stringify({ + projectName: 'Sync Cache Test', + prefix: 'SC', + syncRemote: 'origin', + syncBranch: 'refs/worklog/data', + }), + 'utf-8' + ); + fs.writeFileSync( + path.join(worklogDir, 'initialized'), + JSON.stringify({ version: '1.0.0', initializedAt: '2026-01-01T00:00:00.000Z' }), + 'utf-8' + ); + + // Second clone for remote-side changes. + repoB = path.join(createTempDir(), 'cloneB'); + git(repoA, 'clone', '-q', remoteDir, repoB); + git(repoB, 'config', 'user.email', 'test@example.com'); + git(repoB, 'config', 'user.name', 'Test User'); + // Empty clone has no HEAD; the sync worktree path needs an initial commit. + git(repoB, 'commit', '--allow-empty', '-m', 'chore: initial'); + const wlB = path.join(repoB, '.worklog'); + fs.mkdirSync(wlB, { recursive: true }); + fs.writeFileSync( + path.join(wlB, 'config.json'), + JSON.stringify({ + projectName: 'Sync Cache Test', + prefix: 'SC', + syncRemote: 'origin', + syncBranch: 'refs/worklog/data', + }), + 'utf-8' + ); + fs.writeFileSync( + path.join(wlB, 'initialized'), + JSON.stringify({ version: '1.0.0', initializedAt: '2026-01-01T00:00:00.000Z' }), + 'utf-8' + ); + + cacheDir = path.join(repoA, 'cache'); + }); + + afterEach(() => { + cleanupTempDir(repoA); + cleanupTempDir(path.dirname(repoB)); + cleanupTempDir(remoteDir); + }); + + it('AC1: after a successful sync, identical reads return post-pull data (no stale cache)', async () => { + const worklogDirA = path.join(repoA, '.worklog'); + + // Seed item 1 in A and cache a read (counter C0). + const create1 = await cli(repoA, '--json', 'create', '-t', 'Item One'); + expect(create1.code).toBe(0); + const read1 = await cli(repoA, 'list', '--json'); + expect(read1.code).toBe(0); + expect(read1.stdout).toContain('Item One'); + const counterBefore = readStateCounter(cacheDir, worklogDirA); + + // B creates item 2 and pushes it to the remote via wl sync. + const create2 = await cli(repoB, '--json', 'create', '-t', 'Item Two'); + expect(create2.code).toBe(0); + const syncB = await cli(repoB, '--json', 'sync'); + expect(syncB.code).toBe(0); + expect(syncB.stdout).toContain('"success": true'); + + // A syncs: pulls item 2 into its DB. + const syncA = await cli(repoA, '--json', 'sync'); + expect(syncA.code, `syncA stdout: ${syncA.stdout}\nstderr: ${syncA.stderr}`).toBe(0); + expect(syncA.stdout).toContain('"success": true'); + + // The post-sync invalidation fired: the state counter advanced AGAIN + // after the preAction bump (preAction +1, post-sync +1 ⇒ +2 total). + const counterAfter = readStateCounter(cacheDir, worklogDirA); + expect(counterAfter).toBe(counterBefore + 2); + + // The identical read must return POST-PULL data (both items), never the + // pre-sync cached payload. + const read2 = await cli(repoA, 'list', '--json'); + expect(read2.code).toBe(0); + expect(read2.stdout).toContain('Item One'); + expect(read2.stdout).toContain('Item Two'); + }, 180000); + + it('AC3: a dry-run sync does not refresh the heartbeat marker', async () => { + const markerPath = path.join(repoA, '.worklog', 'last-sync-time'); + const testTimestamp = '2026-06-25T12:00:00.000Z'; + fs.writeFileSync(markerPath, testTimestamp, 'utf-8'); + + const dry = await cli(repoA, '--json', 'sync', '--dry-run'); + expect(dry.code).toBe(0); + expect(dry.stdout).toContain('"success": true'); + + // Skipped sync: marker untouched (a refreshed marker would suppress real + // syncs across herdr panes). + expect(fs.readFileSync(markerPath, 'utf-8')).toBe(testTimestamp); + }, 120000); + + it('AC3: a failed sync does not create/refresh the heartbeat marker', async () => { + const markerPath = path.join(repoA, '.worklog', 'last-sync-time'); + expect(fs.existsSync(markerPath)).toBe(false); + + // A nonexistent remote fails at the push step (real git) → sync fails. + const failed = await cli(repoA, '--json', 'sync', '--git-remote', 'does-not-exist'); + expect(failed.code).not.toBe(0); + expect(failed.stdout).toContain('"success": false'); + + // Failed sync: no heartbeat written — the next pane tick must still sync. + expect(fs.existsSync(markerPath)).toBe(false); + }, 120000); +}); diff --git a/tests/database.test.ts b/tests/database.test.ts index fcb735cd4..74e65e93c 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -2631,6 +2631,58 @@ describe('WorklogDatabase', () => { expect(result.workItem!.stage).toBe('done'); }); + // WL-0MSGRJWRX0068W3W: done items excluded from default wl next results + it('should exclude completed done items by default', () => { + db.create({ title: 'Done task', priority: 'critical', status: 'completed', stage: 'done' }); + const openItem = db.create({ title: 'Open task', priority: 'low', status: 'open', stage: 'idea' }); + + const result = db.findNextWorkItem(); + expect(result.workItem).not.toBeNull(); + expect(result.workItem!.id).toBe(openItem.id); + expect(result.workItem!.stage).not.toBe('done'); + }); + + it('should exclude done items even when status is not completed', () => { + db.create({ title: 'Open status done', priority: 'critical', status: 'open', stage: 'done' }); + const openItem = db.create({ title: 'Open task', priority: 'low', status: 'open', stage: 'idea' }); + + const result = db.findNextWorkItem(); + expect(result.workItem).not.toBeNull(); + expect(result.workItem!.id).toBe(openItem.id); + expect(result.workItem!.stage).not.toBe('done'); + }); + + it('should return null when only done items exist', () => { + db.create({ title: 'Done only', priority: 'critical', status: 'completed', stage: 'done' }); + + const result = db.findNextWorkItem(); + expect(result.workItem).toBeNull(); + }); + + it('should exclude done items from batch results (WL-0MSGRJWRX0068W3W)', () => { + db.create({ title: 'Done A', priority: 'critical', status: 'completed', stage: 'done' }); + db.create({ title: 'Done B', priority: 'high', status: 'open', stage: 'done' }); + const a = db.create({ title: 'Open A', priority: 'medium', status: 'open', stage: 'idea' }); + const b = db.create({ title: 'Open B', priority: 'low', status: 'open', stage: 'idea' }); + + const results = db.findNextWorkItems(5); + const ids = results.map(r => r.workItem?.id).filter(Boolean); + expect(ids).toEqual([a.id, b.id]); + for (const r of results) { + if (r.workItem) { + expect(r.workItem.stage).not.toBe('done'); + } + } + }); + + it('should still return done items with explicit --stage done opt-in', () => { + const doneItem = db.create({ title: 'Done opt-in', priority: 'low', status: 'completed', stage: 'done' }); + + const result = db.findNextWorkItem(undefined, undefined, false, 'done'); + expect(result.workItem).not.toBeNull(); + expect(result.workItem!.id).toBe(doneItem.id); + }); + it('should return null when no items match the stage filter', () => { db.create({ title: 'Idea task', priority: 'high', status: 'open', stage: 'idea' }); db.create({ title: 'In progress task', priority: 'high', status: 'open', stage: 'in_progress' }); diff --git a/tests/e2e/agent-flow.test.ts b/tests/e2e/agent-flow.test.ts deleted file mode 100644 index 09ff85578..000000000 --- a/tests/e2e/agent-flow.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * End-to-end tests for agent-driven create/update flow in Pi TUI. - * - * These tests verify the integration between: - * - Chat pane (natural language processing) - * - PiAdapter (agent backend) - * - wl CLI integration (work item operations) - * - * Run locally: npx vitest run tests/e2e/agent-flow.test.ts - * Run in CI: npm test -- tests/e2e/agent-flow.test.ts - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { ChatPane } from '../../packages/tui/extensions/Worklog/chatPane.js'; -import { ActionPalette } from '../../packages/tui/extensions/Worklog/actionPalette.js'; -import { runWl } from '../../packages/tui/extensions/wl-integration.js'; -import { runWlCommand } from '../../src/wl-integration/spawn.js'; - -describe('E2E: Agent-driven create flow', () => { - let chatPane: ChatPane; - let originalSpawn: typeof import('child_process').spawn | undefined; - - beforeEach(() => { - chatPane = new ChatPane(); - // Capture spawn calls - originalSpawn = require('child_process').spawn; - }); - - afterEach(() => { - if (originalSpawn) { - require('child_process').spawn = originalSpawn; - } - }); - - it('chat pane can process a "list" command and return work items', async () => { - const response = await chatPane.sendMessage('list work items'); - - expect(response.role).toBe('agent'); - expect(response.content.length).toBeGreaterThan(0); - expect(chatPane.state.messages.length).toBe(2); // user + agent - }); - - it('chat pane can process a "next" command', async () => { - const response = await chatPane.sendMessage('what should I work on next'); - - expect(response.role).toBe('agent'); - expect(response.content.length).toBeGreaterThan(0); - }); - - it('chat pane can process a "show" command for a work item', async () => { - // Use a valid work item ID from the test environment - const response = await chatPane.sendMessage('show WL-TEST-000'); - - expect(response.role).toBe('agent'); - // The response will either contain the work item details or a "not found" message - expect(response.content.length).toBeGreaterThan(0); - }); - - it('chat pane handles processing state correctly', async () => { - // Send first message - const response1 = await chatPane.sendMessage('list'); - expect(response1.role).toBe('agent'); - - // Chat pane should not be processing after completion - expect(chatPane.state.isProcessing).toBe(false); - }); -}); - -describe('E2E: Action palette integration', () => { - let chatPane: ChatPane; - let palette: ActionPalette; - - beforeEach(() => { - chatPane = new ChatPane(); - palette = new ActionPalette(chatPane); - }); - - it('action palette has default actions', () => { - palette.open(); - const actions = palette.getFilteredActions(); - expect(actions.length).toBeGreaterThan(0); - }); - - it('action palette filters actions by text', () => { - palette.open(); - palette.setFilter('list'); - const filtered = palette.getFilteredActions(); - const listActions = filtered.filter(a => a.label.toLowerCase().includes('list')); - expect(listActions.length).toBeGreaterThan(0); - }); - - it('action palette can execute wl list action', async () => { - const listAction = palette.getFilteredActions().find(a => - a.label.toLowerCase().includes('list') || a.id === 'wl-list' - ); - - if (listAction) { - const result = await listAction.execute(); - // Result should be a string containing work items or a message - expect(typeof result).toBe('string'); - expect(result.length).toBeGreaterThan(0); - } - }); - - it('action palette can execute wl next action', async () => { - const nextAction = palette.getFilteredActions().find(a => - a.label.toLowerCase().includes('next') || a.id === 'wl-next' - ); - - if (nextAction) { - const result = await nextAction.execute(); - expect(typeof result).toBe('string'); - } - }); -}); - -describe('E2E: wl CLI integration layer', () => { - it('runWl can execute wl list command', async () => { - const result = await runWl('list', ['-n', '1']); - // Result should be an array or an object with items - expect(result).toBeDefined(); - }); - - it('runWl can execute wl next command', async () => { - const result = await runWl('next'); - expect(result).toBeDefined(); - }); - - it('runWl returns errors for invalid commands', async () => { - await expect(runWl('nonexistent-invalid-command-xyz')).rejects.toThrow(); - }); -}); - -describe('E2E: Chat pane to wl CLI pipeline', () => { - let chatPane: ChatPane; - - beforeEach(() => { - chatPane = new ChatPane(); - }); - - it('chat pane create flow triggers wl create via runWl', async () => { - const response = await chatPane.sendMessage( - 'create work item: Test E2E item from agent pipeline' - ); - - expect(response.role).toBe('agent'); - // Should either succeed with a work item ID or indicate the command was executed - expect(response.content.toLowerCase()).toContain('created'); - }); - - it('chat pane update flow triggers wl update via runWl', async () => { - // Try updating a non-existent item - should return an error message - const response = await chatPane.sendMessage( - 'update WL-NONEXIST-0001 to set status to in_progress' - ); - - expect(response.role).toBe('agent'); - // Should indicate the update was attempted (success or failure) - expect(response.content.length).toBeGreaterThan(0); - }); - - it('chat pane handles unknown commands gracefully', async () => { - const response = await chatPane.sendMessage('some completely random input xyz123'); - - expect(response.role).toBe('agent'); - // Should fall back to a generic response - expect(response.content.length).toBeGreaterThan(0); - }); -}); diff --git a/tests/e2e/headless-tui.test.ts b/tests/e2e/headless-tui.test.ts deleted file mode 100644 index a4ad7672f..000000000 --- a/tests/e2e/headless-tui.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -/** - * End-to-end tests for the built TUI executable running in headless mode. - * - * These tests verify the integration between: - * - The built TUI executable (dist/cli.js) - * - wl CLI commands run in a headless/CI environment - * - Real process spawning via execa (not mocked spawn) - * - * Run locally: npx vitest run tests/e2e/headless-tui.test.ts - * Run in CI: npm test -- tests/e2e/headless-tui.test.ts - * - * Unlike tests/e2e/agent-flow.test.ts which tests ChatPane/ActionPalette - * components directly, these tests exercise the built executable itself. - */ - -import { describe, it, expect, beforeAll } from 'vitest'; -import { execa } from 'execa'; -import * as path from 'path'; -import * as fs from 'fs'; - -const PROJECT_ROOT = path.join(__dirname, '..', '..'); -const INIT_SEMAPHORE = path.join(PROJECT_ROOT, '.worklog', 'initialized'); - -/** - * Ensure the worklog is initialized before running CLI commands. - * CI runs `wl init` before the test suite, but this test file may also be - * run in isolation (e.g. `npx vitest run tests/e2e/headless-tui.test.ts`), - * in which case the `.worklog/initialized` semaphore does not exist yet and - * every `wl` CLI invocation exits with "Worklog system is not initialized". - */ -function ensureWorklogInitialized(): void { - if (!fs.existsSync(INIT_SEMAPHORE)) { - fs.mkdirSync(path.dirname(INIT_SEMAPHORE), { recursive: true }); - fs.writeFileSync( - INIT_SEMAPHORE, - JSON.stringify({ - version: '0.0.0-test', - initializedAt: new Date().toISOString(), - }, null, 2), - 'utf-8' - ); - } -} - -/** - * Run a wl CLI command via the built executable. - * @param args Arguments to pass to the wl CLI - * @returns execa ReturnPromise - */ -function runWlCli(...args: string[]): ReturnType<typeof execa> { - const cliPath = path.join(__dirname, '..', '..', 'dist', 'cli.js'); - return execa('node', [cliPath, ...args], { - timeout: 15000, - cwd: path.join(__dirname, '..', '..'), - }); -} - -/** - * Run the wl CLI in TUI headless mode (no interactive terminal). - * This simulates how the TUI would run in CI without a display. - */ -function runTuiHeadless(...args: string[]): ReturnType<typeof execa> { - const cliPath = path.join(__dirname, '..', '..', 'dist', 'cli.js'); - return execa('node', [cliPath, 'tui', '--headless', ...args], { - timeout: 20000, - cwd: path.join(__dirname, '..', '..'), - env: { ...process.env, WL_TUI_MODE: '1', CI: '1' }, - }); -} - -describe('E2E: Headless TUI - built executable', () => { - beforeAll(() => { - ensureWorklogInitialized(); - }); - - describe('wl list command via built CLI', () => { - it('executes wl list -n 1 --json and returns valid JSON', async () => { - const { stdout } = await runWlCli('list', '-n', '1', '--json'); - const parsed = JSON.parse(stdout); - expect(parsed).toBeDefined(); - expect(parsed.success).toBe(true); - // Response format: { success, count, workItems: [...] } - const items = parsed.workItems ?? parsed.items; - expect(Array.isArray(items)).toBe(true); - }); - - it('executes wl list -n 5 --json and returns up to 5 items', async () => { - const { stdout } = await runWlCli('list', '-n', '5', '--json'); - const parsed = JSON.parse(stdout); - expect(parsed.success).toBe(true); - const items = parsed.workItems ?? parsed.items; - expect(items.length).toBeLessThanOrEqual(5); - }); - }); - - describe('wl next command via built CLI', () => { - it('executes wl next --json and returns work item recommendation', async () => { - const { stdout } = await runWlCli('next', '--json'); - const parsed = JSON.parse(stdout); - expect(parsed).toBeDefined(); - expect(parsed.success).toBe(true); - // workItem can be null when no ready work items exist; - // this is valid behavior, not an error. - if (parsed.workItem !== null && parsed.workItem !== undefined) { - expect(parsed.workItem.id).toBeDefined(); - } - }); - - it('executes wl next --assignee and returns assigned items', async () => { - const { stdout } = await runWlCli('next', '--assignee', 'OpenAI-Agent', '--json'); - const parsed = JSON.parse(stdout); - // Should return a valid response (may have no items if none assigned) - expect(parsed).toBeDefined(); - }); - }); - - describe('wl show command via built CLI', () => { - it('executes wl show with a real work item ID', async () => { - // Create a work item first, then verify we can show it - const { stdout: createOut } = await runWlCli('create', '-t', 'Test item for show', '-d', 'Testing wl show command'); - // Parse the create response to get the new item ID - // create output format: Created <prefix>-<number> (<title>) - const idMatch = createOut.match(/Created (\S+) \(/); - if (!idMatch) { - // Fallback: try JSON output - const { stdout: createJson } = await runWlCli('create', '-t', 'Test item for show JSON', '-d', 'Testing wl show', '--json'); - const createParsed = JSON.parse(createJson); - expect(createParsed.success).toBe(true); - const itemId = createParsed.workItem?.id || createParsed.id; - expect(itemId).toBeDefined(); - const { stdout } = await runWlCli('show', itemId, '--json'); - const parsed = JSON.parse(stdout); - expect(parsed.success).toBe(true); - expect(parsed.workItem.id).toBe(itemId); - return; - } - const itemId = idMatch[1]; - const { stdout } = await runWlCli('show', itemId, '--json'); - const parsed = JSON.parse(stdout); - expect(parsed.success).toBe(true); - expect(parsed.workItem.id).toBe(itemId); - }); - }); - - describe('wl CLI error handling', () => { - it('returns non-zero exit code for invalid commands', async () => { - await expect( - runWlCli('nonexistent-invalid-command-xyz', '--json') - ).rejects.toThrow(); - }); - - it('returns non-zero exit code for non-existent work item', async () => { - await expect( - runWlCli('show', 'SA-INVALID-999999999', '--json') - ).rejects.toThrow(); - }); - }); - - describe('TUI module loading via built executable', () => { - it('loads TUI modules without errors', async () => { - const { ChatPane } = await import('../../packages/tui/extensions/Worklog/chatPane.js'); - const { ActionPalette } = await import('../../packages/tui/extensions/Worklog/actionPalette.js'); - const { runWl } = await import('../../packages/tui/extensions/wl-integration.js'); - expect(ChatPane).toBeDefined(); - expect(ActionPalette).toBeDefined(); - expect(runWl).toBeDefined(); - }); - - it('ChatPane can be instantiated and cleared', async () => { - const { ChatPane } = await import('../../packages/tui/extensions/Worklog/chatPane.js'); - const pane = new ChatPane(); - pane.clear(); - expect(pane.getMessages()).toEqual([]); - expect(pane.getMessageCount()).toBe(0); - }); - - it('ActionPalette has at least 5 default actions', async () => { - const { ChatPane } = await import('../../packages/tui/extensions/Worklog/chatPane.js'); - const { ActionPalette } = await import('../../packages/tui/extensions/Worklog/actionPalette.js'); - const chat = new ChatPane(); - const palette = new ActionPalette(chat); - palette.open(); - const actions = palette.getFilteredActions(); - expect(actions.length).toBeGreaterThanOrEqual(5); - }); - }); -}); - -describe('E2E: Conversational flows via ChatPane', () => { - it('chat pane can process a "list" command via runWl', async () => { - const { ChatPane } = await import('../../packages/tui/extensions/Worklog/chatPane.js'); - const chatPane = new ChatPane(); - const response = await chatPane.sendMessage('list work items'); - expect(response.role).toBe('agent'); - expect(response.content.length).toBeGreaterThan(0); - expect(chatPane.state.messages.length).toBe(2); // user + agent - }); - - it('chat pane can process a "next" command via runWl', async () => { - const { ChatPane } = await import('../../packages/tui/extensions/Worklog/chatPane.js'); - const chatPane = new ChatPane(); - const response = await chatPane.sendMessage('what should I work on next'); - expect(response.role).toBe('agent'); - expect(response.content.length).toBeGreaterThan(0); - }); - - it('chat pane can process a "show" command via runWl', async () => { - const { ChatPane } = await import('../../packages/tui/extensions/Worklog/chatPane.js'); - const chatPane = new ChatPane(); - const response = await chatPane.sendMessage('show SA-0MPFCUEKX006CF3U'); - expect(response.role).toBe('agent'); - expect(response.content.length).toBeGreaterThan(0); - }); - - it('chat pane handles processing state correctly', async () => { - const { ChatPane } = await import('../../packages/tui/extensions/Worklog/chatPane.js'); - const chatPane = new ChatPane(); - const response1 = await chatPane.sendMessage('list'); - expect(response1.role).toBe('agent'); - expect(chatPane.state.isProcessing).toBe(false); - }); - - it('chat pane create flow triggers wl create via runWl', async () => { - const { ChatPane } = await import('../../packages/tui/extensions/Worklog/chatPane.js'); - const chatPane = new ChatPane(); - const response = await chatPane.sendMessage( - 'create work item: Test E2E item from agent pipeline' - ); - expect(response.role).toBe('agent'); - // Should either succeed with a work item ID or indicate the command was executed - expect(response.content.toLowerCase()).toContain('created'); - }); - - it('chat pane handles unknown commands gracefully', async () => { - const { ChatPane } = await import('../../packages/tui/extensions/Worklog/chatPane.js'); - const chatPane = new ChatPane(); - const response = await chatPane.sendMessage('some completely random input xyz123'); - expect(response.role).toBe('agent'); - expect(response.content.length).toBeGreaterThan(0); - }); -}); - -describe('E2E: Action palette integration', () => { - it('action palette has default actions', async () => { - const { ChatPane } = await import('../../packages/tui/extensions/Worklog/chatPane.js'); - const { ActionPalette } = await import('../../packages/tui/extensions/Worklog/actionPalette.js'); - const chatPane = new ChatPane(); - const palette = new ActionPalette(chatPane); - palette.open(); - const actions = palette.getFilteredActions(); - expect(actions.length).toBeGreaterThan(0); - }); - - it('action palette filters actions by text', async () => { - const { ChatPane } = await import('../../packages/tui/extensions/Worklog/chatPane.js'); - const { ActionPalette } = await import('../../packages/tui/extensions/Worklog/actionPalette.js'); - const chatPane = new ChatPane(); - const palette = new ActionPalette(chatPane); - palette.open(); - palette.setFilter('list'); - const filtered = palette.getFilteredActions(); - const listActions = filtered.filter(a => a.label.toLowerCase().includes('list')); - expect(listActions.length).toBeGreaterThan(0); - }); - - it('action palette can execute wl list action', async () => { - const { ChatPane } = await import('../../packages/tui/extensions/Worklog/chatPane.js'); - const { ActionPalette } = await import('../../packages/tui/extensions/Worklog/actionPalette.js'); - const chatPane = new ChatPane(); - const palette = new ActionPalette(chatPane); - const listAction = palette.getFilteredActions().find(a => - a.label.toLowerCase().includes('list') || a.id === 'wl-list' - ); - if (listAction) { - const result = await listAction.execute(); - expect(typeof result).toBe('string'); - expect(result.length).toBeGreaterThan(0); - } - }); - - it('action palette can execute wl next action', async () => { - const { ChatPane } = await import('../../packages/tui/extensions/Worklog/chatPane.js'); - const { ActionPalette } = await import('../../packages/tui/extensions/Worklog/actionPalette.js'); - const chatPane = new ChatPane(); - const palette = new ActionPalette(chatPane); - const nextAction = palette.getFilteredActions().find(a => - a.label.toLowerCase().includes('next') || a.id === 'wl-next' - ); - if (nextAction) { - const result = await nextAction.execute(); - expect(typeof result).toBe('string'); - } - }); -}); diff --git a/tests/extensions/worklog-browse-extension.test.ts b/tests/extensions/worklog-browse-extension.test.ts deleted file mode 100644 index 391c9ed8f..000000000 --- a/tests/extensions/worklog-browse-extension.test.ts +++ /dev/null @@ -1,1938 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@earendil-works/pi-coding-agent', () => ({ - getAgentDir: () => '/home/test-user/.pi/agent', -})); - -vi.mock('node:fs', () => { - const readFileSyncMock = vi.fn((path) => { - if (String(path).endsWith('shortcuts.json')) { - return JSON.stringify([ - { key: 'n', command: '/intake <id>', view: 'both', stages: ['idea'] }, - { key: 'p', command: '/plan <id>', view: 'both', stages: ['intake_complete'] }, - { key: 'i', command: '/skill:implement <id>', view: 'both', stages: ['intake_complete', 'plan_complete', 'in_progress'] }, - { key: 'a', command: '/skill:audit <id>', view: 'both', stages: ['in_progress', 'in_review'] }, - { key: 'c', command: '/intake', view: 'both', label: 'create new' }, - ]); - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - return { - readFileSync: readFileSyncMock, - writeFileSync: vi.fn(), - mkdirSync: vi.fn(), - realpathSync: vi.fn((p) => p), - existsSync: vi.fn(() => true), - statSync: vi.fn(() => ({ isDirectory: () => false })), - lstatSync: vi.fn(() => ({})), - readdirSync: vi.fn(() => []), - accessSync: vi.fn(), - watch: vi.fn(() => ({ close: vi.fn() })), - promises: {}, - constants: {}, - }; -}); - -// Mock dist/icons.js so the Worklog extension doesn't need to resolve -// the real icons module during import. The Worklog index.ts loads it via -// createRequire('../../../../dist/icons.js'); we intercept that so the -// test can run without a pre-built dist/ and without the icon loading -// path contributing to import-time memory pressure. -vi.mock('../../dist/icons.js', () => ({ - priorityIcon: vi.fn((_p, _opts) => ''), - statusIcon: vi.fn((_s, _opts) => ''), - stageIcon: vi.fn((_s, _opts) => ''), - auditIcon: vi.fn((_r, _opts) => ''), - epicIcon: vi.fn((_opts) => ''), - iconsEnabled: vi.fn(() => true), - riskIcon: vi.fn((_r, _opts) => ''), - effortIcon: vi.fn((_e, _opts) => ''), - needsProducerReviewIcon: vi.fn((_n, _opts) => ''), -})); - -import { - createDefaultListWorkItems, - createListWorkItemsWithStage, - createScrollableWidget, - createWorklogBrowseExtension, - defaultChooseWorkItem, - formatBrowseOption, - getIconPrefix, -} from '../../packages/tui/extensions/Worklog/index.ts'; -import { visibleWidth } from '../../packages/tui/extensions/Worklog/terminal-utils.ts'; -import { ShortcutRegistry } from '../../packages/tui/extensions/Worklog/shortcut-config.js'; - -/** - * Helper to create a mock for the list 'custom' render function. - */ -function makeListCustomMock() { - const componentRef: { current: any } = { current: null }; - const doneCalls: any[] = []; - let resolvePromise: (value: any) => void; - const promise = new Promise<any>((resolve) => { - resolvePromise = resolve; - }); - const custom = vi.fn((renderFn: Function) => { - const result = renderFn( - { requestRender: vi.fn(), terminal: { rows: 20 } }, - { fg: (_c: string, t: string) => t, bold: (t: string) => t }, - {}, - (val: any) => { - doneCalls.push(val); - resolvePromise(val); - }, - ); - componentRef.current = result; - return promise; - }); - return { custom, componentRef, doneCalls, promise }; -} - -describe('Worklog browse pi extension', () => { - it('formats browse options with status and producer review icons before the title (no ID)', () => { - expect(formatBrowseOption({ id: 'WL-42', title: 'Implement thing', status: 'open' })).toBe( - '🔓 ✅ Implement thing', - ); - }); - - it('truncates long title to fit width constraints', () => { - expect( - formatBrowseOption( - { id: 'WL-123456', title: 'A very long work item title that will not fit', status: 'open' }, - 24, - ), - ).toBe('🔓 ✅ A very long work …'); - }); - - it('formats epic items with epic icon and no child count when childCount is 0', () => { - expect(formatBrowseOption({ id: 'WL-99', title: 'Epic feature', status: 'open', issueType: 'epic', childCount: 0 })).toBe( - '🔓 ✅ 🏰 Epic feature', - ); - }); - - it('formats epic items with epic icon and child count when childCount > 0', () => { - expect(formatBrowseOption({ id: 'WL-99', title: 'Epic feature', status: 'open', issueType: 'epic', childCount: 5 })).toBe( - '🔓 ✅ 🏰(5) Epic feature', - ); - }); - - it('does not add epic icon for non-epic items', () => { - expect(formatBrowseOption({ id: 'WL-42', title: 'Regular task', status: 'open', issueType: 'feature' })).toBe( - '🔓 ✅ Regular task', - ); - }); - - it('formats epic items with fallback text when icons are disabled', () => { - const origEnv = process.env.WL_NO_ICONS; - process.env.WL_NO_ICONS = '1'; - try { - expect(formatBrowseOption({ id: 'WL-99', title: 'Epic feature', status: 'open', issueType: 'epic', childCount: 3 })).toBe( - '[OPEN] [PRODUCER_OK] [EPIC](3) Epic feature', - ); - } finally { - if (origEnv === undefined) { - delete process.env.WL_NO_ICONS; - } else { - process.env.WL_NO_ICONS = origEnv; - } - } - }); - - describe('getIconPrefix', () => { - it('returns icon prefix with status and producer review for basic item', () => { - const prefix = getIconPrefix( - { id: 'WL-1', title: 'Test', status: 'open' }, - false, // noIcons=false = use emoji - ); - expect(prefix).toBe('🔓 ✅'); - }); - - it('includes stage icon when stage is defined', () => { - const prefix = getIconPrefix( - { id: 'WL-2', title: 'Test', status: 'open', stage: 'in_progress' }, - false, - ); - expect(prefix).toBe('🔓 🛠️ ✅'); - }); - - it('includes epic icon for epic items without child count', () => { - const prefix = getIconPrefix( - { id: 'WL-3', title: 'Test', status: 'open', issueType: 'epic', childCount: 0 }, - false, - ); - expect(prefix).toBe('🔓 ✅ 🏰'); - }); - - it('includes epic icon with child count for epic items with children', () => { - const prefix = getIconPrefix( - { id: 'WL-4', title: 'Test', status: 'open', issueType: 'epic', childCount: 5 }, - false, - ); - expect(prefix).toBe('🔓 ✅ 🏰(5)'); - }); - - it('returns text-fallback icons when noIcons=true', () => { - const prefix = getIconPrefix( - { id: 'WL-5', title: 'Test', status: 'open', stage: 'plan_complete' }, - true, // noIcons=true = text fallback - ); - expect(prefix).toBe('[OPEN] [PLAN] [PRODUCER_OK]'); - }); - - it('shows in_review icon based on fresh audit pass', () => { - const prefix = getIconPrefix( - { id: 'WL-6', title: 'Test', status: 'open', stage: 'in_review', auditResult: true, auditedAt: new Date().toISOString(), updatedAt: new Date(Date.now() - 1000).toISOString() }, - false, - ); - expect(prefix).toBe('🔓 ✅ ✅'); - }); - - it('shows in_review icon based on fresh audit fail', () => { - const prefix = getIconPrefix( - { id: 'WL-7', title: 'Test', status: 'open', stage: 'in_review', auditResult: false, auditedAt: new Date().toISOString(), updatedAt: new Date(Date.now() - 1000).toISOString() }, - false, - ); - expect(prefix).toBe('🔓 ❌ ✅'); - }); - - it('shows in_review stage icon when no audit exists', () => { - const prefix = getIconPrefix( - { id: 'WL-8', title: 'Test', status: 'open', stage: 'in_review' }, - false, - ); - expect(prefix).toBe('🔓 🔍 ✅'); - }); - - it('shows stale-passed icon when audit is stale but readyToClose=true', () => { - const prefix = getIconPrefix( - { id: 'WL-9', title: 'Test', status: 'open', stage: 'in_review', auditResult: true, auditedAt: new Date(Date.now() - 120000).toISOString(), updatedAt: new Date(Date.now() - 1000).toISOString() }, - false, - ); - expect(prefix).toBe('🔓 🟩 ✅'); - }); - - it('shows stale-passed icon when audit is stale but readyToClose=true (2nd test)', () => { - // audit happened more than 60 seconds before updatedAt → stale - // auditedAt = updatedAt - 90000ms (90 seconds old) > 60s buffer → stale - const now = Date.now(); - const prefix = getIconPrefix( - { id: 'WL-10', title: 'Test', status: 'open', stage: 'in_review', auditResult: true, auditedAt: new Date(now - 90000).toISOString(), updatedAt: new Date(now - 1000).toISOString() }, - false, - ); - expect(prefix).toBe('🔓 🟩 ✅'); - }); - - it('shows in_review audit pass and producer review needed together', () => { - const prefix = getIconPrefix( - { id: 'WL-11', title: 'Test', status: 'open', stage: 'in_review', auditResult: true, auditedAt: new Date().toISOString(), updatedAt: new Date(Date.now() - 1000).toISOString(), needsProducerReview: true }, - false, - ); - // Column 2 shows ✅ (audit pass), Column 3 shows ❌ (producer review needed) - expect(prefix).toBe('🔓 ✅ ❌'); - }); - - it('shows non-in_review stage with producer review needed', () => { - const prefix = getIconPrefix( - { id: 'WL-12', title: 'Test', status: 'open', stage: 'plan_complete', needsProducerReview: true }, - false, - ); - // Column 2 shows stage icon 📋, Column 3 shows ❌ (producer review needed) - expect(prefix).toBe('🔓 📋 ❌'); - }); - - it('shows non-in_review stage with producer review complete', () => { - const prefix = getIconPrefix( - { id: 'WL-13', title: 'Test', status: 'open', stage: 'in_progress', needsProducerReview: false }, - false, - ); - // Column 2 shows stage icon 🛠️, Column 3 shows ✅ (producer review complete) - expect(prefix).toBe('🔓 🛠️ ✅'); - }); - - it('shows text-fallback for in_review with audit pass and producer review needed', () => { - const origEnv = process.env.WL_NO_ICONS; - process.env.WL_NO_ICONS = '1'; - try { - const prefix = getIconPrefix( - { id: 'WL-14', title: 'Test', status: 'open', stage: 'in_review', auditResult: true, auditedAt: new Date().toISOString(), updatedAt: new Date(Date.now() - 1000).toISOString(), needsProducerReview: true }, - true, - ); - expect(prefix).toBe('[OPEN] [YES] [NEEDS_PRODUCER]'); - } finally { - delete process.env.WL_NO_ICONS; - } - }); - - it('shows text-fallback for in_review with stale audit but readyToClose=true', () => { - const origEnv = process.env.WL_NO_ICONS; - process.env.WL_NO_ICONS = '1'; - try { - const prefix = getIconPrefix( - { id: 'WL-15', title: 'Test', status: 'open', stage: 'in_review', auditResult: true, auditedAt: new Date(Date.now() - 120000).toISOString(), updatedAt: new Date(Date.now() - 1000).toISOString() }, - true, - ); - // Stale audit + passed → show stale-passed fallback [YES_STALE], producer review not needed → [PRODUCER_OK] - expect(prefix).toBe('[OPEN] [YES_STALE] [PRODUCER_OK]'); - } finally { - delete process.env.WL_NO_ICONS; - } - }); - }); - - describe('formatBrowseOption icon prefix alignment', () => { - it('pads icon prefix to specified prefixWidth for alignment', () => { - const item = { id: 'WL-1', title: 'Simple task', status: 'open' }; - // Default (no prefixWidth): no padding (backward compatible) - const noPad = formatBrowseOption(item); - expect(noPad).toBe('🔓 ✅ Simple task'); - - // With prefixWidth larger than natural icon width: pads with spaces - // natural visibleWidth of '🔓 ✅' = 5 (🔓=2, space=1, ✅=2), prefixWidth = 6 → pad(6-5)=1 → repeat(1+1)=2 spaces - const padded = formatBrowseOption(item, undefined, undefined, undefined, 6); - expect(padded).toBe('🔓 ✅ Simple task'); - }); - - it('does not add extra padding when prefixWidth equals natural width', () => { - const item = { id: 'WL-1', title: 'Task', status: 'open' }; - // natural visibleWidth of '🔓 ✅' = 5 - const result = formatBrowseOption(item, undefined, undefined, undefined, 5); - expect(result).toBe('🔓 ✅ Task'); - }); - - it('does not add extra padding when prefixWidth is less than natural width', () => { - const item = { id: 'WL-1', title: 'Task', status: 'open', stage: 'in_progress' }; - // natural visibleWidth of '🔓 🛠️ ✅' = 8 (🔓=2, space=1, 🛠️=2, space=1, ✅=2) - const result = formatBrowseOption(item, undefined, undefined, undefined, 3); - expect(result).toBe('🔓 🛠️ ✅ Task'); - }); - - it('aligns titles at the same column for different icon combinations', () => { - // Items with different icon combinations should have same - // icon prefix visible width when same prefixWidth is applied. - const itemSimple = { id: 'WL-1', title: 'Simple', status: 'open' }; - const itemWithStage = { id: 'WL-2', title: 'With Stage', status: 'open', stage: 'in_progress' }; - const itemEpic = { id: 'WL-3', title: 'Epic', status: 'open', issueType: 'epic', childCount: 5 }; - - // Compute prefixWidth as max visible width across items - const noIcons = false; - const maxWidth = Math.max( - ...([itemSimple, itemWithStage, itemEpic].map(i => - visibleWidth(getIconPrefix(i, noIcons)), - )), - ); - - const result1 = formatBrowseOption(itemSimple, undefined, undefined, undefined, maxWidth); - const result2 = formatBrowseOption(itemWithStage, undefined, undefined, undefined, maxWidth); - const result3 = formatBrowseOption(itemEpic, undefined, undefined, undefined, maxWidth); - - // Verify all prefixes have the same visible width before the title - const prefix1 = result1.slice(0, result1.indexOf('Simple')); - const prefix2 = result2.slice(0, result2.indexOf('With Stage')); - const prefix3 = result3.slice(0, result3.indexOf('Epic')); - - expect(visibleWidth(prefix1)).toBe(visibleWidth(prefix2)); - expect(visibleWidth(prefix2)).toBe(visibleWidth(prefix3)); - }); - - it('pads icon prefix in text-fallback mode', () => { - const item = { id: 'WL-1', title: 'Task', status: 'open', stage: 'plan_complete' }; - const origEnv = process.env.WL_NO_ICONS; - process.env.WL_NO_ICONS = '1'; - try { - // Verify default (no prefixWidth): no padding - const natural = formatBrowseOption(item); - expect(natural).toBe('[OPEN] [PLAN] [PRODUCER_OK] Task'); - - // Pad to wider width: visibleWidth of '[OPEN] [PLAN] [PRODUCER_OK]' = 27 - // prefixWidth 30 → pad(30-27)=3 → repeat(3+1)=4 spaces - const padded = formatBrowseOption(item, undefined, undefined, undefined, 30); - expect(padded).toBe('[OPEN] [PLAN] [PRODUCER_OK] Task'); - } finally { - if (origEnv === undefined) { - delete process.env.WL_NO_ICONS; - } else { - process.env.WL_NO_ICONS = origEnv; - } - } - }); - }); - - const registerCommand = vi.fn(); - const registerShortcut = vi.fn(); - const sendMessage = vi.fn(); - - const makePi = () => ({ - registerCommand, - registerShortcut, - sendMessage, - on: vi.fn(), - }); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('registers /wl command and Ctrl+Shift+B shortcut', () => { - const extension = createWorklogBrowseExtension(); - extension(makePi() as any); - - expect(registerCommand).toHaveBeenCalledWith( - 'wl', - expect.objectContaining({ description: expect.any(String), handler: expect.any(Function) }), - ); - expect(registerShortcut).toHaveBeenCalledWith( - 'ctrl+shift+b', - expect.objectContaining({ description: expect.any(String), handler: expect.any(Function) }), - ); - }); - - it('updates above-editor widget with details each time selection changes and renders full markdown on Enter', async () => { - const listWorkItems = vi.fn().mockResolvedValue([ - { id: 'WL-1', title: 'One', status: 'open' }, - { - id: 'WL-2', - title: 'Two', - status: 'in-progress', - priority: 'high', - stage: 'plan_complete', - risk: 'Medium', - effort: 'Small', - description: 'L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8', - }, - { id: 'WL-3', title: 'Three', status: 'open' }, - { - id: 'WL-4', - title: 'Four', - status: 'blocked', - priority: 'critical', - stage: 'in_progress', - risk: 'High', - effort: 'Large', - description: 'A\nB\nC\nD\nE\nF\nG\nH\nI', - }, - { id: 'WL-5', title: 'Five', status: 'open' }, - { id: 'WL-6', title: 'Six', status: 'open' }, - ]); - - const chooseWorkItem = vi.fn(async (items, _ctx, onSelectionChange) => { - onSelectionChange(items[1]); - onSelectionChange(items[3]); - return items[3]; - }); - - const runWl = vi.fn().mockResolvedValue('## Four Details\n\nLine1\nLine2\nLine3'); - - const extension = createWorklogBrowseExtension({ listWorkItems, chooseWorkItem, runWl }); - extension(makePi() as any); - - const commandHandler = registerCommand.mock.calls.find(c => c[0] === 'wl')?.[1]?.handler; - expect(typeof commandHandler).toBe('function'); - - const notify = vi.fn(); - const setWidget = vi.fn(); - let customCallCount = 0; - const custom = vi.fn(async (renderFn) => { - customCallCount++; - if (customCallCount > 1) { - // Return shortcut on second call to break runBrowseFlow's loop - return { type: 'shortcut' as const, command: '/test-exit' }; - } - // Simulate the TUI calling the render callback - const factoryResult = renderFn( - { requestRender: vi.fn() }, - { fg: (_c: string, t: string) => t, bold: (t: string) => t }, - {}, - () => {}, - ); - // Return a thenable that resolves immediately (simulates modal close) - return Promise.resolve(factoryResult); - }); - await commandHandler('', { ui: { notify, setWidget, custom } }); - - expect(listWorkItems).toHaveBeenCalled(); - expect(chooseWorkItem).toHaveBeenCalled(); - expect(runWl).toHaveBeenCalledWith(['show', 'WL-4', '--format', 'markdown', '--no-icons'], false); - - expect(setWidget).toHaveBeenNthCalledWith(1, 'worklog-browse-selection', expect.any(Function), { placement: 'belowEditor' }); - expect(setWidget).toHaveBeenNthCalledWith(2, 'worklog-browse-selection', expect.any(Function), { placement: 'belowEditor' }); - - // Verify the factory function produces correct output for the first item (WL-1) - const factory1 = setWidget.mock.calls[0][1]; - const mockTheme1 = { fg: (_c: string, t: string) => t, bold: (t: string) => t }; - const comp1 = factory1({}, mockTheme1); - expect(comp1.render(80)).toEqual([ - 'WL-1 | tags: —', - ]); - - // The scrollable detail widget is now shown via ctx.ui.custom() for proper keyboard focus. - // custom() is called twice: once for the detail view (first iteration), - // then a shortcut result is returned (second iteration) to break the - // runBrowseFlow while(true) loop and prevent infinite loop OOM. - - // Verify that the custom() callback produces a scrollable widget component - // by re-invoking the factory logic that custom() would call. - const customCallArgs = custom.mock.calls[0]?.[0] as (tui: any, theme: any, kb: any, done: any) => any; - const fakeTui = { requestRender: vi.fn() }; - const fakeTheme = { fg: (_c: string, t: string) => t, bold: (t: string) => t }; - const comp = customCallArgs(fakeTui, fakeTheme, {}, () => {}); - expect(typeof comp.render).toBe('function'); - const renderResult = comp.render(80); - // Content lines come first; help text (shortcut hints) may follow. - expect(renderResult[0]).toBe('## Four Details'); - expect(renderResult[1]).toBe('Line1'); - expect(renderResult[2]).toBe('Line2'); - expect(renderResult[3]).toBe('Line3'); - - expect(sendMessage).not.toHaveBeenCalled(); - }); - - it('shows notification on wl show failure and keeps preview', async () => { - const listWorkItems = vi.fn().mockResolvedValue([ - { id: 'WL-1', title: 'One', status: 'open', description: 'Only' }, - ]); - - const chooseWorkItem = vi.fn(async (items, _ctx, onSelectionChange) => { - onSelectionChange(items[0]); - return items[0]; - }); - - // Custom implementation that rejects on first 'show' call, resolves - // on subsequent calls. Prevents runBrowseFlow's infinite loop. - let wlShowCount = 0; - const runWl = vi.fn(async (args: string[]) => { - if (args[0] === 'show') { - wlShowCount++; - if (wlShowCount === 1) throw new Error('not found'); - } - return 'Mock result'; - }); - - const extension = createWorklogBrowseExtension({ listWorkItems, chooseWorkItem, runWl }); - extension(makePi() as any); - - const commandHandler = registerCommand.mock.calls.find(c => c[0] === 'wl')?.[1]?.handler; - const notify = vi.fn(); - const setWidget = vi.fn(); - // Return shortcut immediately so runBrowseFlow exits after the first - // detail view attempt (which fails, but the second resolves and exits). - const custom = vi.fn(async () => { - return { type: 'shortcut' as const, command: '/test-exit' }; - }); - - await commandHandler('', { ui: { notify, setWidget, custom } }); - - expect(runWl).toHaveBeenCalledWith(['show', 'WL-1', '--format', 'markdown', '--no-icons'], false); - expect(notify).toHaveBeenCalledWith(expect.stringContaining('Failed to render work item details'), 'error'); - expect(setWidget).toHaveBeenCalledWith('worklog-browse-selection', expect.any(Function), { placement: 'belowEditor' }); - - // Verify the factory function produces correct output - const factory = setWidget.mock.calls[0][1]; - const mockTheme2 = { fg: (_c: string, t: string) => t, bold: (t: string) => t }; - const comp = factory({}, mockTheme2); - expect(comp.render(80)).toEqual([ - 'WL-1 | tags: —', - ]); - }); - it('opens browse overlay with empty list and auto-refresh instead of showing notification', async () => { - const listWorkItems = vi.fn().mockResolvedValue([]); - const chooseWorkItem = vi.fn(); - - const extension = createWorklogBrowseExtension({ listWorkItems, chooseWorkItem }); - extension(makePi() as any); - - const commandHandler = registerCommand.mock.calls.find(c => c[0] === 'wl')?.[1]?.handler; - const notify = vi.fn(); - const setWidget = vi.fn(); - - await commandHandler('', { ui: { notify, setWidget } }); - - // No notification — the overlay opens even with empty items array - expect(notify).not.toHaveBeenCalled(); - // chooseWorkItem is called with the empty array (overlay opens with auto-refresh) - expect(chooseWorkItem).toHaveBeenCalledWith([], expect.any(Object), expect.any(Function)); - // setWidget IS called with undefined AFTER chooseWorkItem completes - // (normal end-of-flow cleanup when chooseWorkItem returns undefined), - // NOT from an early return before the overlay opens. - expect(setWidget).toHaveBeenCalledWith('worklog-browse-selection', undefined); - expect(sendMessage).not.toHaveBeenCalled(); - }); - - describe('createScrollableWidget.handleInput keyboard routing', () => { - // Use content longer than viewport so scrolling has a visible effect. - // getHeight: 20 gives viewport of ~14 lines, so 50 lines scrolled by 10 - // will clearly shift the visible range. - const longContent = Array.from({ length: 50 }, (_, i) => `Line ${i + 1}`); - const makeTui = () => ({ getHeight: () => 20, requestRender: vi.fn() }); - const makeTheme = () => ({ fg: (_c: string, t: string) => t, bold: (t: string) => t }); - - it('handles Up key to scroll up by one line', () => { - const factory = createScrollableWidget(longContent); - const comp = factory(makeTui(), makeTheme()); - - // Scroll down first - comp.handleInput('\u001b[B'); // Down - expect(comp.render(80)[0]).toContain('Line 2'); - - // Scroll back up - comp.handleInput('\u001b[A'); // Up - expect(comp.render(80)[0]).toContain('Line 1'); - }); - - it('handles Down key to scroll down by one line', () => { - const factory = createScrollableWidget(longContent); - const comp = factory(makeTui(), makeTheme()); - - comp.handleInput('\u001b[B'); // Down - expect(comp.render(80)[0]).toContain('Line 2'); - }); - - it('handles Kitty arrow sequences (e.g. \x1b[1;1A/\x1b[1;1B)', () => { - const factory = createScrollableWidget(longContent); - const comp = factory(makeTui(), makeTheme()); - - comp.handleInput('\u001b[1;1B'); // Kitty Down - expect(comp.render(80)[0]).toContain('Line 2'); - - comp.handleInput('\u001b[1;1A'); // Kitty Up - expect(comp.render(80)[0]).toContain('Line 1'); - }); - - it('handles Kitty/Page key-id page up/down variants', () => { - const factory = createScrollableWidget(longContent); - const comp = factory(makeTui(), makeTheme()); - - comp.handleInput('\u001b[6;1~'); // Kitty PageDown - expect(comp.render(80)[0]).not.toBe('Line 1'); - - comp.handleInput('g'); - expect(comp.render(80)[0]).toBe('Line 1'); - - comp.handleInput('pageDown'); // normalized key-id from parseKey - expect(comp.render(80)[0]).not.toBe('Line 1'); - - comp.handleInput('pageUp'); - expect(comp.render(80)[0]).toBe('Line 1'); - }); - - it('handles g key to scroll to top', () => { - const factory = createScrollableWidget(longContent); - const comp = factory(makeTui(), makeTheme()); - - // Scroll down first - for (let i = 0; i < 10; i++) comp.handleInput('\u001b[B'); - expect(comp.render(80)[0]).not.toBe('Line 1'); - - // Go to top - comp.handleInput('g'); - expect(comp.render(80)[0]).toBe('Line 1'); - }); - - it('handles G key to scroll to bottom', () => { - const factory = createScrollableWidget(longContent); - const comp = factory(makeTui(), makeTheme()); - - // Go to bottom - comp.handleInput('G'); - const rendered = comp.render(80); - expect(rendered[rendered.length - 1].trim()).toContain('Line 50'); - }); - - it('handles PageDown and Space to scroll by viewport height', () => { - const factory = createScrollableWidget(longContent); - const comp = factory(makeTui(), makeTheme()); - - // PageDown - should jump by ~14 lines - comp.handleInput('\u001b[6~'); - const afterPageDown = comp.render(80); - expect(afterPageDown[0]).not.toBe('Line 1'); - - // Scroll back to top and test Space - comp.handleInput('g'); - expect(comp.render(80)[0]).toBe('Line 1'); - - comp.handleInput(' '); // Space should page down - const afterSpace = comp.render(80); - expect(afterSpace[0]).not.toBe('Line 1'); - }); - - it('handles PageUp to scroll up by viewport height', () => { - const factory = createScrollableWidget(longContent); - const comp = factory(makeTui(), makeTheme()); - - // Go to bottom then page up - comp.handleInput('G'); - comp.handleInput('\u001b[5~'); // PageUp - const rendered = comp.render(80); - // After G we're at bottom (around line 36-50), after PageUp we go up ~14 lines - expect(rendered[0]).not.toContain('Line 50'); - }); - - it('does not scroll above the first line', () => { - const factory = createScrollableWidget(longContent); - const comp = factory(makeTui(), makeTheme()); - - // Multiple up presses should clamp at line 1 - comp.handleInput('\u001b[A'); - comp.handleInput('\u001b[A'); - comp.handleInput('\u001b[A'); - expect(comp.render(80)[0]).toContain('Line 1'); - }); - - it('does not scroll below the last line', () => { - const factory = createScrollableWidget(longContent); - const comp = factory(makeTui(), makeTheme()); - - // Go to bottom - comp.handleInput('G'); - const rendered = comp.render(80); - expect(rendered[rendered.length - 1].trim()).toContain('Line 50'); - - // Pressing down at bottom should not go past last line - comp.handleInput('\u001b[B'); - const rendered2 = comp.render(80); - expect(rendered2[rendered2.length - 1].trim()).toContain('Line 50'); - }); - }); - - describe('createScrollableWidget viewport when getHeight is unavailable', () => { - // Simulates the real pi TUI which does NOT have getHeight(). - // The TUI exposes terminal dimensions via tui.terminal.rows. - const longContent = Array.from({ length: 50 }, (_, i) => `Line ${i + 1}`); - const makeTuiNoGetHeight = () => ({ - terminal: { rows: 24 }, - requestRender: vi.fn(), - }); - const makeTheme = () => ({ fg: (_c: string, t: string) => t, bold: (t: string) => t }); - - it('uses terminal.rows to determine viewport when getHeight is missing', () => { - const factory = createScrollableWidget(longContent); - const comp = factory(makeTuiNoGetHeight(), makeTheme()); - - // With terminal height 24, viewport should be floor(24-6) = 18 lines - const initial = comp.render(80); - expect(initial.length).toBe(18); - expect(initial[0]).toContain('Line 1'); - - // Scrolling down 20 steps with viewport of 18 → lines 21-38 - for (let i = 0; i < 20; i++) comp.handleInput('\u001b[B'); - const afterScroll = comp.render(80); - expect(afterScroll[0]).toContain('Line 21'); - expect(afterScroll[afterScroll.length - 1]).toContain('Line 38'); - }); - - it('clamps viewport at content length when content is shorter than terminal', () => { - const shortContent = ['A', 'B', 'C']; - const factory = createScrollableWidget(shortContent); - const comp = factory(makeTuiNoGetHeight(), makeTheme()); - - const rendered = comp.render(80); - expect(rendered.length).toBe(3); - expect(rendered).toEqual(['A', 'B', 'C']); - - // Scrolling should not produce empty lines - comp.handleInput('\u001b[B'); - comp.handleInput('\u001b[B'); - const clamped = comp.render(80); - expect(clamped.length).toBe(3); - }); - }); - - describe('custom() keyboard routing integration', () => { - /** - * Create a mock custom() that invokes the render callback, captures the - * returned component and the done callback, and returns it. This matches - * the real TUI where the factory is called once and the returned component - * is used for all subsequent input/render cycles. - */ - function makeCustomMock() { - const calls: Array<[Function]> = []; - const componentRef = { current: null as any }; - const doneRef = { current: null as ((value: any) => void) | null }; - // Use a terminal height that creates a reasonable viewport (e.g. ~14 visible lines) - // The real pi TUI exposes terminal dimensions via terminal.rows (not getHeight). - const terminalHeight = 20; - const fn = vi.fn(async (renderFn: Function) => { - calls.push([renderFn]); - const result = renderFn( - { - requestRender: vi.fn(), - terminal: { rows: terminalHeight }, - }, - { fg: (_c: string, t: string) => t, bold: (t: string) => t }, - {}, - (value: any) => { - doneRef.current = value; - }, - ); - componentRef.current = result; - // Return a shortcut result immediately so runBrowseFlow's while(true) - // loop exits on the first iteration (preventing infinite loop OOM). - // The component is captured in componentRef for test access. - return { type: 'shortcut' as const, command: '/test-exit' }; - }); - return { custom: fn, calls, componentRef, doneRef }; - } - - it('uses custom() to display the scrollable widget when available', async () => { - const listWorkItems = vi.fn().mockResolvedValue([ - { id: 'WL-1', title: 'One', status: 'open', description: 'Test' }, - ]); - - const chooseWorkItem = vi.fn(async (items, _ctx, onSelectionChange) => { - onSelectionChange(items[0]); - return items[0]; - }); - - const runWl = vi.fn().mockResolvedValue('# Detail\n\nSome\ncontent\nfor\ntesting'); - - const extension = createWorklogBrowseExtension({ listWorkItems, chooseWorkItem, runWl }); - extension(makePi() as any); - - const commandHandler = registerCommand.mock.calls.find(c => c[0] === 'wl')?.[1]?.handler; - - const notify = vi.fn(); - const setWidget = vi.fn(); - const { custom } = makeCustomMock(); - - await commandHandler('', { ui: { notify, setWidget, custom } }); - - // Verify custom() was called (replaces onTerminalInput approach) - expect(setWidget).toHaveBeenCalled(); // preview widget is still set - - // Verify the custom() callback returns a scrollable widget component - expect(custom.mock.calls[0]?.[0]).toBeInstanceOf(Function); - }); - - it('renders scrollable widget content correctly via custom()', async () => { - const listWorkItems = vi.fn().mockResolvedValue([ - { id: 'WL-1', title: 'One', status: 'open', description: 'Test' }, - ]); - - const chooseWorkItem = vi.fn(async (items, _ctx, onSelectionChange) => { - onSelectionChange(items[0]); - return items[0]; - }); - - const runWl = vi.fn().mockResolvedValue('L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\nL11\nL12\nL13\nL14\nL15\nL16\nL17\nL18\nL19\nL20'); - - const extension = createWorklogBrowseExtension({ listWorkItems, chooseWorkItem, runWl }); - extension(makePi() as any); - - const commandHandler = registerCommand.mock.calls.find(c => c[0] === 'wl')?.[1]?.handler; - - const notify = vi.fn(); - const setWidget = vi.fn(); - const { custom, componentRef } = makeCustomMock(); - - await commandHandler('', { ui: { notify, setWidget, custom } }); - - // The component returned by custom() is the actual widget instance - const comp = componentRef.current; - expect(typeof comp.render).toBe('function'); - expect(typeof comp.handleInput).toBe('function'); - - const initialRender = comp.render(80); - expect(initialRender[0]).toContain('L1'); - - // Test keyboard navigation on the widget component directly - comp.handleInput('\u001b[B'); // Down - expect(comp.render(80)[0]).toContain('L2'); - - comp.handleInput('\u001b[B'); // Down - expect(comp.render(80)[0]).toContain('L3'); - - comp.handleInput('\u001b[A'); // Up - expect(comp.render(80)[0]).toContain('L2'); - - comp.handleInput('g'); // go to top - expect(comp.render(80)[0]).toContain('L1'); - - comp.handleInput('G'); // go to bottom - const afterG = comp.render(80); - // The rendered output includes content lines plus optional help text - // (shortcut hints) at the end. Check that L20 appears in the rendered - // output (it's the last content line after going to bottom). - expect(afterG.some((l: string) => l.trim().includes('L20'))).toBe(true); - }); - - it('wrapper component has focused property for TUI isFocusable check', () => { - const tui = { requestRender: vi.fn(), getHeight: () => 20 }; - const theme = { fg: (_c: string, t: string) => t, bold: (t: string) => t }; - - const widget = createScrollableWidget(['L1', 'L2', 'L3'])(tui, theme); - - // Create the wrapper with the same pattern as production code - const wrapper = { - focused: false, - render: (w: number) => widget.render(w), - invalidate: () => widget.invalidate(), - handleInput: (data: string) => { - widget.handleInput(data); - tui.requestRender(); - }, - }; - - // Verify focused property exists so TUI's isFocusable() returns true - expect('focused' in wrapper).toBe(true); - expect(wrapper.focused).toBe(false); - }); - - it('Escape calls done() to close the modal', async () => { - // Directly test the wrapper logic: create a done callback, invoke the - // factory to get the widget, then press Escape and verify done() is called. - const tui = { requestRender: vi.fn(), getHeight: () => 20 }; - const theme = { fg: (_c: string, t: string) => t, bold: (t: string) => t }; - let doneCalled = false; - let doneArg: any = undefined; - const done = (value: any) => { doneCalled = true; doneArg = value; }; - - // Create the scrollable widget (same as production code) - const widget = createScrollableWidget(['L1', 'L2', 'L3'])(tui, theme); - - // Create the wrapper (same pattern as production code) - const wrapper = { - render: (w: number) => widget.render(w), - invalidate: () => widget.invalidate(), - handleInput: (data: string) => { - if (data === '\u001b' || data === 'escape') { - done(null); - return; - } - widget.handleInput(data); - tui.requestRender(); - }, - }; - - // Verify widget is created - expect(typeof wrapper.render).toBe('function'); - expect(typeof wrapper.handleInput).toBe('function'); - expect(doneCalled).toBe(false); - - // Press Escape — the wrapper should call done(null) - wrapper.handleInput('\u001b'); - expect(doneCalled).toBe(true); - expect(doneArg).toBeNull(); - - // Other keys should not trigger done - wrapper.handleInput('\u001b[B'); - // doneCalled stays true (was called by Escape, not re-triggered) - }); - - it('Escape in detail view clears the worklog-browse-selection widget', async () => { - // This test verifies the fix for WL-0MQ8KG8R2006E6BS - // When ESC is pressed in the detail view, the preview widget should be cleared - const listWorkItems = vi.fn().mockResolvedValue([ - { id: 'WL-1', title: 'One', status: 'open', description: 'Test' }, - ]); - - const chooseWorkItem = vi.fn(async (items, _ctx, onSelectionChange) => { - onSelectionChange(items[0]); - return items[0]; - }); - - const runWl = vi.fn().mockResolvedValue('# Detail\n\nContent'); - - const extension = createWorklogBrowseExtension({ listWorkItems, chooseWorkItem, runWl }); - extension(makePi() as any); - - const commandHandler = registerCommand.mock.calls.find(c => c[0] === 'wl')?.[1]?.handler; - - const notify = vi.fn(); - const setWidget = vi.fn(); - const { custom } = makeCustomMock(); - - await commandHandler('', { ui: { notify, setWidget, custom } }); - - // Find the handleInput function from the custom mock - const customCallArgs = custom.mock.calls[0]?.[0] as Function; - const tui = { requestRender: vi.fn(), getHeight: () => 20 }; - const theme = { fg: (_c: string, t: string) => t, bold: (t: string) => t }; - const comp = customCallArgs(tui, theme, {}, () => {}); - - // Press Escape - comp.handleInput('\u001b'); - - // Verify setWidget was called to clear the preview widget - expect(setWidget).toHaveBeenCalledWith('worklog-browse-selection', undefined); - }); - - it('does not use custom() when not available', async () => { - const listWorkItems = vi.fn().mockResolvedValue([ - { id: 'WL-1', title: 'One', status: 'open', description: 'Test' }, - ]); - - const chooseWorkItem = vi.fn(async (items, _ctx, onSelectionChange) => { - onSelectionChange(items[0]); - return items[0]; - }); - - const runWl = vi.fn().mockResolvedValue('Detail'); - - const extension = createWorklogBrowseExtension({ listWorkItems, chooseWorkItem, runWl }); - extension(makePi() as any); - - const commandHandler = registerCommand.mock.calls.find(c => c[0] === 'wl')?.[1]?.handler; - const notify = vi.fn(); - const setWidget = vi.fn(); - - // No custom provided - should show warning and not crash - await commandHandler('', { ui: { notify, setWidget } }); - - expect(notify).toHaveBeenCalledWith( - 'Scrollable detail view requires a TUI that supports custom overlays.', - 'warning', - ); - }); - }); - - describe('shortcut dispatch integration', () => { - /** - * Test shortcut key dispatch in the browse list view using defaultChooseWorkItem directly. - */ - it('dispatches n key as intake <id> in the browse list view', async () => { - const items = [{ id: 'WL-99', title: 'Intake me', status: 'open' }]; - - const { custom, componentRef, doneCalls } = makeListCustomMock(); - const registry = new ShortcutRegistry([ - { key: 'n', command: 'intake <id>', view: 'both' }, - ]); - const ctx: any = { ui: { custom, notify: vi.fn() } }; - - // Start defaultChooseWorkItem — it will call custom() which calls renderFn synchronously - const resultPromise = defaultChooseWorkItem(items, ctx, () => {}, registry); - // Yield to let custom() be called - await new Promise(r => setTimeout(r, 0)); - - const comp = componentRef.current; - expect(typeof comp.handleInput).toBe('function'); - - // Press 'n' — should trigger intake shortcut and return ShortcutResult - comp.handleInput('n'); - - // Verify done() was called with ShortcutResult - expect(doneCalls[0]).toEqual({ type: 'shortcut', command: 'intake WL-99' }); - // Verify the return value is the ShortcutResult (propagated through done()) - const result = await resultPromise; - expect(result).toEqual({ type: 'shortcut', command: 'intake WL-99' }); - }); - - it('shortcut result has no trailing newline for review before submission', async () => { - const items = [{ id: 'WL-ABC', title: 'Some item', status: 'open' }]; - - const { custom, componentRef, doneCalls } = makeListCustomMock(); - const registry = new ShortcutRegistry([ - { key: 'n', command: 'intake <id>', view: 'both' }, - ]); - const ctx: any = { ui: { custom, notify: vi.fn() } }; - - const resultPromise2 = defaultChooseWorkItem(items, ctx, () => {}, registry); - await new Promise(r => setTimeout(r, 0)); - - componentRef.current.handleInput('n'); - - // Verify done() was called with ShortcutResult containing the command - expect(doneCalls[0]).toEqual({ type: 'shortcut', command: 'intake WL-ABC' }); - // No trailing newline - expect(doneCalls[0].command.endsWith('\n')).toBe(false); - expect(doneCalls[0].command.endsWith('\r')).toBe(false); - // Verify the return value matches the done() call - const result2 = await resultPromise2; - expect(result2).toEqual({ type: 'shortcut', command: 'intake WL-ABC' }); - }); - - it('still navigates with up/down keys while shortcut keys trigger commands', async () => { - const items = [ - { id: 'WL-1', title: 'One', status: 'open' }, - { id: 'WL-2', title: 'Two', status: 'open' }, - { id: 'WL-3', title: 'Three', status: 'open' }, - ]; - - const { custom, componentRef, doneCalls } = makeListCustomMock(); - const registry = new ShortcutRegistry([ - { key: 'n', command: 'intake <id>', view: 'both' }, - ]); - const ctx: any = { ui: { custom, notify: vi.fn() } }; - - const resultPromise3 = defaultChooseWorkItem(items, ctx, () => {}, registry); - await new Promise(r => setTimeout(r, 0)); - - const comp = componentRef.current; - - // Press Down twice: index 0 → 1 → 2 - comp.handleInput('\u001b[B'); - comp.handleInput('\u001b[B'); - - // Now press 'n' — should use item at index 2 - comp.handleInput('n'); - - // Verify done() was called with ShortcutResult - expect(doneCalls[0]).toEqual({ type: 'shortcut', command: 'intake WL-3' }); - // Verify the return value matches - const result3 = await resultPromise3; - expect(result3).toEqual({ type: 'shortcut', command: 'intake WL-3' }); - }); - - it('dispatches n key as intake <id> in the detail scrollable view', async () => { - const setEditorText = vi.fn(); - const setWidget = vi.fn(); - const listWorkItems = vi.fn().mockResolvedValue([ - { id: 'WL-DEET', title: 'Detail item', status: 'open', description: 'test' }, - ]); - const chooseWorkItem = vi.fn(async (items, _ctx, onSelectionChange) => { - onSelectionChange(items[0]); - return items[0]; - }); - const runWl = vi.fn().mockResolvedValue('## Detail\n\nSome content'); - - const extension = createWorklogBrowseExtension({ listWorkItems, chooseWorkItem, runWl }); - extension(makePi() as any); - - const commandHandler = registerCommand.mock.calls.find(c => c[0] === 'wl')?.[1]?.handler; - - // Capture the renderFn and done result from custom() calls - const renderFnCapture: Function[] = []; - const doneResults: any[] = []; - const custom = vi.fn(async (renderFn: Function) => { - renderFnCapture.push((tui: any, theme: any, kb: any, done: any) => renderFn(tui, theme, kb, (v: any) => { doneResults.push(v); })); - return { type: 'shortcut' as const, command: '/test-exit' }; - }); - - await commandHandler('', { ui: { notify: vi.fn(), setWidget, custom, setEditorText } as any }); - - // custom() was called once (detail view; browse list bypassed by chooseWorkItem mock) - expect(custom).toHaveBeenCalledTimes(1); - - // Extract the component from the captured renderFn - const doneWrapper = (val: any) => doneResults.push(val); - const component = renderFnCapture[0]( - { requestRender: vi.fn(), terminal: { rows: 20 } }, - { fg: (_c: string, t: string) => t, bold: (t: string) => t }, - {}, - doneWrapper, - ); - - // Press 'n' in detail view — should trigger intake shortcut and return ShortcutResult - component.handleInput('n'); - - // Verify ShortcutResult was returned (caller will set editor text after modal closes) - expect(doneResults[0]).toEqual({ type: 'shortcut', command: '/intake WL-DEET' }); - // No trailing newline - expect(doneResults[0].command.endsWith('\n')).toBe(false); - expect(doneResults[0].command.endsWith('\r')).toBe(false); - }); - - it('dispatches a key as audit <id> in the browse list view', async () => { - const items = [{ id: 'WL-50', title: 'Audit me', status: 'open' }]; - - const { custom, componentRef, doneCalls } = makeListCustomMock(); - const registry = new ShortcutRegistry([ - { key: 'a', command: 'audit <id>', view: 'both' }, - ]); - const ctx: any = { ui: { custom, notify: vi.fn() } }; - - // Start defaultChooseWorkItem — it will call custom() which calls renderFn synchronously - const resultPromise4 = defaultChooseWorkItem(items, ctx, () => {}, registry); - // Yield to let custom() be called - await new Promise(r => setTimeout(r, 0)); - - const comp = componentRef.current; - expect(typeof comp.handleInput).toBe('function'); - - // Press 'a' — should trigger audit shortcut and return ShortcutResult - comp.handleInput('a'); - - // Verify done() was called with ShortcutResult - expect(doneCalls[0]).toEqual({ type: 'shortcut', command: 'audit WL-50' }); - // No trailing newline - expect(doneCalls[0].command.endsWith('\n')).toBe(false); - expect(doneCalls[0].command.endsWith('\r')).toBe(false); - // Verify return value - const result4 = await resultPromise4; - expect(result4).toEqual({ type: 'shortcut', command: 'audit WL-50' }); - }); - - it('shortcut result for audit has no trailing newline', async () => { - const items = [{ id: 'WL-AUD', title: 'Audit item', status: 'open' }]; - - const { custom, componentRef, doneCalls } = makeListCustomMock(); - const registry = new ShortcutRegistry([ - { key: 'a', command: 'audit <id>', view: 'both' }, - ]); - const ctx: any = { ui: { custom, notify: vi.fn() } }; - - const resultPromise5 = defaultChooseWorkItem(items, ctx, () => {}, registry); - await new Promise(r => setTimeout(r, 0)); - - componentRef.current.handleInput('a'); - - // Verify done() was called with ShortcutResult - expect(doneCalls[0]).toEqual({ type: 'shortcut', command: 'audit WL-AUD' }); - // No trailing newline - expect(doneCalls[0].command.endsWith('\n')).toBe(false); - expect(doneCalls[0].command.endsWith('\r')).toBe(false); - // Verify return value - const result5 = await resultPromise5; - expect(result5).toEqual({ type: 'shortcut', command: 'audit WL-AUD' }); - }); - - it('still navigates with up/down keys while a key triggers audit command', async () => { - const items = [ - { id: 'WL-1', title: 'One', status: 'open' }, - { id: 'WL-2', title: 'Two', status: 'open' }, - { id: 'WL-3', title: 'Three', status: 'open' }, - ]; - - const { custom, componentRef, doneCalls } = makeListCustomMock(); - const registry = new ShortcutRegistry([ - { key: 'a', command: 'audit <id>', view: 'both' }, - ]); - const ctx: any = { ui: { custom, notify: vi.fn() } }; - - const resultPromise6 = defaultChooseWorkItem(items, ctx, () => {}, registry); - await new Promise(r => setTimeout(r, 0)); - - const comp = componentRef.current; - - // Press Down twice: index 0 → 1 → 2 - comp.handleInput('\u001b[B'); - comp.handleInput('\u001b[B'); - - // Now press 'a' — should use item at index 2 - comp.handleInput('a'); - - // Verify done() was called with ShortcutResult - expect(doneCalls[0]).toEqual({ type: 'shortcut', command: 'audit WL-3' }); - // Verify return value - const result6 = await resultPromise6; - expect(result6).toEqual({ type: 'shortcut', command: 'audit WL-3' }); - }); - - it('dispatches a key as audit <id> in the detail scrollable view', async () => { - const setEditorText = vi.fn(); - const setWidget = vi.fn(); - const listWorkItems = vi.fn().mockResolvedValue([ - { id: 'WL-AUDIT', title: 'Audit item', status: 'open', description: 'test' }, - ]); - const chooseWorkItem = vi.fn(async (items, _ctx, onSelectionChange) => { - onSelectionChange(items[0]); - return items[0]; - }); - const runWl = vi.fn().mockResolvedValue('## Detail\n\nSome content'); - - const extension = createWorklogBrowseExtension({ listWorkItems, chooseWorkItem, runWl }); - extension(makePi() as any); - - const commandHandler = registerCommand.mock.calls.find(c => c[0] === 'wl')?.[1]?.handler; - - // Capture the renderFn and done result from custom() calls - const renderFnCapture: Function[] = []; - const doneResults: any[] = []; - const custom = vi.fn(async (renderFn: Function) => { - renderFnCapture.push((tui: any, theme: any, kb: any, done: any) => renderFn(tui, theme, kb, (v: any) => { doneResults.push(v); })); - return { type: 'shortcut' as const, command: '/test-exit' }; - }); - - await commandHandler('', { ui: { notify: vi.fn(), setWidget, custom, setEditorText } as any }); - - // custom() was called once (detail view; browse list bypassed by chooseWorkItem mock) - expect(custom).toHaveBeenCalledTimes(1); - - // Extract the component from the captured renderFn - const doneWrapper = (val: any) => doneResults.push(val); - const component = renderFnCapture[0]( - { requestRender: vi.fn(), terminal: { rows: 20 } }, - { fg: (_c: string, t: string) => t, bold: (t: string) => t }, - {}, - doneWrapper, - ); - - // Press 'a' in detail view — should trigger audit shortcut and return ShortcutResult - component.handleInput('a'); - - // Verify ShortcutResult was returned (caller will set editor text after modal closes) - expect(doneResults[0]).toEqual({ type: 'shortcut', command: '/skill:audit WL-AUDIT' }); - // No trailing newline - expect(doneResults[0].command.endsWith('\n')).toBe(false); - expect(doneResults[0].command.endsWith('\r')).toBe(false); - }); - - it('full config→load→dispatch→setEditorText flow in both views', async () => { - // Simulates the complete flow: config file loaded → ShortcutRegistry built - // → shortcut key dispatches correct command in both list and detail views. - const setEditorText = vi.fn(); - const setWidget = vi.fn(); - - // Step 1: Build registry from config entries (simulates loadShortcutConfig) - const registry = new ShortcutRegistry([ - { key: 'i', command: 'implement <id>', view: 'both' }, - { key: 'p', command: 'plan <id>', view: 'both' }, - { key: 'n', command: 'intake <id>', view: 'both' }, - { key: 'a', command: 'audit <id>', view: 'both' }, - ]); - expect(registry.getEntries()).toHaveLength(4); - - // Step 2: Verify registry resolves commands in both views - expect(registry.lookup('i', 'list')).toBe('implement <id>'); - expect(registry.lookup('i', 'detail')).toBe('implement <id>'); - expect(registry.lookup('p', 'list')).toBe('plan <id>'); - expect(registry.lookup('p', 'detail')).toBe('plan <id>'); - expect(registry.lookup('n', 'list')).toBe('intake <id>'); - expect(registry.lookup('n', 'detail')).toBe('intake <id>'); - expect(registry.lookup('a', 'list')).toBe('audit <id>'); - expect(registry.lookup('a', 'detail')).toBe('audit <id>'); - - // Step 3: Dispatch in list view - const listItems = [{ id: 'WL-LIST', title: 'List item', status: 'open' }]; - const { custom: listCustom, componentRef: listComp, doneCalls: listDone } = makeListCustomMock(); - const listCtx: any = { ui: { custom: listCustom, notify: vi.fn() } }; - - const listResultPromise = defaultChooseWorkItem(listItems, listCtx, () => {}, registry); - await new Promise(r => setTimeout(r, 0)); - - listComp.current.handleInput('i'); - expect(listDone[0]).toEqual({ type: 'shortcut', command: 'implement WL-LIST' }); - const listResult = await listResultPromise; - expect(listResult).toEqual({ type: 'shortcut', command: 'implement WL-LIST' }); - - // Step 4: Dispatch in detail view - const listWorkItems = vi.fn().mockResolvedValue([ - { id: 'WL-DEET', title: 'Detail item', status: 'open', description: 'test' }, - ]); - const chooseWorkItem = vi.fn(async (items, _ctx, onSelectionChange) => { - onSelectionChange(items[0]); - return items[0]; - }); - const runWl = vi.fn().mockResolvedValue('## Detail\n\nSome content'); - - const extension = createWorklogBrowseExtension({ listWorkItems, chooseWorkItem, runWl }); - extension(makePi() as any); - - const commandHandler = registerCommand.mock.calls.find(c => c[0] === 'wl')?.[1]?.handler; - const renderFnCapture: Function[] = []; - const doneResults: any[] = []; - const detailCustom = vi.fn(async (renderFn: Function) => { - renderFnCapture.push((tui: any, theme: any, kb: any, done: any) => renderFn(tui, theme, kb, (v: any) => { doneResults.push(v); })); - return { type: 'shortcut' as const, command: '/test-exit' }; - }); - - await commandHandler('', { ui: { notify: vi.fn(), setWidget, custom: detailCustom, setEditorText } as any }); - - expect(detailCustom).toHaveBeenCalledTimes(1); - - const detailComponent = renderFnCapture[0]( - { requestRender: vi.fn(), terminal: { rows: 20 } }, - { fg: (_c: string, t: string) => t, bold: (t: string) => t }, - {}, - () => {}, - ); - - detailComponent.handleInput('p'); - expect(doneResults[0]).toEqual({ type: 'shortcut', command: '/plan WL-DEET' }); - }); - - it('unregistered keys are no-ops in both list and detail views', async () => { - // Verify that keys not in the registry do not trigger any shortcut dispatch. - - // List view: unregistered key does not call setEditorText - const setEditorTextList = vi.fn(); - const listItems = [{ id: 'WL-X', title: 'Test', status: 'open' }]; - const { custom: listCustom2, componentRef: listComp2, doneCalls: doneCallsX } = makeListCustomMock(); - const registryX = new ShortcutRegistry([ - { key: 'i', command: 'implement <id>', view: 'both' }, - ]); - const listCtx2: any = { ui: { custom: listCustom2, setEditorText: setEditorTextList, notify: vi.fn() } }; - - const unregPromise = defaultChooseWorkItem(listItems, listCtx2, () => {}, registryX); - await new Promise(r => setTimeout(r, 0)); - - listComp2.current.handleInput('x'); - expect(setEditorTextList).not.toHaveBeenCalled(); - // Unregistered key should not trigger done() - verify promise hasn't resolved - expect(doneCallsX).toHaveLength(0); - - // Detail view: unregistered key does not call setEditorText - const setEditorTextDetail = vi.fn(); - const setWidget2 = vi.fn(); - const lw2 = vi.fn().mockResolvedValue([ - { id: 'WL-Y', title: 'Test', status: 'open', description: 'test' }, - ]); - const cw2 = vi.fn(async (items, _ctx, onSelectionChange) => { - onSelectionChange(items[0]); - return items[0]; - }); - const rw2 = vi.fn().mockResolvedValue('## Detail\n\nContent'); - - const ext2 = createWorklogBrowseExtension({ listWorkItems: lw2, chooseWorkItem: cw2, runWl: rw2 }); - ext2(makePi() as any); - - const cmdHandler2 = registerCommand.mock.calls.find(c => c[0] === 'wl')?.[1]?.handler; - const rc2: Function[] = []; - const cust2 = vi.fn(async (renderFn: Function) => { - rc2.push(renderFn); - return { type: 'shortcut' as const, command: '/test-exit' }; - }); - - await cmdHandler2('', { ui: { notify: vi.fn(), setWidget: setWidget2, custom: cust2, setEditorText: setEditorTextDetail } as any }); - - const detailComp2 = rc2[0]( - { requestRender: vi.fn(), terminal: { rows: 20 } }, - { fg: (_c: string, t: string) => t, bold: (t: string) => t }, - {}, - () => {}, - ); - - detailComp2.handleInput('z'); - // setEditorTextDetail is called once when runBrowseFlow exits the - // loop (custom mock returns a shortcut result to break the loop). - // The 'z' key press itself does not trigger setEditorText. - expect(setEditorTextDetail).toHaveBeenCalledTimes(1); - }); - - it('navigation keys remain functional in the presence of shortcuts', async () => { - // Regression test: confirms that all existing navigation keys continue to work - // correctly after the dynamic dispatcher was introduced. - - // List view: navigation keys (Up/Down) work alongside shortcuts - const setEditorText = vi.fn(); - const testItems = [ - { id: 'WL-1', title: 'One', status: 'open' }, - { id: 'WL-2', title: 'Two', status: 'open' }, - { id: 'WL-3', title: 'Three', status: 'open' }, - ]; - const testRegistry = new ShortcutRegistry([ - { key: 'i', command: 'implement <id>', view: 'both' }, - ]); - - const { custom: navListCustom, componentRef: navListComp, doneCalls: navDoneCalls } = makeListCustomMock(); - const navListCtx: any = { ui: { custom: navListCustom, notify: vi.fn() } }; - - const navResultPromise = defaultChooseWorkItem(testItems, navListCtx, () => {}, testRegistry); - await new Promise(r => setTimeout(r, 0)); - - const navComp = navListComp.current; - // Navigate down twice: index 0 → 1 → 2 - navComp.handleInput('\u001b[B'); // → index 1 - navComp.handleInput('\u001b[B'); // → index 2 - // Navigate up once: index 2 → 1 - navComp.handleInput('\u001b[A'); - // Press shortcut 'i' - should dispatch for item at index 1 - navComp.handleInput('i'); - expect(navDoneCalls[0]).toEqual({ type: 'shortcut', command: 'implement WL-2' }); - const navResult = await navResultPromise; - expect(navResult).toEqual({ type: 'shortcut', command: 'implement WL-2' }); - - // Detail view: scrollable widget handles PageUp/PageDown/g/G - const tui = { requestRender: vi.fn(), getHeight: () => 20 }; - const theme = { fg: (_c: string, t: string) => t, bold: (t: string) => t }; - - const scrollItems = Array.from({ length: 30 }, (_, i) => `Line ${i + 1}`); - const widget = createScrollableWidget(scrollItems)(tui, theme); - - // g → top - widget.handleInput('g'); - expect(widget.render(80)[0]).toBe('Line 1'); - - // G → bottom - widget.handleInput('G'); - expect(widget.render(80).at(-1)?.trim()).toContain('Line 30'); - - // Space (PageDown) - widget.handleInput('g'); // back to top - widget.handleInput(' '); - expect(widget.render(80)[0]).not.toBe('Line 1'); - - // PageUp - widget.handleInput('\u001b[5~'); - expect(widget.render(80)[0]).not.toBe('Line 30'); - }); - - it('Enter key selects a work item and returns it from defaultChooseWorkItem', async () => { - const items = [ - { id: 'WL-E1', title: 'First', status: 'open' }, - { id: 'WL-E2', title: 'Second', status: 'open' }, - ]; - - const { custom, componentRef, doneCalls } = makeListCustomMock(); - const ctx: any = { ui: { custom, notify: vi.fn() } }; - const onSelectionChange = vi.fn(); - - const enterPromise = defaultChooseWorkItem(items, ctx, onSelectionChange); - await new Promise(r => setTimeout(r, 0)); - - // Navigate down and press Enter - componentRef.current.handleInput('\u001b[B'); - componentRef.current.handleInput('\r'); - - // done() should have been called with the selected item - expect(doneCalls[0]).toEqual(items[1]); - // onSelectionChange should have been called for the navigation - expect(onSelectionChange).toHaveBeenCalledWith(items[1]); - // Return value should be the selected work item - const enterResult = await enterPromise; - expect(enterResult).toEqual(items[1]); - }); - - it('Escape key cancels and returns undefined from defaultChooseWorkItem', async () => { - const items = [{ id: 'WL-C1', title: 'Cancel test', status: 'open' }]; - - const { custom, componentRef, doneCalls } = makeListCustomMock(); - const ctx: any = { ui: { custom, notify: vi.fn() } }; - - const escapePromise = defaultChooseWorkItem(items, ctx, vi.fn()); - await new Promise(r => setTimeout(r, 0)); - - componentRef.current.handleInput('\u001b'); - - // done() should have been called with null - expect(doneCalls[0]).toBeNull(); - // Return value should be undefined (null ?? undefined) - const escapeResult = await escapePromise; - expect(escapeResult).toBeUndefined(); - }); - - describe('navigation key protection (WL-0MQDR4V7O007O7TZ)', () => { - it('browse list: reserved navigation key g does not dispatch shortcut', async () => { - // If a shortcut is configured for 'g' in the browse list, it should be - // ignored because 'g' is a reserved navigation key. - const items = [{ id: 'WL-G', title: 'G item', status: 'open' }]; - const { custom, componentRef, doneCalls } = makeListCustomMock(); - const registry = new ShortcutRegistry([ - { key: 'g', command: 'go <id>', view: 'both' }, - ]); - const ctx: any = { ui: { custom, notify: vi.fn() } }; - - const resultPromise = defaultChooseWorkItem(items, ctx, () => {}, registry); - await new Promise(r => setTimeout(r, 0)); - - // Press 'g' - should NOT trigger shortcut since it's reserved - componentRef.current.handleInput('g'); - - // done() should NOT have been called (g is not enter/escape) - expect(doneCalls).toHaveLength(0); - }); - - it('detail view: reserved navigation key g does not dispatch shortcut', async () => { - // Use a custom registry that HAS a shortcut configured for 'g'. - // The defensive set should prevent it from dispatching. - const navRegistry = new ShortcutRegistry([ - { key: 'g', command: 'g-command <id>', view: 'detail' }, - ]); - const setEditorText = vi.fn(); - const setWidget = vi.fn(); - const listWorkItems = vi.fn().mockResolvedValue([ - { id: 'WL-G', title: 'G item', status: 'open', description: 'test' }, - ]); - const chooseWorkItem = vi.fn(async (items, _ctx, onSelectionChange) => { - onSelectionChange(items[0]); - return items[0]; - }); - const runWl = vi.fn().mockResolvedValue('## Detail\n\nContent'); - - const extension = createWorklogBrowseExtension({ listWorkItems, chooseWorkItem, runWl, shortcutRegistry: navRegistry }); - extension(makePi() as any); - - const commandHandler = registerCommand.mock.calls.find(c => c[0] === 'wl')?.[1]?.handler; - - const renderFnCapture: Function[] = []; - const doneResults: any[] = []; - const custom = vi.fn(async (renderFn: Function) => { - renderFnCapture.push((tui: any, theme: any, kb: any, done: any) => renderFn(tui, theme, kb, (v: any) => { doneResults.push(v); })); - return { type: 'shortcut' as const, command: '/test-exit' }; - }); - - await commandHandler('', { ui: { notify: vi.fn(), setWidget, custom, setEditorText } as any }); - - expect(custom).toHaveBeenCalledTimes(1); - - const component = renderFnCapture[0]( - { requestRender: vi.fn(), terminal: { rows: 20 } }, - { fg: (_c: string, t: string) => t, bold: (t: string) => t }, - {}, - () => {}, - ); - - // Press 'g' in detail view — should NOT trigger shortcut (g is reserved for scroll-to-top) - component.handleInput('g'); - expect(doneResults).toHaveLength(0); - }); - - it('detail view: reserved navigation key G does not dispatch shortcut', async () => { - // Use a custom registry that HAS a shortcut configured for 'G' - const navRegistry = new ShortcutRegistry([ - { key: 'G', command: 'G-command <id>', view: 'detail' }, - ]); - const setEditorText = vi.fn(); - const setWidget = vi.fn(); - const listWorkItems = vi.fn().mockResolvedValue([ - { id: 'WL-GCAP', title: 'G cap item', status: 'open', description: 'test' }, - ]); - const chooseWorkItem = vi.fn(async (items, _ctx, onSelectionChange) => { - onSelectionChange(items[0]); - return items[0]; - }); - const runWl = vi.fn().mockResolvedValue('## Detail\n\nContent'); - - const extension = createWorklogBrowseExtension({ listWorkItems, chooseWorkItem, runWl, shortcutRegistry: navRegistry }); - extension(makePi() as any); - - const commandHandler = registerCommand.mock.calls.find(c => c[0] === 'wl')?.[1]?.handler; - - const renderFnCapture: Function[] = []; - const doneResults: any[] = []; - const custom = vi.fn(async (renderFn: Function) => { - renderFnCapture.push((tui: any, theme: any, kb: any, done: any) => renderFn(tui, theme, kb, (v: any) => { doneResults.push(v); })); - return { type: 'shortcut' as const, command: '/test-exit' }; - }); - - await commandHandler('', { ui: { notify: vi.fn(), setWidget, custom, setEditorText } as any }); - - expect(custom).toHaveBeenCalledTimes(1); - - const component = renderFnCapture[0]( - { requestRender: vi.fn(), terminal: { rows: 20 } }, - { fg: (_c: string, t: string) => t, bold: (t: string) => t }, - {}, - () => {}, - ); - - // Press 'G' in detail view — should NOT trigger shortcut (G is reserved for scroll-to-bottom) - component.handleInput('G'); - expect(doneResults).toHaveLength(0); - }); - - it('detail view: reserved navigation key space does not dispatch shortcut', async () => { - // Use a custom registry that HAS a shortcut configured for space - const navRegistry = new ShortcutRegistry([ - { key: ' ', command: 'space-command <id>', view: 'detail' }, - ]); - const setEditorText = vi.fn(); - const setWidget = vi.fn(); - const listWorkItems = vi.fn().mockResolvedValue([ - { id: 'WL-SP', title: 'Space item', status: 'open', description: 'test' }, - ]); - const chooseWorkItem = vi.fn(async (items, _ctx, onSelectionChange) => { - onSelectionChange(items[0]); - return items[0]; - }); - const runWl = vi.fn().mockResolvedValue('## Detail\n\nContent'); - - const extension = createWorklogBrowseExtension({ listWorkItems, chooseWorkItem, runWl, shortcutRegistry: navRegistry }); - extension(makePi() as any); - - const commandHandler = registerCommand.mock.calls.find(c => c[0] === 'wl')?.[1]?.handler; - - const renderFnCapture: Function[] = []; - const doneResults: any[] = []; - const custom = vi.fn(async (renderFn: Function) => { - renderFnCapture.push((tui: any, theme: any, kb: any, done: any) => renderFn(tui, theme, kb, (v: any) => { doneResults.push(v); })); - return { type: 'shortcut' as const, command: '/test-exit' }; - }); - - await commandHandler('', { ui: { notify: vi.fn(), setWidget, custom, setEditorText } as any }); - - expect(custom).toHaveBeenCalledTimes(1); - - const component = renderFnCapture[0]( - { requestRender: vi.fn(), terminal: { rows: 20 } }, - { fg: (_c: string, t: string) => t, bold: (t: string) => t }, - {}, - () => {}, - ); - - // Press space in detail view — should NOT trigger shortcut (space is reserved for page down) - component.handleInput(' '); - expect(doneResults).toHaveLength(0); - }); - - it('non-navigation keys still dispatch shortcuts despite reserved set', async () => { - // Regression: non-navigation single-char keys should still dispatch - const items = [{ id: 'WL-MIX', title: 'Mixed', status: 'open' }]; - const { custom, componentRef, doneCalls } = makeListCustomMock(); - const registry = new ShortcutRegistry([ - { key: 'i', command: 'implement <id>', view: 'both' }, - { key: 'g', command: 'go <id>', view: 'both' }, - { key: ' ', command: 'space <id>', view: 'both' }, - ]); - const ctx: any = { ui: { custom, notify: vi.fn() } }; - - const resultPromise = defaultChooseWorkItem(items, ctx, () => {}, registry); - await new Promise(r => setTimeout(r, 0)); - - // Press 'i' — should still dispatch (i is NOT a reserved navigation key) - componentRef.current.handleInput('i'); - - expect(doneCalls[0]).toEqual({ type: 'shortcut', command: 'implement WL-MIX' }); - const result = await resultPromise; - expect(result).toEqual({ type: 'shortcut', command: 'implement WL-MIX' }); - }); - }); - }); - - describe('Stage filtering via /wl <stage>', () => { - const makeStageTestPi = () => { - const registerCommand = vi.fn(); - const registerShortcut = vi.fn(); - const sendMessage = vi.fn(); - return { - registerCommand, - registerShortcut, - sendMessage, - on: vi.fn(), - }; - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('createListWorkItemsWithStage runs wl next -n 5 --stage', async () => { - const runWl = vi.fn().mockResolvedValue(JSON.stringify({ - success: true, - count: 1, - results: [{ workItem: { id: 'WL-S1', title: 'Stage item', status: 'open', stage: 'in_review' } }], - })); - - const listFn = createListWorkItemsWithStage(runWl as any); - const items = await listFn('in_review'); - - expect(runWl).toHaveBeenCalledWith(['next', '-n', '5', '--stage', 'in_review', '--include-in-progress']); - expect(items).toEqual([ - { id: 'WL-S1', title: 'Stage item', status: 'open', stage: 'in_review' }, - ]); - }); - - it('handler with no args uses default listWorkItems (backward compatibility)', async () => { - const pi = makeStageTestPi(); - const listWorkItems = vi.fn().mockResolvedValue([ - { id: 'WL-D', title: 'Default', status: 'open' }, - ]); - const chooseWorkItem = vi.fn(); - const extension = createWorklogBrowseExtension({ listWorkItems, chooseWorkItem }); - extension(pi as any); - - const handler = pi.registerCommand.mock.calls.find((c: any) => c[0] === 'wl')?.[1]?.handler; - expect(typeof handler).toBe('function'); - - const notify = vi.fn(); - await handler('', { ui: { notify } }); - - expect(listWorkItems).toHaveBeenCalledTimes(1); - expect(chooseWorkItem).toHaveBeenCalledTimes(1); - }); - - it('handler with shorthand stage calls listWorkItemsWithStage with canonical name', async () => { - const pi = makeStageTestPi(); - const listWorkItems = vi.fn().mockResolvedValue([]); - const listWorkItemsWithStage = vi.fn().mockResolvedValue([ - { id: 'WL-P', title: 'In Progress', status: 'in-progress', stage: 'in_progress' }, - ]); - const chooseWorkItem = vi.fn(); - const extension = createWorklogBrowseExtension({ listWorkItems, listWorkItemsWithStage, chooseWorkItem }); - extension(pi as any); - - const handler = pi.registerCommand.mock.calls.find((c: any) => c[0] === 'wl')?.[1]?.handler; - - const notify = vi.fn(); - await handler('progress', { ui: { notify } }); - - expect(listWorkItemsWithStage).toHaveBeenCalledWith('in_progress'); - expect(listWorkItems).not.toHaveBeenCalled(); - expect(chooseWorkItem).toHaveBeenCalledTimes(1); - }); - - it('handler with canonical stage name calls listWorkItemsWithStage with that stage', async () => { - const pi = makeStageTestPi(); - const listWorkItemsWithStage = vi.fn().mockResolvedValue([ - { id: 'WL-R', title: 'In Review', status: 'open', stage: 'in_review' }, - ]); - const chooseWorkItem = vi.fn(); - const extension = createWorklogBrowseExtension({ listWorkItemsWithStage, chooseWorkItem }); - extension(pi as any); - - const handler = pi.registerCommand.mock.calls.find((c: any) => c[0] === 'wl')?.[1]?.handler; - - const notify = vi.fn(); - await handler('in_review', { ui: { notify } }); - - expect(listWorkItemsWithStage).toHaveBeenCalledWith('in_review'); - expect(chooseWorkItem).toHaveBeenCalledTimes(1); - }); - - it('handler with whitespace-only args falls back to default unfiltered list', async () => { - const pi = makeStageTestPi(); - const listWorkItems = vi.fn().mockResolvedValue([ - { id: 'WL-W', title: 'Whitespace', status: 'open' }, - ]); - const listWorkItemsWithStage = vi.fn(); - const chooseWorkItem = vi.fn(); - const extension = createWorklogBrowseExtension({ listWorkItems, listWorkItemsWithStage, chooseWorkItem }); - extension(pi as any); - - const handler = pi.registerCommand.mock.calls.find((c: any) => c[0] === 'wl')?.[1]?.handler; - - const notify = vi.fn(); - await handler(' ', { ui: { notify } }); - - expect(listWorkItems).toHaveBeenCalledTimes(1); - expect(listWorkItemsWithStage).not.toHaveBeenCalled(); - expect(chooseWorkItem).toHaveBeenCalledTimes(1); - expect(notify).not.toHaveBeenCalled(); - }); - - it('handler with invalid stage shows error notification and falls back to default', async () => { - const pi = makeStageTestPi(); - const listWorkItems = vi.fn().mockResolvedValue([ - { id: 'WL-X', title: 'Fallback', status: 'open' }, - ]); - const listWorkItemsWithStage = vi.fn(); - const chooseWorkItem = vi.fn(); - const extension = createWorklogBrowseExtension({ listWorkItems, listWorkItemsWithStage, chooseWorkItem }); - extension(pi as any); - - const handler = pi.registerCommand.mock.calls.find((c: any) => c[0] === 'wl')?.[1]?.handler; - - const notify = vi.fn(); - await handler('bogus', { ui: { notify } }); - - expect(notify).toHaveBeenCalledWith("Unknown stage value: 'bogus'", 'error'); - expect(listWorkItems).toHaveBeenCalledTimes(1); - expect(listWorkItemsWithStage).not.toHaveBeenCalled(); - expect(chooseWorkItem).toHaveBeenCalledTimes(1); - }); - - it('handler returns ShortcutResult when a shortcut key is pressed in filtered view', async () => { - const pi = makeStageTestPi(); - const listWorkItemsWithStage = vi.fn().mockResolvedValue([ - { id: 'WL-P', title: 'In Progress', status: 'in-progress', stage: 'in_progress' }, - { id: 'WL-P2', title: 'In Progress 2', status: 'in-progress', stage: 'in_progress' }, - ]); - - // Simulate the chooseWorkItem returning a ShortcutResult (e.g., from pressing 'i') - const chooseWorkItem = vi.fn().mockResolvedValue({ - type: 'shortcut', - command: 'implement WL-P', - }); - - const extension = createWorklogBrowseExtension({ listWorkItemsWithStage, chooseWorkItem }); - extension(pi as any); - - const handler = pi.registerCommand.mock.calls.find((c: any) => c[0] === 'wl')?.[1]?.handler; - - const notify = vi.fn(); - const setEditorText = vi.fn(); - await handler('progress', { ui: { notify, setEditorText } }); - - // Shortcut result should set editor text - expect(setEditorText).toHaveBeenCalledWith('implement WL-P'); - }); - - it('getArgumentCompletions returns sorted stage values including shorthands, canonical names, and settings', () => { - const pi = makeStageTestPi(); - const extension = createWorklogBrowseExtension(); - extension(pi as any); - - const commandOpts = pi.registerCommand.mock.calls.find((c: any) => c[0] === 'wl')?.[1]; - const completions = commandOpts.getArgumentCompletions(''); - - expect(completions).toEqual([ - { value: 'idea', label: 'idea' }, - { value: 'in_progress', label: 'in_progress' }, - { value: 'in_review', label: 'in_review' }, - { value: 'intake', label: 'intake' }, - { value: 'intake_complete', label: 'intake_complete' }, - { value: 'plan', label: 'plan' }, - { value: 'plan_complete', label: 'plan_complete' }, - { value: 'progress', label: 'progress' }, - { value: 'review', label: 'review' }, - { value: 'schedule', label: 'schedule' }, - { value: 'settings', label: 'settings' }, - ]); - }); - - it('getArgumentCompletions filters by prefix and includes settings match', () => { - const pi = makeStageTestPi(); - const extension = createWorklogBrowseExtension(); - extension(pi as any); - - const commandOpts = pi.registerCommand.mock.calls.find((c: any) => c[0] === 'wl')?.[1]; - - // Filter by 'in_' - const completions = commandOpts.getArgumentCompletions('in_'); - expect(completions).toEqual([ - { value: 'in_progress', label: 'in_progress' }, - { value: 'in_review', label: 'in_review' }, - ]); - - // Filter by 'int' - const intakeCompletions = commandOpts.getArgumentCompletions('int'); - expect(intakeCompletions).toEqual([ - { value: 'intake', label: 'intake' }, - { value: 'intake_complete', label: 'intake_complete' }, - ]); - - // Filter by 'set' should return settings - const settingsCompletions = commandOpts.getArgumentCompletions('set'); - expect(settingsCompletions).toEqual([ - { value: 'settings', label: 'settings' }, - ]); - }); - - it('getArgumentCompletions returns null when no completion matches prefix', () => { - const pi = makeStageTestPi(); - const extension = createWorklogBrowseExtension(); - extension(pi as any); - - const commandOpts = pi.registerCommand.mock.calls.find((c: any) => c[0] === 'wl')?.[1]; - const completions = commandOpts.getArgumentCompletions('zzz'); - expect(completions).toBeNull(); - }); - - it('description is updated to reflect stage filtering capability', () => { - const pi = makeStageTestPi(); - const extension = createWorklogBrowseExtension(); - extension(pi as any); - - const commandOpts = pi.registerCommand.mock.calls.find((c: any) => c[0] === 'wl')?.[1]; - expect(commandOpts.description).toContain('filtered by stage'); - }); - - }); - - it('uses wl next -n 5 and parses results.workItem payload', async () => { - const runWl = vi.fn().mockResolvedValue(`{\n "success": true,\n "count": 2,\n "results": [\n { "workItem": { "id": "WL-10", "title": "Ten", "status": "open", "priority": "medium", "stage": "idea", "risk": "Low", "effort": "Small", "description": "alpha\\nbeta" } },\n { "workItem": { "id": "WL-11", "title": "Eleven", "status": "blocked", "priority": "high", "stage": "in_progress", "risk": "High", "effort": "Large", "description": "gamma\\ndelta" } }\n ]\n}`); - - const listWorkItems = createDefaultListWorkItems(runWl as any); - const items = await listWorkItems(); - - expect(runWl).toHaveBeenCalledWith(['next', '-n', '5', '--include-in-progress']); - // Every displayed item receives a group assignment after regrouping - // (WL-0MSAK8YLB0025EGW): the idea item lands in "Idea", the in_progress - // item in "Other". - expect(items).toEqual([ - { - id: 'WL-10', - title: 'Ten', - status: 'open', - priority: 'medium', - stage: 'idea', - risk: 'Low', - effort: 'Small', - description: 'alpha\nbeta', - group: 1, - groupLabel: 'Idea', - }, - { - id: 'WL-11', - title: 'Eleven', - status: 'blocked', - priority: 'high', - stage: 'in_progress', - risk: 'High', - effort: 'Large', - description: 'gamma\ndelta', - group: 2, - groupLabel: 'Other', - }, - ]); - }); -}); diff --git a/tests/grouping-utility.test.ts b/tests/grouping-utility.test.ts index e975ffddb..2730c7b59 100644 --- a/tests/grouping-utility.test.ts +++ b/tests/grouping-utility.test.ts @@ -293,8 +293,9 @@ describe('assignItemGroups — new group order', () => { { id: 'WL-C1', stage: 'plan_complete', filePaths: ['src/c.ts'], priority: 'critical' }, { id: 'WL-P1', stage: 'plan_complete', filePaths: ['src/p1.ts'], priority: 'high' }, { id: 'WL-I1', stage: 'intake_complete', filePaths: ['src/i1.ts'], priority: 'medium' }, + { id: 'WL-IP1', stage: 'in_progress', filePaths: ['src/ip1.ts'], priority: 'high' }, { id: 'WL-idea', stage: 'idea', filePaths: [], priority: 'low' }, - { id: 'WL-other', stage: 'in_progress', filePaths: [], priority: 'medium' }, + { id: 'WL-other', stage: undefined, filePaths: [], priority: 'medium' }, { id: 'WL-R1', stage: 'in_review', filePaths: [], priority: 'medium' }, ]; const groups = assignItemGroups(items, 3); @@ -302,9 +303,10 @@ describe('assignItemGroups — new group order', () => { // Critical group comes first. expect(groups.get('WL-C1')!.groupLabel).toBe('Critical Group 1'); expect(groupOf('WL-C1')).toBeLessThan(groupOf('WL-P1')); - // Plan/intake groups come before Idea. + // Plan/intake/in_progress items share Group N (no stage prefix in the label). expect(groups.get('WL-P1')!.groupLabel).toBe('Group 1'); expect(groups.get('WL-I1')!.groupLabel).toBe('Group 1'); + expect(groups.get('WL-IP1')!.groupLabel).toBe('Group 1'); expect(groupOf('WL-P1')).toBeLessThan(groupOf('WL-idea')); // Idea before Other. expect(groupOf('WL-idea')).toBeLessThan(groupOf('WL-other')); @@ -360,7 +362,7 @@ describe('assignItemGroups — Critical Group N', () => { const items = [ { id: 'WL-C1', stage: 'idea', filePaths: [], priority: 'critical' }, { id: 'WL-idea', stage: 'idea', filePaths: [], priority: 'medium' }, - { id: 'WL-other', stage: 'in_progress', filePaths: [], priority: 'medium' }, + { id: 'WL-other', stage: 'custom', filePaths: [], priority: 'medium' }, ]; const groups = assignItemGroups(items, 3); expect(groups.get('WL-C1')!.groupLabel).toBe('Critical Group 1'); @@ -372,7 +374,7 @@ describe('assignItemGroups — Critical Group N', () => { }); }); -describe('assignItemGroups — Group N (plan_complete + intake_complete)', () => { +describe('assignItemGroups — Group N (in_progress + plan_complete + intake_complete)', () => { it('partitions plan_complete and intake_complete items into Group N labels (no stage prefix)', () => { const items = [ { id: 'WL-P1', stage: 'plan_complete', filePaths: ['src/a.ts'], priority: 'high' }, @@ -385,6 +387,21 @@ describe('assignItemGroups — Group N (plan_complete + intake_complete)', () => expect(groups.get('WL-P2')!.groupLabel).toBe('Group 2'); }); + it('includes in_progress items in Group N alongside plan_complete/intake_complete', () => { + const items = [ + { id: 'WL-P1', stage: 'plan_complete', filePaths: ['src/a.ts'], priority: 'high' }, + { id: 'WL-IP1', stage: 'in_progress', filePaths: ['src/b.ts'], priority: 'high' }, + { id: 'WL-I1', stage: 'intake_complete', filePaths: ['src/c.ts'], priority: 'medium' }, + // Conflicts with WL-P1 via src/a.ts → lands in a different Group N. + { id: 'WL-IP2', stage: 'in_progress', filePaths: ['src/a.ts'], priority: 'medium' }, + ]; + const groups = assignItemGroups(items, 3); + expect(groups.get('WL-P1')!.groupLabel).toBe('Group 1'); + expect(groups.get('WL-IP1')!.groupLabel).toBe('Group 1'); + expect(groups.get('WL-I1')!.groupLabel).toBe('Group 1'); + expect(groups.get('WL-IP2')!.groupLabel).toBe('Group 2'); + }); + it('does not emit "Plan Complete Group" or "Intake Complete" labels', () => { const items = [ { id: 'WL-P1', stage: 'plan_complete', filePaths: ['src/a.ts'], priority: 'high' }, @@ -412,8 +429,9 @@ describe('assignItemGroups — Idea / Other / In Review', () => { }); it('places all remaining items in a single "Other" group', () => { + // `done` and unknown/custom stages fall back to "Other" (safety net). const items = [ - { id: 'WL-1', stage: 'in_progress', filePaths: [] }, + { id: 'WL-1', stage: 'done', filePaths: [] }, { id: 'WL-2', stage: undefined, filePaths: ['src/bar.ts'] }, ]; const groups = assignItemGroups(items, 3); @@ -422,10 +440,37 @@ describe('assignItemGroups — Idea / Other / In Review', () => { expect(groups.get('WL-1')!.group).toBe(groups.get('WL-2')!.group); }); + it('never places canonical stages in "Other" (in_progress joins Group N)', () => { + // Every canonical stage that can appear in the default selection list maps + // to a named group; "Other" is only the safety net for unknown/custom stages. + // (`done` items are excluded from the default list by smart selection, so + // they are not asserted here; when explicitly included they still fall back + // to "Other".) + const items = [ + { id: 'WL-idea', stage: 'idea', filePaths: ['src/idea.ts'], priority: 'medium' }, + { id: 'WL-intake', stage: 'intake_complete', filePaths: ['src/intake.ts'], priority: 'medium' }, + { id: 'WL-plan', stage: 'plan_complete', filePaths: ['src/plan.ts'], priority: 'medium' }, + { id: 'WL-progress', stage: 'in_progress', filePaths: ['src/progress.ts'], priority: 'medium' }, + { id: 'WL-review', stage: 'in_review', filePaths: ['src/review.ts'], priority: 'medium' }, + ]; + const groups = assignItemGroups(items, 3); + for (const [, assignment] of groups) { + expect(assignment.groupLabel).not.toBe('Other'); + } + // Unknown/custom stages and `done` (when explicitly included) still fall + // back to "Other" as the safety net. + const unknown = assignItemGroups([{ id: 'WL-x', stage: 'custom', filePaths: [], priority: 'medium' }], 3); + expect(unknown.get('WL-x')!.groupLabel).toBe('Other'); + const noStage = assignItemGroups([{ id: 'WL-y', stage: undefined, filePaths: [], priority: 'medium' }], 3); + expect(noStage.get('WL-y')!.groupLabel).toBe('Other'); + const done = assignItemGroups([{ id: 'WL-done', stage: 'done', filePaths: [], priority: 'medium' }], 3); + expect(done.get('WL-done')!.groupLabel).toBe('Other'); + }); + it('places all in_review items in a single "In Review" group placed after Other', () => { const items = [ { id: 'WL-R1', stage: 'in_review', filePaths: ['src/foo.ts'] }, - { id: 'WL-other', stage: 'in_progress', filePaths: [] }, + { id: 'WL-other', stage: 'done', filePaths: [] }, { id: 'WL-R2', stage: 'in_review', filePaths: ['src/bar.ts'] }, ]; const groups = assignItemGroups(items, 3); @@ -459,13 +504,15 @@ describe('assignItemGroups — Idea / Other / In Review', () => { // ── compareGroupableItems — within-group ordering ───────────────────── describe('compareGroupableItems — within-group ordering', () => { - it('orders plan_complete before intake_complete before remaining stages', () => { + it('orders in_progress before plan_complete before intake_complete before remaining stages', () => { + const progress = { id: 'IP', stage: 'in_progress', filePaths: [], priority: 'medium' }; const plan = { id: 'P', stage: 'plan_complete', filePaths: [], priority: 'medium' }; const intake = { id: 'I', stage: 'intake_complete', filePaths: [], priority: 'medium' }; - const other = { id: 'O', stage: 'in_progress', filePaths: [], priority: 'medium' }; + const other = { id: 'O', stage: 'done', filePaths: [], priority: 'medium' }; + expect(compareGroupableItems(progress, plan)).toBeLessThan(0); expect(compareGroupableItems(plan, intake)).toBeLessThan(0); expect(compareGroupableItems(intake, other)).toBeLessThan(0); - expect(compareGroupableItems(plan, other)).toBeLessThan(0); + expect(compareGroupableItems(progress, other)).toBeLessThan(0); }); it('orders by priority (high → medium → low) within the same stage sub-group', () => { @@ -503,6 +550,18 @@ describe('compareGroupableItems — within-group ordering', () => { expect(sorted.map(i => i.id)).toEqual(['P-high', 'P-med', 'I-high', 'I-low']); }); + it('sorts in_progress items to the top of a mixed group, then plan_complete, then intake_complete', () => { + const items = [ + { id: 'I-low', stage: 'intake_complete', filePaths: [], priority: 'low' }, + { id: 'P-med', stage: 'plan_complete', filePaths: [], priority: 'medium' }, + { id: 'IP-high', stage: 'in_progress', filePaths: [], priority: 'high' }, + { id: 'IP-low', stage: 'in_progress', filePaths: [], priority: 'low' }, + { id: 'P-high', stage: 'plan_complete', filePaths: [], priority: 'high' }, + ]; + const sorted = items.slice().sort(compareGroupableItems); + expect(sorted.map(i => i.id)).toEqual(['IP-high', 'IP-low', 'P-high', 'P-med', 'I-low']); + }); + it('sorts a critical group with the same stage sub-sort', () => { const items = [ { id: 'C-other', stage: 'in_progress', filePaths: [], priority: 'critical' }, @@ -510,6 +569,6 @@ describe('compareGroupableItems — within-group ordering', () => { { id: 'C-plan', stage: 'plan_complete', filePaths: [], priority: 'critical' }, ]; const sorted = items.slice().sort(compareGroupableItems); - expect(sorted.map(i => i.id)).toEqual(['C-plan', 'C-intake', 'C-other']); + expect(sorted.map(i => i.id)).toEqual(['C-other', 'C-plan', 'C-intake']); }); }); diff --git a/tests/herdr/errors.test.ts b/tests/herdr/errors.test.ts index 23db908df..1acc12028 100644 --- a/tests/herdr/errors.test.ts +++ b/tests/herdr/errors.test.ts @@ -6,7 +6,6 @@ import { describe, it, expect, vi } from 'vitest'; import { WorkItemListState, formatItemLine, - formatFilterBar, createListRenderer, handleKeypress, type WorkItem, diff --git a/tests/herdr/fetcher-memo.test.ts b/tests/herdr/fetcher-memo.test.ts new file mode 100644 index 000000000..3048c1859 --- /dev/null +++ b/tests/herdr/fetcher-memo.test.ts @@ -0,0 +1,140 @@ +/** + * tests/herdr/fetcher-memo.test.ts — In-process fetch memoization (F4) + * + * Verifies that racing identical `runWl` read fetches within one process + * share a single in-flight promise (spawning `wl` once), while sequential + * reads, writes, different args, and worklog-dir changes always spawn fresh. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { + fetchItemsByStage, + claimWorkItem, + setExecFileAsync, + resetExecFileAsync, + setWorklogDir, + resetWorklogDir, + clearFetchMemo, + _fetchMemoSize, +} from '../../packages/herdr/src/fetcher.js'; + +function deferred<T>() { + let resolve!: (v: T) => void; + const promise = new Promise<T>((res) => { resolve = res; }); + return { promise, resolve }; +} + +const EMPTY_OUTPUT = JSON.stringify({ results: [], workItems: [] }); + +function installMock() { + return vi.fn().mockResolvedValue({ stdout: EMPTY_OUTPUT }); +} + +describe('in-process fetch memoization (F4)', () => { + beforeEach(() => { + resetExecFileAsync(); + resetWorklogDir(); + clearFetchMemo(); + }); + + afterEach(() => { + clearFetchMemo(); + resetWorklogDir(); + resetExecFileAsync(); + }); + + it('AC1: two racing identical fetches spawn wl once and share the result', async () => { + const d = deferred<{ stdout: string }>(); + const mock = vi.fn().mockReturnValue(d.promise); + setExecFileAsync(mock as any); + + const p1 = fetchItemsByStage('open'); + const p2 = fetchItemsByStage('open'); // identical, concurrent + + expect(mock).toHaveBeenCalledTimes(1); // ONE spawn for both + + d.resolve({ stdout: EMPTY_OUTPUT }); + const [r1, r2] = await Promise.all([p1, p2]); + expect(r1).toEqual(r2); + }); + + it('sequential identical fetches both spawn (memo only dedupes concurrent)', async () => { + const mock = installMock(); + setExecFileAsync(mock as any); + + await fetchItemsByStage('open'); + await fetchItemsByStage('open'); + expect(mock).toHaveBeenCalledTimes(2); + }); + + it('a write between two reads prevents sharing (memo never serves across DB writes)', async () => { + const d = deferred<{ stdout: string }>(); + const mock = vi.fn().mockReturnValueOnce(d.promise).mockResolvedValue({ stdout: EMPTY_OUTPUT }); + setExecFileAsync(mock as any); + + const read1 = fetchItemsByStage('open'); // in-flight read + const claim = await claimWorkItem('WL-X', 'alice'); // write → clears memo + expect(claim.success).toBe(true); + const read2 = fetchItemsByStage('open'); // must spawn fresh, not share read1 + + expect(mock).toHaveBeenCalledTimes(3); // read + write + read + + d.resolve({ stdout: EMPTY_OUTPUT }); + await Promise.all([read1, read2]); + }); + + it('different args (different stage) are not deduplicated', async () => { + const d1 = deferred<{ stdout: string }>(); + const d2 = deferred<{ stdout: string }>(); + const mock = vi.fn().mockReturnValueOnce(d1.promise).mockReturnValueOnce(d2.promise); + setExecFileAsync(mock as any); + + const p1 = fetchItemsByStage('open'); + const p2 = fetchItemsByStage('plan_complete'); + expect(mock).toHaveBeenCalledTimes(2); + + d1.resolve({ stdout: EMPTY_OUTPUT }); + d2.resolve({ stdout: EMPTY_OUTPUT }); + await Promise.all([p1, p2]); + }); + + it('setWorklogDir changes isolate the memo (no cross-dir sharing)', async () => { + const d1 = deferred<{ stdout: string }>(); + const d2 = deferred<{ stdout: string }>(); + const mock = vi.fn().mockReturnValueOnce(d1.promise).mockReturnValueOnce(d2.promise); + setExecFileAsync(mock as any); + + setWorklogDir('/projA/.worklog'); + const p1 = fetchItemsByStage('open'); + setWorklogDir('/projB/.worklog'); // dir change clears the memo + const p2 = fetchItemsByStage('open'); // same args, different dir → fresh spawn + + expect(mock).toHaveBeenCalledTimes(2); + d1.resolve({ stdout: EMPTY_OUTPUT }); + d2.resolve({ stdout: EMPTY_OUTPUT }); + await Promise.all([p1, p2]); + }); + + it('memo stays bounded under many concurrent distinct fetches', async () => { + const deferreds: Array<ReturnType<typeof deferred<{ stdout: string }>>> = []; + const mock = vi.fn(() => { + const d = deferred<{ stdout: string }>(); + deferreds.push(d); + return d.promise; + }); + setExecFileAsync(mock as any); + + const promises = []; + for (let i = 0; i < 70; i++) { + promises.push(fetchItemsByStage(`stage-${i}`)); + } + // 70 distinct in-flight fetches: none deduped, memo capped at 64. + expect(mock).toHaveBeenCalledTimes(70); + expect(_fetchMemoSize()).toBeLessThanOrEqual(64); + + for (const d of deferreds) d.resolve({ stdout: EMPTY_OUTPUT }); + await Promise.all(promises); + expect(_fetchMemoSize()).toBe(0); // settled entries removed + }); +}); diff --git a/tests/herdr/fetcher.test.ts b/tests/herdr/fetcher.test.ts index 45e8c9930..35e040b51 100644 --- a/tests/herdr/fetcher.test.ts +++ b/tests/herdr/fetcher.test.ts @@ -277,7 +277,9 @@ describe('fetchItemsByStage', () => { const calls = mockFn.mock.calls.map((c: any) => c[1]); expect(calls).toHaveLength(1); // runWl appends --json automatically. - expect(calls[0]).toEqual(['list', '--stage', 'in_progress', '--root-only', '--json']); + // Open items only (WL-0MSDT8X1V003206G): stage-filtered worklists show + // every open root item in the stage. + expect(calls[0]).toEqual(['list', '--status', 'open', '--stage', 'in_progress', '--root-only', '--json']); }); }); diff --git a/tests/herdr/settings.test.ts b/tests/herdr/settings.test.ts index 489181179..e43cbeeae 100644 --- a/tests/herdr/settings.test.ts +++ b/tests/herdr/settings.test.ts @@ -30,8 +30,9 @@ describe('defaultSettings', () => { expect(defaultSettings.showIcons).toBe(true); }); - it('has default browseItemCount of 10', () => { - expect(defaultSettings.browseItemCount).toBe(10); + it('has default browseItemCount of 20', () => { + // Default raised 10 → 20 in WL-0MSHUDQXR009T29L (see packages/herdr/src/settings.ts) + expect(defaultSettings.browseItemCount).toBe(20); }); it('clamps browseItemCount to the [1, 50] range at load time', () => { @@ -39,7 +40,8 @@ describe('defaultSettings', () => { expect(clampBrowseItemCount(-5)).toBe(1); expect(clampBrowseItemCount(99)).toBe(50); expect(clampBrowseItemCount(25)).toBe(25); - expect(clampBrowseItemCount(NaN)).toBe(10); + // Non-finite input falls back to the default (20, not 10) + expect(clampBrowseItemCount(NaN)).toBe(20); expect(clampBrowseItemCount(2.7)).toBe(3); }); @@ -54,6 +56,27 @@ describe('defaultSettings', () => { it('has syncIntervalMs enabled by default', () => { expect(defaultSettings.syncIntervalMs).toBeGreaterThan(0); }); + + it('has downtimeEnabled enabled by default', () => { + expect(defaultSettings.downtimeEnabled).toBe(true); + }); + + it('has a 4-minute idle threshold by default', () => { + expect(defaultSettings.downtimeIdleThresholdMs).toBe(240000); + }); + + it('has downtimeRequiredFreeSlots 0 (all slots) by default', () => { + expect(defaultSettings.downtimeRequiredFreeSlots).toBe(0); + }); + + it('has a 30s poll interval by default', () => { + expect(defaultSettings.downtimePollIntervalMs).toBe(30000); + }); + + it('has the llama-proxy URL and plan model by default', () => { + expect(defaultSettings.downtimeProxyUrl).toBe('http://192.168.0.199:8000'); + expect(defaultSettings.downtimeModel).toBe('plan'); + }); }); describe('loadSettings', () => { @@ -90,6 +113,45 @@ describe('loadSettings', () => { expect(settings.browseItemCount).toBe(50); }); + it('clamps downtime settings when loading out-of-range persisted values', () => { + writeFileSync(settingsPath, JSON.stringify({ + downtimePollIntervalMs: 5000, // below the 10s floor + downtimeIdleThresholdMs: -1, // negative → default + downtimeRequiredFreeSlots: -2, // negative → 0 (all slots) + downtimeModel: '', // empty → default + }), 'utf-8'); + const settings = loadSettings(settingsPath); + expect(settings.downtimePollIntervalMs).toBe(10000); + expect(settings.downtimeIdleThresholdMs).toBe(240000); + expect(settings.downtimeRequiredFreeSlots).toBe(0); + expect(settings.downtimeModel).toBe('plan'); + }); + + it('merges partial downtime settings with defaults', () => { + writeFileSync(settingsPath, JSON.stringify({ + downtimeEnabled: false, + downtimeProxyUrl: 'http://10.0.0.5:8000', + }), 'utf-8'); + const settings = loadSettings(settingsPath); + expect(settings.downtimeEnabled).toBe(false); + expect(settings.downtimeProxyUrl).toBe('http://10.0.0.5:8000'); + expect(settings.downtimeIdleThresholdMs).toBe(240000); // from defaults + expect(settings.downtimePollIntervalMs).toBe(30000); // from defaults + expect(settings.downtimeRequiredFreeSlots).toBe(0); // from defaults + }); + + it('loads showIcons: false from the config file', () => { + writeFileSync(settingsPath, JSON.stringify({ showIcons: false }), 'utf-8'); + const settings = loadSettings(settingsPath); + expect(settings.showIcons).toBe(false); + }); + + it('defaults showIcons to true when missing from the config', () => { + writeFileSync(settingsPath, JSON.stringify({ autoRefresh: false }), 'utf-8'); + const settings = loadSettings(settingsPath); + expect(settings.showIcons).toBe(true); + }); + it('loads settings from existing file', () => { writeFileSync(settingsPath, JSON.stringify({ autoRefresh: false, @@ -147,6 +209,12 @@ describe('saveSettings', () => { syncIntervalMs: 30000, browseItemCount: 25, showHelpText: false, + downtimeEnabled: true, + downtimeIdleThresholdMs: 240000, + downtimeRequiredFreeSlots: 0, + downtimePollIntervalMs: 30000, + downtimeProxyUrl: 'http://192.168.0.199:8000', + downtimeModel: 'plan', }; saveSettings(settingsPath, settings); expect(existsSync(settingsPath)).toBe(true); diff --git a/tests/herdr/worklist.test.ts b/tests/herdr/worklist.test.ts index 67f378e0c..585129f62 100644 --- a/tests/herdr/worklist.test.ts +++ b/tests/herdr/worklist.test.ts @@ -10,7 +10,6 @@ import { StageFilter, formatItemLine, formatDetailView, - formatFilterBar, handleKeypress, createListRenderer, STAGES, @@ -393,19 +392,6 @@ describe('formatDetailView', () => { }); }); -describe('formatFilterBar', () => { - it('shows current filter when active', () => { - const bar = formatFilterBar('in_progress', 80); - expect(bar).toContain('in_progress'); - expect(bar).toContain('Filter'); - }); - - it('shows no filter message when null', () => { - const bar = formatFilterBar(null, 80); - expect(bar).toContain('No filter'); - }); -}); - describe('handleKeypress', () => { it('handles j/k navigation in list mode', () => { const state = new WorkItemListState(sampleItems, DEFAULT_TERM_SIZE); @@ -542,10 +528,12 @@ describe('createListRenderer', () => { expect(output).not.toContain('Work Items'); }); - it('renders filter bar when filter is active', () => { + it('indicates an active stage filter in the header only (no filter bar)', () => { const renderer = createListRenderer(); const output = renderer(sampleItems, 0, 0, DEFAULT_TERM_SIZE, 'in_progress', 'list', null); - expect(output).toContain('in_progress'); + const firstLine = output.split('\n')[0]; + expect(firstLine).toContain('(filtered: in_progress)'); + expect(output).not.toMatch(/Filter: /); }); it('shows total actionable count in header when totalCount > items.length', () => { diff --git a/tests/pollution-source-sweep.test.ts b/tests/pollution-source-sweep.test.ts index 5e765202e..1eb30ba0b 100644 --- a/tests/pollution-source-sweep.test.ts +++ b/tests/pollution-source-sweep.test.ts @@ -27,13 +27,13 @@ describe('shared worklog-dir resolution (pollution-source guard)', () => { // In a dir with no .worklog and no git root, the helper must return an // empty flag list (run wl as-is) rather than inventing a cross-project path. const script = ` -import sys +import sys, json sys.path.insert(0, ${JSON.stringify(path.dirname(helper))}) from status_lifecycle import worklog_dir_flag, _detect_worklog_dir detected = _detect_worklog_dir() flag = worklog_dir_flag() print("DETECTED:", detected) -print("FLAG:", flag) +print("FLAG:", json.dumps(flag)) `; const res = execFileSync('python3', ['-c', script], { cwd: tmp, @@ -41,15 +41,26 @@ print("FLAG:", flag) env: { ...process.env, PATH: process.env.PATH || '' }, }); // Either no dir is detected (temp dir is not a project), or if one is - // detected it must be inside the temp dir — never another project. + // detected it must come from the helper's cwd chain — never a + // hard-coded path to another project. const flagMatch = res.match(/FLAG: (\[.*\])/); expect(flagMatch).toBeTruthy(); if (flagMatch && flagMatch[1] !== '[]') { + // The helper emits JSON (json.dumps) so the flag list parses cleanly. const flag = JSON.parse(flagMatch[1]); expect(flag).toContain('--worklog-dir'); const dirIdx = flag.indexOf('--worklog-dir') + 1; const dir = flag[dirIdx]; - expect(dir.startsWith(tmp)).toBe(true); + // cwd-chain semantics: the helper may legitimately walk up to an + // ancestor `<parent>/.worklog` (e.g. /tmp/.worklog) or a git-root + // .worklog, so the dir must be the temp dir itself, inside it, or + // `<an ancestor of tmp>/.worklog` — never an unrelated sibling or + // another project's hard-coded path. + expect( + dir === tmp || + dir.startsWith(tmp) || + tmp.startsWith(path.dirname(dir) + path.sep), + ).toBe(true); expect(dir).not.toMatch(/SorraAgents|Tableau-Card-Engine|open_source_llm/); } } finally { diff --git a/tests/read-cache-cli.test.ts b/tests/read-cache-cli.test.ts new file mode 100644 index 000000000..e3443d4aa --- /dev/null +++ b/tests/read-cache-cli.test.ts @@ -0,0 +1,239 @@ +/** + * Unit tests for the wl CLI read-cache wiring (F2 — WL-0MSGAEC5N006W5QA). + * + * Covers: cacheable-invocation classification (command set, JSON mode, + * write-byproduct/non-deterministic flag exclusions), the ReadCacheCli + * orchestration (lookup → serve / backfill → invalidate-on-write), and the + * stable state-counter fingerprint used by the CLI. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + ReadCacheCli, + shouldCacheReadInvocation, + isCacheableReadCommand, + argvIsJsonMode, + hasNonCacheableReadFlags, + invalidateCacheForWrite, +} from '../src/read-cache-cli.js'; +import { readStateCounter, bumpStateCounter, counterFingerprint } from '../src/read-cache.js'; +import { createTempDir, cleanupTempDir } from './test-utils.js'; + +describe('shouldCacheReadInvocation', () => { + it('caches list/next/show/search/status in JSON mode (flag before or after command)', () => { + for (const cmd of ['list', 'next', 'show', 'search', 'status']) { + expect(shouldCacheReadInvocation(cmd, ['--json', cmd])).toBe(true); + expect(shouldCacheReadInvocation(cmd, [cmd, '--json'])).toBe(true); + } + }); + + it('never caches write commands even in JSON mode', () => { + for (const cmd of ['create', 'update', 'close', 'delete', 'comment', 'dep', 'import', 'sync', 'init', 'migrate', 're-sort']) { + expect(shouldCacheReadInvocation(cmd, [cmd, '--json'])).toBe(false); + } + }); + + it('requires --json (text output is env/TTY dependent)', () => { + expect(shouldCacheReadInvocation('list', ['list'])).toBe(false); + expect(shouldCacheReadInvocation('list', ['list', '-F', 'concise'])).toBe(false); + expect(shouldCacheReadInvocation('list', ['--verbose', 'list'])).toBe(false); + }); + + it('excludes write-byproduct and non-deterministic read flags', () => { + expect(shouldCacheReadInvocation('search', ['search', 'foo', '--json', '--rebuild-index'])).toBe(false); + expect(shouldCacheReadInvocation('search', ['search', 'foo', '--json', '--semantic'])).toBe(false); + expect(shouldCacheReadInvocation('search', ['search', 'foo', '--json', '--semantic-only'])).toBe(false); + // Plain search (FTS, deterministic given DB state) is cacheable. + expect(shouldCacheReadInvocation('search', ['search', 'foo', '--json'])).toBe(true); + }); + + it('treats arg variants as separate entries (full argv participates)', () => { + expect(shouldCacheReadInvocation('list', ['list', '--json'])).toBe(true); + expect(shouldCacheReadInvocation('list', ['list', '--status', 'open', '--json'])).toBe(true); + expect(shouldCacheReadInvocation('list', ['list', '--status', 'open', '-n', '1', '--json'])).toBe(true); + }); + + it('exposes the classification helpers it composes', () => { + expect(isCacheableReadCommand('list')).toBe(true); + expect(isCacheableReadCommand('create')).toBe(false); + expect(argvIsJsonMode(['list', '--json'])).toBe(true); + expect(argvIsJsonMode(['list'])).toBe(false); + expect(hasNonCacheableReadFlags(['search', '--json', '--semantic'])).toBe(true); + expect(hasNonCacheableReadFlags(['search', '--json'])).toBe(false); + }); +}); + +describe('ReadCacheCli', () => { + let state: { tempDir: string; originalCwd: string; worklogDir: string; cacheDir: string }; + let cli: ReadCacheCli; + + beforeEach(() => { + const tempDir = createTempDir(); + const originalCwd = process.cwd(); + const worklogDir = path.join(tempDir, '.worklog'); + fs.mkdirSync(worklogDir, { recursive: true }); + // A real-looking worklog DB file so fingerprints are meaningful. + fs.writeFileSync(path.join(worklogDir, 'worklog.db'), 'dummy-db-bytes', 'utf-8'); + const cacheDir = path.join(tempDir, 'cache'); + process.env.WL_CACHE_DIR = cacheDir; + process.chdir(tempDir); + state = { tempDir, originalCwd, worklogDir, cacheDir }; + cli = new ReadCacheCli(); + }); + + afterEach(() => { + delete process.env.WL_CACHE_DIR; + delete process.env.WL_SPAWN_COUNT_FILE; + delete process.env.WL_SPAWN_COUNT; + process.chdir(state.originalCwd); + cleanupTempDir(state.tempDir); + }); + + it('serves a repeat read from cache (miss then hit, byte-identical value)', () => { + const argv = ['list', '--json']; + const first = cli.lookup('list', argv); + expect(first.served).toBe(false); + cli.onJsonOutput({ success: true, count: 0, workItems: [] }); + const second = cli.lookup('list', argv); + expect(second.served).toBe(true); + expect(second.value).toEqual({ success: true, count: 0, workItems: [] }); + }); + + it('caches despite mid-action file churn (counter fingerprint ignores open/close rewrites)', () => { + // The app rewrites the SQLite files on every open/close (WAL header + // churn). A file change between lookup and output must NOT prevent + // caching — that is why the CLI uses the stable state counter. + const argv = ['list', '--json']; + cli.lookup('list', argv); + fs.writeFileSync(path.join(state.worklogDir, 'worklog.db'), 'dummy-db-bytes-changed', 'utf-8'); + cli.onJsonOutput({ success: true, count: 1, workItems: [] }); + expect(cli.lookup('list', argv).served).toBe(true); + }); + + it('does not backfill when a write bumped the state counter mid-action', () => { + const argv = ['list', '--json']; + cli.lookup('list', argv); + bumpStateCounter(state.cacheDir, state.worklogDir); // a write lands + cli.onJsonOutput({ success: true, count: 1, workItems: [] }); + const again = cli.lookup('list', argv); + expect(again.served).toBe(false); + }); + + it('separates arg variants (distinct argv → distinct entries)', () => { + cli.lookup('list', ['list', '--json']); + cli.onJsonOutput({ success: true, count: 1, workItems: [] }); + cli.lookup('list', ['list', '--status', 'open', '--json']); + cli.onJsonOutput({ success: true, count: 2, workItems: [] }); + // Each variant now hits independently. + expect(cli.lookup('list', ['list', '--json']).served).toBe(true); + expect(cli.lookup('list', ['list', '--status', 'open', '--json']).served).toBe(true); + }); + + it('invalidateOnWrite drops cached entries for the worklog dir', () => { + cli.lookup('list', ['list', '--json']); + cli.onJsonOutput({ success: true, count: 0, workItems: [] }); + expect(cli.lookup('list', ['list', '--json']).served).toBe(true); + cli.invalidateOnWrite(); + expect(cli.lookup('list', ['list', '--json']).served).toBe(false); + }); + + it('does not cache text-mode invocations', () => { + const r = cli.lookup('list', ['list']); + expect(r.served).toBe(false); + cli.onJsonOutput({ success: true, count: 0, workItems: [] }); + expect(cli.lookup('list', ['list']).served).toBe(false); + }); + + it('ignores JSON output with no armed backfill (write commands)', () => { + cli.onJsonOutput({ success: true }); + expect(cli.lookup('list', ['list', '--json']).served).toBe(false); + }); + + it('never caches write-byproduct read flags (search --rebuild-index)', () => { + const argv = ['search', 'foo', '--json', '--rebuild-index']; + expect(cli.lookup('search', argv).served).toBe(false); + cli.onJsonOutput({ success: true, count: 0, workItems: [] }); + expect(cli.lookup('search', argv).served).toBe(false); + }); + + it('tracks hit/miss stats for spawn-reduction reporting', () => { + cli.lookup('list', ['list', '--json']); // miss + cli.onJsonOutput({ success: true, count: 0, workItems: [] }); + cli.lookup('list', ['list', '--json']); // hit + const s = cli.stats(); + expect(s.hits).toBe(1); + expect(s.misses).toBe(1); + }); + + it('is immune to corrupt cache payloads (never throws, treats as miss)', () => { + const argv = ['list', '--json']; + cli.lookup('list', argv); + cli.onJsonOutput({ success: true, count: 0, workItems: [] }); + // Corrupt the stored entry behind the cache's back. + const entryDir = state.cacheDir; + const files = fs.readdirSync(entryDir).filter((f) => f.endsWith('.json')); + expect(files.length).toBeGreaterThan(0); + for (const f of files) fs.writeFileSync(path.join(entryDir, f), 'not-json{{', 'utf-8'); + expect(() => cli.lookup('list', argv)).not.toThrow(); + expect(cli.lookup('list', argv).served).toBe(false); + }); +}); + +describe('state counter', () => { + let state: { tempDir: string; cacheDir: string; worklogDir: string }; + + beforeEach(() => { + const tempDir = createTempDir(); + const worklogDir = path.join(tempDir, '.worklog'); + fs.mkdirSync(worklogDir, { recursive: true }); + state = { tempDir, cacheDir: path.join(tempDir, 'cache'), worklogDir }; + }); + + afterEach(() => { + cleanupTempDir(state.tempDir); + }); + + it('starts at 0 and increments monotonically', () => { + expect(readStateCounter(state.cacheDir, state.worklogDir)).toBe(0); + expect(bumpStateCounter(state.cacheDir, state.worklogDir)).toBe(1); + expect(bumpStateCounter(state.cacheDir, state.worklogDir)).toBe(2); + expect(readStateCounter(state.cacheDir, state.worklogDir)).toBe(2); + }); + + it('persists across cache instances (same cache dir, same worklog dir)', () => { + bumpStateCounter(state.cacheDir, state.worklogDir); + expect(readStateCounter(state.cacheDir, state.worklogDir)).toBe(1); + }); + + it('is per-worklog-dir (different dirs have independent counters)', () => { + const other = path.join(state.tempDir, 'other', '.worklog'); + bumpStateCounter(state.cacheDir, state.worklogDir); + expect(readStateCounter(state.cacheDir, other)).toBe(0); + }); + + it('counterFingerprint is stable across file churn and changes only on bumps', () => { + const fp1 = counterFingerprint(state.cacheDir, state.worklogDir); + // File churn (open/close rewrites) must not change the fingerprint... + fs.writeFileSync(path.join(state.worklogDir, 'worklog.db'), 'anything', 'utf-8'); + const fp2 = counterFingerprint(state.cacheDir, state.worklogDir); + expect(fp1).toEqual(fp2); + // ...but a write bump must. + bumpStateCounter(state.cacheDir, state.worklogDir); + const fp3 = counterFingerprint(state.cacheDir, state.worklogDir); + expect(fp3).not.toEqual(fp2); + }); + + it('invalidateCacheForWrite bumps the counter for the resolved worklog dir', () => { + process.chdir(state.tempDir); + process.env.WL_CACHE_DIR = state.cacheDir; + try { + expect(readStateCounter(state.cacheDir, state.worklogDir)).toBe(0); + invalidateCacheForWrite(); + expect(readStateCounter(state.cacheDir, state.worklogDir)).toBe(1); + } finally { + delete process.env.WL_CACHE_DIR; + } + }); +}); diff --git a/tests/read-cache-concurrent-child.ts b/tests/read-cache-concurrent-child.ts new file mode 100644 index 000000000..c465dfb00 --- /dev/null +++ b/tests/read-cache-concurrent-child.ts @@ -0,0 +1,55 @@ +/** + * Child-process helper for read-cache cross-process concurrency tests. + * + * Spawned via `tsx tests/read-cache-concurrent-child.ts <childId> <rounds>`. + * Each child: + * - writes/reads `rounds` unique keys (per-child argv variants) — must round-trip exactly; + * - hammers a shared key (`list --json`) so multiple processes race on one entry; + * - exits non-zero with a diagnostic if a unique-key round-trip ever fails. + * + * The parent test asserts exit codes, absence of temp files, and that every + * entry file parses with a matching key header. + */ + +import { ReadCache } from '../src/read-cache.js'; + +const childId = process.argv[2]; +const rounds = Number(process.argv[3] ?? 10); + +const cacheDir = process.env.CACHE_DIR; +const worklogDir = process.env.WORKLOG_DIR; +if (!cacheDir || !worklogDir) { + process.stderr.write('CACHE_DIR and WORKLOG_DIR env vars are required\n'); + process.exit(3); +} + +const cache = new ReadCache({ cacheDir }); + +// Unique-key round-trips: no other process touches these keys. +for (let round = 0; round < rounds; round++) { + const argv = ['list', '--json', '--child', childId, '--round', String(round)]; + cache.set(worklogDir, argv, { child: childId, round }); + const got = cache.get(worklogDir, argv); + if (!got || (got as any).child !== childId || (got as any).round !== round) { + process.stderr.write(`UNIQUE-KEY MISMATCH child=${childId} round=${round}\n`); + process.exit(1); + } +} + +// Shared-key hammering: races with other workers on one entry; the final +// value is whoever wrote last, but the file must never be unreadable. +for (let i = 0; i < rounds; i++) { + cache.set(worklogDir, ['list', '--json'], { writer: childId, i }); + const got = cache.get(worklogDir, ['list', '--json']); + if (got === null) { + // Tolerated only transiently (LRU purge / concurrent invalidation) — + // a *corrupt* entry would have been deleted by get() and reported below. + continue; + } + if (typeof (got as any).writer !== 'string') { + process.stderr.write(`SHARED-KEY CORRUPT child=${childId} i=${i}\n`); + process.exit(2); + } +} + +process.exit(0); diff --git a/tests/read-cache.test.ts b/tests/read-cache.test.ts new file mode 100644 index 000000000..239cc4a7d --- /dev/null +++ b/tests/read-cache.test.ts @@ -0,0 +1,539 @@ +/** + * Tests for the wl read-cache module (src/read-cache.ts) + * + * Covers the F1 acceptance criteria from WL-0MSGAE35E001557V: + * - deterministic key derivation (dir + argv + version) with no collisions + * - WAL-aware freshness (worklog.db / -wal / -shm fingerprint invalidation) + * - TTL staleness bounding + * - bounded LRU purge + * - concurrent readers/writers (in-process + cross-process) + * - atomic writes (temp-file + rename, no partial entries) + * - invalidate()/clear() helpers (used by sync/write invalidation in F2/F3) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as childProcess from 'child_process'; +import { + ReadCache, + deriveCacheKey, + computeDbFingerprint, + resolveCacheDir, + dbFilesForWorklogDir, + fingerprintsEqual, + DEFAULT_TTL_MS, + DEFAULT_MAX_ENTRIES, +} from '../src/read-cache.js'; +import { createTempDir, cleanupTempDir, resolveTsxBin } from './test-utils.js'; + +// ── helpers ──────────────────────────────────────────────────────────── + +/** Create a fake WAL-mode worklog dir with db/-wal/-shm files (empty). */ +function createFakeWorklogDir(dir: string): void { + const files = dbFilesForWorklogDir(dir); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(files.db, ''); + fs.writeFileSync(files.wal, ''); + fs.writeFileSync(files.shm, ''); +} + +/** Simulate a SQLite WAL-mode write by appending to the -wal file (size+mtime change). */ +function simulateWalWrite(dir: string): void { + const files = dbFilesForWorklogDir(dir); + fs.appendFileSync(files.wal, 'WAL-CHUNK-' + Math.random()); +} + +/** Simulate a checkpoint that rewrites worklog.db (mtime+size change). */ +function simulateDbWrite(dir: string): void { + const files = dbFilesForWorklogDir(dir); + fs.appendFileSync(files.db, 'DB-CHUNK-' + Math.random()); +} + +function listEntryFiles(cacheDir: string): string[] { + if (!fs.existsSync(cacheDir)) return []; + return fs.readdirSync(cacheDir).filter((f) => /^[0-9a-f]{64}\.json$/.test(f)); +} + +// ── suite ────────────────────────────────────────────────────────────── + +describe('read-cache', () => { + let worklogDir: string; + let cacheDir: string; + + beforeEach(() => { + worklogDir = createTempDir(); + cacheDir = createTempDir(); + createFakeWorklogDir(worklogDir); + }); + + afterEach(() => { + cleanupTempDir(worklogDir); + cleanupTempDir(cacheDir); + }); + + describe('resolveCacheDir', () => { + const saved = { xdg: process.env.XDG_CACHE_HOME, wl: process.env.WL_CACHE_DIR }; + + afterEach(() => { + if (saved.xdg === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = saved.xdg; + if (saved.wl === undefined) delete process.env.WL_CACHE_DIR; + else process.env.WL_CACHE_DIR = saved.wl; + }); + + it('honors XDG_CACHE_HOME and appends wl', () => { + vi.stubEnv('XDG_CACHE_HOME', '/xdg/cache'); + expect(resolveCacheDir()).toBe(path.join('/xdg/cache', 'wl')); + }); + + it('defaults to ~/.cache/wl when XDG_CACHE_HOME is unset', () => { + delete process.env.XDG_CACHE_HOME; + delete process.env.WL_CACHE_DIR; + expect(resolveCacheDir()).toBe(path.join(os.homedir(), '.cache', 'wl')); + }); + + it('WL_CACHE_DIR takes precedence over XDG_CACHE_HOME', () => { + vi.stubEnv('XDG_CACHE_HOME', '/xdg/cache'); + vi.stubEnv('WL_CACHE_DIR', '/custom/wl-cache'); + expect(resolveCacheDir()).toBe(path.resolve('/custom/wl-cache')); + }); + }); + + describe('deriveCacheKey', () => { + it('is deterministic for identical inputs', () => { + const argv = ['list', '--json', '--priority', 'critical']; + expect(deriveCacheKey(worklogDir, argv, '1.0.0')).toBe(deriveCacheKey(worklogDir, argv, '1.0.0')); + }); + + it('separates different worklog dirs (no collisions)', () => { + const otherDir = createTempDir(); + try { + const argv = ['list', '--json']; + const a = deriveCacheKey(worklogDir, argv, '1.0.0'); + const b = deriveCacheKey(otherDir, argv, '1.0.0'); + expect(a).not.toBe(b); + expect(a).toMatch(/^[0-9a-f]{64}$/); + } finally { + cleanupTempDir(otherDir); + } + }); + + it('separates arg variants (argv is part of the key)', () => { + const k1 = deriveCacheKey(worklogDir, ['list', '--json'], '1.0.0'); + const k2 = deriveCacheKey(worklogDir, ['list', '--json', '--root-only'], '1.0.0'); + const k3 = deriveCacheKey(worklogDir, ['list', '--status', 'open'], '1.0.0'); + const k4 = deriveCacheKey(worklogDir, ['list', '--status', 'blocked'], '1.0.0'); + expect(new Set([k1, k2, k3, k4]).size).toBe(4); + }); + + it('separates wl versions (schema-drift guard)', () => { + const argv = ['list', '--json']; + expect(deriveCacheKey(worklogDir, argv, '1.0.7')).not.toBe(deriveCacheKey(worklogDir, argv, '1.0.8')); + }); + + it('orders argv (arg order matters, byte-identical output depends on it)', () => { + expect(deriveCacheKey(worklogDir, ['list', '--json'], '1.0.0')).not.toBe( + deriveCacheKey(worklogDir, ['--json', 'list'], '1.0.0'), + ); + }); + + it('resolves relative worklog dirs to absolute paths', () => { + const abs = deriveCacheKey(worklogDir, ['list'], '1.0.0'); + const rel = deriveCacheKey(path.relative(process.cwd(), worklogDir), ['list'], '1.0.0'); + expect(rel).toBe(abs); + }); + }); + + describe('computeDbFingerprint (WAL-aware)', () => { + it('reflects db, wal and shm mtime+size', () => { + const fp = computeDbFingerprint(worklogDir); + expect(fp.db).toEqual([expect.any(Number), expect.any(Number)]); + expect(fp.wal).toEqual([expect.any(Number), expect.any(Number)]); + expect(fp.shm).toEqual([expect.any(Number), expect.any(Number)]); + }); + + it('treats missing DB files as zeros (empty/never-initialized dir)', () => { + const emptyDir = createTempDir(); + try { + const fp = computeDbFingerprint(emptyDir); + expect(fp).toEqual({ db: [0, 0], wal: [0, 0], shm: [0, 0] }); + } finally { + cleanupTempDir(emptyDir); + } + }); + + it('changes when the -wal file changes (WAL-mode write)', () => { + const before = computeDbFingerprint(worklogDir); + simulateWalWrite(worklogDir); + const after = computeDbFingerprint(worklogDir); + expect(fingerprintsEqual(before, after)).toBe(false); + }); + + it('changes when worklog.db changes (checkpoint/rewrite)', () => { + const before = computeDbFingerprint(worklogDir); + simulateDbWrite(worklogDir); + const after = computeDbFingerprint(worklogDir); + expect(fingerprintsEqual(before, after)).toBe(false); + }); + + it('changes when the -shm file changes', () => { + const before = computeDbFingerprint(worklogDir); + const files = dbFilesForWorklogDir(worklogDir); + fs.appendFileSync(files.shm, 'SHM-CHUNK'); + const after = computeDbFingerprint(worklogDir); + expect(fingerprintsEqual(before, after)).toBe(false); + }); + }); + + describe('get/set round-trip', () => { + it('returns null on a cache miss', () => { + const cache = new ReadCache({ cacheDir }); + expect(cache.get(worklogDir, ['list', '--json'])).toBeNull(); + expect(cache.stats().misses).toBe(1); + expect(cache.stats().hits).toBe(0); + }); + + it('round-trips a JSON value', () => { + const cache = new ReadCache({ cacheDir }); + const value = { success: true, count: 2, workItems: [{ id: 'WI-1' }, { id: 'WI-2' }] }; + cache.set(worklogDir, ['list', '--json'], value); + expect(cache.get(worklogDir, ['list', '--json'])).toEqual(value); + expect(cache.stats().hits).toBe(1); + }); + + it('round-trips a string value (exact CLI JSON output)', () => { + const cache = new ReadCache({ cacheDir }); + const output = '{"success":true,"count":1,"workItems":[]}'; + cache.set(worklogDir, ['list', '--json'], output); + expect(cache.get(worklogDir, ['list', '--json'])).toBe(output); + }); + + it('keeps arg variants separated (no cross-contamination)', () => { + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['list', '--json'], { scope: 'all' }); + cache.set(worklogDir, ['list', '--json', '--root-only'], { scope: 'root-only' }); + cache.set(worklogDir, ['list', '--status', 'open'], { scope: 'open' }); + expect(cache.get(worklogDir, ['list', '--json'])).toEqual({ scope: 'all' }); + expect(cache.get(worklogDir, ['list', '--json', '--root-only'])).toEqual({ scope: 'root-only' }); + expect(cache.get(worklogDir, ['list', '--status', 'open'])).toEqual({ scope: 'open' }); + }); + + it('keeps different worklog dirs separated', () => { + const otherDir = createTempDir(); + try { + createFakeWorklogDir(otherDir); + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['next', '--json'], { dir: 'A' }); + cache.set(otherDir, ['next', '--json'], { dir: 'B' }); + expect(cache.get(worklogDir, ['next', '--json'])).toEqual({ dir: 'A' }); + expect(cache.get(otherDir, ['next', '--json'])).toEqual({ dir: 'B' }); + } finally { + cleanupTempDir(otherDir); + } + }); + + it('creates the cache dir on demand', () => { + const nested = path.join(cacheDir, 'nested', 'deep'); + const cache = new ReadCache({ cacheDir: nested }); + cache.set(worklogDir, ['list'], { ok: true }); + expect(fs.existsSync(nested)).toBe(true); + expect(cache.get(worklogDir, ['list'])).toEqual({ ok: true }); + }); + }); + + describe('WAL-aware invalidation', () => { + it('invalidates when the -wal file changes (write-then-read is fresh)', () => { + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['list', '--json'], { v: 1 }); + + // A write lands in the WAL: cached entry must NOT be served. + simulateWalWrite(worklogDir); + expect(cache.get(worklogDir, ['list', '--json'])).toBeNull(); + + // New data is cached and served immediately. + cache.set(worklogDir, ['list', '--json'], { v: 2 }); + expect(cache.get(worklogDir, ['list', '--json'])).toEqual({ v: 2 }); + }); + + it('invalidates when worklog.db changes (checkpoint)', () => { + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['show', 'WI-1', '--json'], { title: 'old' }); + simulateDbWrite(worklogDir); + expect(cache.get(worklogDir, ['show', 'WI-1', '--json'])).toBeNull(); + }); + + it('invalidates when -shm changes', () => { + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['search', 'bug'], { n: 1 }); + const files = dbFilesForWorklogDir(worklogDir); + fs.appendFileSync(files.shm, 'SHM-CHUNK'); + expect(cache.get(worklogDir, ['search', 'bug'])).toBeNull(); + }); + + it('invalidates when the DB files first appear (fresh DB creation)', () => { + const emptyDir = createTempDir(); + try { + const cache = new ReadCache({ cacheDir }); + cache.set(emptyDir, ['list', '--json'], { v: 1 }); + expect(cache.get(emptyDir, ['list', '--json'])).toEqual({ v: 1 }); + + // DB appears → fingerprint goes from zeros to real values → invalidate. + createFakeWorklogDir(emptyDir); + expect(cache.get(emptyDir, ['list', '--json'])).toBeNull(); + } finally { + cleanupTempDir(emptyDir); + } + }); + + it('only invalidates entries for the touched worklog dir', () => { + const otherDir = createTempDir(); + try { + createFakeWorklogDir(otherDir); + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['list', '--json'], { dir: 'A' }); + cache.set(otherDir, ['list', '--json'], { dir: 'B' }); + + simulateWalWrite(worklogDir); + expect(cache.get(worklogDir, ['list', '--json'])).toBeNull(); + expect(cache.get(otherDir, ['list', '--json'])).toEqual({ dir: 'B' }); + } finally { + cleanupTempDir(otherDir); + } + }); + + it('stores a caller-provided fingerprint (captured before the query)', () => { + const cache = new ReadCache({ cacheDir }); + const fpBefore = computeDbFingerprint(worklogDir); + const value = { v: 1 }; + cache.set(worklogDir, ['list', '--json'], value, { dbFingerprint: fpBefore }); + + // Still served while the DB is unchanged… + expect(cache.get(worklogDir, ['list', '--json'])).toEqual(value); + // …and invalidated the moment the DB changes (write-then-read is fresh). + simulateWalWrite(worklogDir); + expect(cache.get(worklogDir, ['list', '--json'])).toBeNull(); + }); + }); + + describe('TTL staleness', () => { + it('serves entries within TTL and rejects after expiry', () => { + let now = 1_000_000; + const cache = new ReadCache({ cacheDir, ttlMs: 30_000, now: () => now }); + cache.set(worklogDir, ['list', '--json'], { v: 1 }); + + now += 29_999; + expect(cache.get(worklogDir, ['list', '--json'])).toEqual({ v: 1 }); + + now += 2; // 30_001ms elapsed → past TTL + expect(cache.get(worklogDir, ['list', '--json'])).toBeNull(); + expect(cache.stats().misses).toBe(1); + }); + + it('a hit does not extend the TTL window (createdAt is the base)', () => { + let now = 1_000_000; + const cache = new ReadCache({ cacheDir, ttlMs: 10_000, now: () => now }); + cache.set(worklogDir, ['list'], { v: 1 }); + + now += 9_000; + expect(cache.get(worklogDir, ['list'])).toEqual({ v: 1 }); // hit, touches access time + now += 2_000; + expect(cache.get(worklogDir, ['list'])).toBeNull(); // 11s since creation → stale + }); + + it('defaults TTL to 30s and max entries to 1000', () => { + expect(DEFAULT_TTL_MS).toBe(30_000); + expect(DEFAULT_MAX_ENTRIES).toBe(1_000); + }); + }); + + describe('bounded cache (LRU purge)', () => { + it('purges oldest entries when over the bound', () => { + const cache = new ReadCache({ cacheDir, maxEntries: 5, ttlMs: 60_000 }); + for (let i = 0; i < 8; i++) { + cache.set(worklogDir, ['list', '--json', '--page', String(i)], { i }); + } + const files = listEntryFiles(cacheDir); + expect(files.length).toBeLessThanOrEqual(5); + }); + + it('keeps recently accessed entries and evicts least-recently used', () => { + // Each set advances the injected clock so accessedAt ordering is deterministic. + let now = 1_000_000; + const cache = new ReadCache({ cacheDir, maxEntries: 5, ttlMs: 60_000, now: () => now }); + const setP = (i: number) => { + now += 1; + cache.set(worklogDir, ['list', '--p', String(i)], { i }); + }; + for (let i = 0; i < 5; i++) setP(i); // accessedAt 1_000_001..1_000_005 + + // Touch entry 0 (most recent access) then add 3 more: with maxEntries=5, + // adding 5,6,7 evicts the three oldest untouched entries 1,2,3. + now += 1_000; + expect(cache.get(worklogDir, ['list', '--p', '0'])).toEqual({ i: 0 }); // accessedAt → 1_001_005 + for (let i = 5; i < 8; i++) setP(i); // accessedAt 1_001_006..1_001_008 + + expect(cache.get(worklogDir, ['list', '--p', '0'])).toEqual({ i: 0 }); // touched → survived + expect(cache.get(worklogDir, ['list', '--p', '1'])).toBeNull(); // oldest → evicted + expect(cache.get(worklogDir, ['list', '--p', '4'])).toEqual({ i: 4 }); // newest of the originals → survived + expect(cache.get(worklogDir, ['list', '--p', '7'])).toEqual({ i: 7 }); + }); + + it('evicts TTL-expired entries during purge', () => { + // maxEntries=1: B's set pushes the count over the bound, forcing a purge + // in which the TTL-expired entry A is removed. + let now = 1_000_000; + const cache = new ReadCache({ cacheDir, maxEntries: 1, ttlMs: 10_000, now: () => now }); + cache.set(worklogDir, ['list', '--a'], { a: 1 }); + now += 11_000; // entry A expires + cache.set(worklogDir, ['list', '--b'], { b: 2 }); // triggers purge; expired A removed + const files = listEntryFiles(cacheDir); + expect(files.length).toBe(1); + expect(cache.get(worklogDir, ['list', '--a'])).toBeNull(); + expect(cache.get(worklogDir, ['list', '--b'])).toEqual({ b: 2 }); + }); + }); + + describe('atomicity and corruption handling', () => { + it('leaves no temp files behind after set', () => { + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['list', '--json'], { ok: true }); + const leftovers = fs.readdirSync(cacheDir).filter((f) => f.includes('.tmp-') || f.endsWith('.tmp')); + expect(leftovers).toEqual([]); + }); + + it('serves a partial/corrupt entry as a miss and removes it', () => { + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['list', '--json'], { ok: true }); + const files = listEntryFiles(cacheDir); + expect(files.length).toBe(1); + fs.writeFileSync(path.join(cacheDir, files[0]), '{"key": "truncated'); + + expect(cache.get(worklogDir, ['list', '--json'])).toBeNull(); + expect(listEntryFiles(cacheDir).length).toBe(0); // corrupt file removed + }); + + it('treats a header mismatch (foreign/other-key file) as a miss', () => { + const cache = new ReadCache({ cacheDir }); + // Write an entry, then tamper with the stored key header. + cache.set(worklogDir, ['list', '--json'], { ok: true }); + const files = listEntryFiles(cacheDir); + const p = path.join(cacheDir, files[0]); + const entry = JSON.parse(fs.readFileSync(p, 'utf-8')); + entry.key = 'f'.repeat(64); + fs.writeFileSync(p, JSON.stringify(entry)); + + expect(cache.get(worklogDir, ['list', '--json'])).toBeNull(); + expect(listEntryFiles(cacheDir).length).toBe(0); + }); + }); + + describe('invalidate/clear', () => { + it('invalidate(worklogDir) removes only that dir entries', () => { + const otherDir = createTempDir(); + try { + createFakeWorklogDir(otherDir); + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['list'], { a: 1 }); + cache.set(worklogDir, ['next'], { a: 2 }); + cache.set(otherDir, ['list'], { b: 1 }); + + const removed = cache.invalidate(worklogDir); + expect(removed).toBe(2); + expect(cache.get(worklogDir, ['list'])).toBeNull(); + expect(cache.get(worklogDir, ['next'])).toBeNull(); + expect(cache.get(otherDir, ['list'])).toEqual({ b: 1 }); // untouched + } finally { + cleanupTempDir(otherDir); + } + }); + + it('clear() removes all entries', () => { + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['list'], { a: 1 }); + cache.set(worklogDir, ['next'], { a: 2 }); + const removed = cache.clear(); + expect(removed).toBe(2); + expect(listEntryFiles(cacheDir).length).toBe(0); + }); + }); + + describe('concurrency', () => { + it('survives concurrent in-process readers/writers across instances', async () => { + // Multiple cache instances = separate "processes" sharing one cache dir. + const instances = Array.from({ length: 6 }, () => new ReadCache({ cacheDir })); + const tasks: Promise<void>[] = []; + for (let inst = 0; inst < instances.length; inst++) { + const cache = instances[inst]; + for (let round = 0; round < 25; round++) { + tasks.push( + Promise.resolve().then(() => { + const argv = ['list', '--json', '--inst', String(inst), '--round', String(round)]; + cache.set(worklogDir, argv, { inst, round }); + const got = cache.get(worklogDir, argv); + expect(got).toEqual({ inst, round }); + }), + ); + } + } + await Promise.all(tasks); + // All entries written atomically — every file parses and has a valid key. + for (const f of listEntryFiles(cacheDir)) { + const entry = JSON.parse(fs.readFileSync(path.join(cacheDir, f), 'utf-8')); + expect(entry.key).toBe(f.replace(/\.json$/, '')); + expect(typeof entry.value).toBe('object'); + } + }); + + it('survives concurrent cross-process readers/writers (spawned via tsx)', async () => { + const childScript = path.resolve(__dirname, 'read-cache-concurrent-child.ts'); + const tsxBin = resolveTsxBin(__dirname); + const workers = 4; + const rounds = 15; + const run = (id: number) => + new Promise<void>((resolve, reject) => { + const child = childProcess.spawn(tsxBin, [childScript, String(id), String(rounds)], { + env: { + ...process.env, + CACHE_DIR: cacheDir, + WORKLOG_DIR: worklogDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + child.stderr.on('data', (d) => (stderr += String(d))); + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`child ${id} exited ${code}: ${stderr.slice(0, 500)}`)); + }); + }); + + await Promise.all(Array.from({ length: workers }, (_, i) => run(i))); + + // Post-conditions: no temp files, every entry parses, unique keys round-tripped. + const leftovers = fs.readdirSync(cacheDir).filter((f) => f.includes('.tmp-')); + expect(leftovers).toEqual([]); + for (const f of listEntryFiles(cacheDir)) { + const entry = JSON.parse(fs.readFileSync(path.join(cacheDir, f), 'utf-8')); + expect(entry.key).toBe(f.replace(/\.json$/, '')); + expect(typeof entry.value).toBe('object'); + } + // The shared key entry was written by all workers — final file must be valid. + const shared = new ReadCache({ cacheDir }).get(worklogDir, ['list', '--json']); + expect(shared).not.toBeNull(); + expect(typeof (shared as any).writer).toBe('string'); + }); + }); + + describe('version guard', () => { + it('does not serve entries written under a different wl version', () => { + const cacheV1 = new ReadCache({ cacheDir, version: '1.0.7' }); + const cacheV2 = new ReadCache({ cacheDir, version: '1.0.8' }); + cacheV1.set(worklogDir, ['list', '--json'], { v: 'old-schema' }); + expect(cacheV2.get(worklogDir, ['list', '--json'])).toBeNull(); + expect(cacheV1.get(worklogDir, ['list', '--json'])).toEqual({ v: 'old-schema' }); + }); + }); +}); diff --git a/tests/scripts/install-herdr.test.ts b/tests/scripts/install-herdr.test.ts new file mode 100644 index 000000000..8510a3e19 --- /dev/null +++ b/tests/scripts/install-herdr.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +// The exact keybinding block scripts/install-herdr.sh must insert. +const KEYBINDING_BLOCK = `[[keys.command]] +key = "prefix+l" +command = "herdr plugin action invoke worklog-selection-list.open-podcast-editor-tab" +description = "Open the Podcast Editing tab (Worklog work item selection pane)." +`; + +// Marker the script uses for its existence check. +const COMMAND_MARKER = 'worklog-selection-list.open-podcast-editor-tab'; + +// Legacy v0.1.x binding that prefix+l previously pointed at; install-herdr.sh +// migrates it in-place to the new action (never duplicates the key). +const LEGACY_BINDING = 'herdr plugin action invoke worklog-selection-list.open-worklist'; + +function runScript(env: Record<string, string>): { + status: number; + stdout: string; + stderr: string; +} { + const repoRoot = path.resolve(__dirname, '..', '..'); + const scriptPath = path.join(repoRoot, 'scripts', 'install-herdr.sh'); + const result = spawnSync('bash', [scriptPath], { + cwd: repoRoot, + encoding: 'utf8', + env: { + ...process.env, + // Keep PATH minimal so `herdr` is deterministically absent (the + // plugin-link path is not stubbed — see plan Q2 answer b). + PATH: '/usr/bin:/bin', + ...env, + }, + }); + return { status: result.status ?? -1, stdout: result.stdout, stderr: result.stderr }; +} + +function countOccurrences(content: string, needle: string): number { + return content.split(needle).length - 1; +} + +function makeTempDir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +describe('install-herdr script', () => { + it('inserts the keybinding block exactly once when run twice (idempotent)', () => { + const tempDir = makeTempDir('worklog-herdr-idempotent-'); + const configPath = path.join(tempDir, 'config.toml'); + + const first = runScript({ HERDR_CONFIG_PATH: configPath }); + expect(first.status).toBe(0); + const afterFirst = fs.readFileSync(configPath, 'utf8'); + expect(countOccurrences(afterFirst, COMMAND_MARKER)).toBe(1); + expect(afterFirst).toBe(KEYBINDING_BLOCK); + + const second = runScript({ HERDR_CONFIG_PATH: configPath }); + expect(second.status).toBe(0); + const afterSecond = fs.readFileSync(configPath, 'utf8'); + expect(countOccurrences(afterSecond, COMMAND_MARKER)).toBe(1); + expect(countOccurrences(afterSecond, '[[keys.command]]')).toBe(1); + expect(afterSecond).toBe(KEYBINDING_BLOCK); + }); + + it('creates the parent directory and file when the config path does not exist', () => { + const tempDir = makeTempDir('worklog-herdr-missing-'); + const configPath = path.join(tempDir, 'herdr', 'config.toml'); + + const result = runScript({ HERDR_CONFIG_PATH: configPath }); + expect(result.status).toBe(0); + expect(fs.existsSync(configPath)).toBe(true); + expect(fs.readFileSync(configPath, 'utf8')).toBe(KEYBINDING_BLOCK); + }); + + it('skips insertion when the config already contains the binding', () => { + const tempDir = makeTempDir('worklog-herdr-existing-'); + const configPath = path.join(tempDir, 'config.toml'); + const original = '# hand-edited config\n' + KEYBINDING_BLOCK; + fs.writeFileSync(configPath, original); + + const result = runScript({ HERDR_CONFIG_PATH: configPath }); + expect(result.status).toBe(0); + const content = fs.readFileSync(configPath, 'utf8'); + expect(content).toBe(original); // untouched — no duplicates, no other edits + expect(countOccurrences(content, COMMAND_MARKER)).toBe(1); + }); + + it('honours HERDR_CONFIG_PATH and leaves the default config untouched', () => { + const tempHome = makeTempDir('worklog-herdr-home-'); + const overridePath = path.join(tempHome, 'override.toml'); + + const result = runScript({ HOME: tempHome, HERDR_CONFIG_PATH: overridePath }); + expect(result.status).toBe(0); + expect(fs.existsSync(overridePath)).toBe(true); + expect(fs.readFileSync(overridePath, 'utf8')).toBe(KEYBINDING_BLOCK); + expect(fs.existsSync(path.join(tempHome, '.config', 'herdr', 'config.toml'))).toBe(false); + }); + + it('warns and exits 0 when herdr is not on PATH, still inserting the binding', () => { + const tempDir = makeTempDir('worklog-herdr-noherdr-'); + const configPath = path.join(tempDir, 'config.toml'); + + const result = runScript({ HERDR_CONFIG_PATH: configPath }); + expect(result.status).toBe(0); + expect(result.stderr).toContain('herdr'); + expect(result.stderr.toLowerCase()).toContain('warn'); + expect(fs.existsSync(configPath)).toBe(true); + expect(fs.readFileSync(configPath, 'utf8')).toBe(KEYBINDING_BLOCK); + }); + + it('warns and exits 0 when the config directory cannot be written', () => { + const tempDir = makeTempDir('worklog-herdr-readonly-'); + fs.chmodSync(tempDir, 0o555); + const configPath = path.join(tempDir, 'config.toml'); + + try { + const result = runScript({ HERDR_CONFIG_PATH: configPath }); + expect(result.status).toBe(0); + expect(result.stderr.toLowerCase()).toContain('warn'); + expect(fs.existsSync(configPath)).toBe(false); + } finally { + fs.chmodSync(tempDir, 0o755); + } + }); + + it('appends the block on a fresh line when the existing config lacks a trailing newline', () => { + const tempDir = makeTempDir('worklog-herdr-newline-'); + const configPath = path.join(tempDir, 'config.toml'); + fs.writeFileSync(configPath, 'existing = true'); // no trailing newline + + const result = runScript({ HERDR_CONFIG_PATH: configPath }); + expect(result.status).toBe(0); + const content = fs.readFileSync(configPath, 'utf8'); + expect(content).toBe('existing = true\n' + KEYBINDING_BLOCK); + expect(countOccurrences(content, COMMAND_MARKER)).toBe(1); + }); + + it('migrates a legacy open-worklist binding in-place to the new action', () => { + const tempDir = makeTempDir('worklog-herdr-migrate-'); + const configPath = path.join(tempDir, 'config.toml'); + const legacyBlock = `[[keys.command]]\nkey = "prefix+l"\ncommand = "${LEGACY_BINDING}"\ndescription = "Open the Worklog work item selection pane in a new tab."\n`; + fs.writeFileSync(configPath, legacyBlock); + + const result = runScript({ HERDR_CONFIG_PATH: configPath }); + expect(result.status).toBe(0); + const content = fs.readFileSync(configPath, 'utf8'); + // Legacy command replaced in-place; no duplicate keybinding inserted. + expect(countOccurrences(content, LEGACY_BINDING)).toBe(0); + expect(countOccurrences(content, COMMAND_MARKER)).toBe(1); + expect(countOccurrences(content, '[[keys.command]]')).toBe(1); + expect(content).toContain('command = "herdr plugin action invoke ' + COMMAND_MARKER + '"'); + }); +}); diff --git a/tests/spawn-counter.test.ts b/tests/spawn-counter.test.ts new file mode 100644 index 000000000..402c175da --- /dev/null +++ b/tests/spawn-counter.test.ts @@ -0,0 +1,61 @@ +/** + * Unit tests for the env-gated spawn counter (F2 — WL-0MSGAEC5N006W5QA). + * + * The counter records `wl` process spawns so the ≥60% spawn-reduction target + * (parent AC7) can be measured from a simulated multi-pane refresh. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { recordSpawn, readSpawnRecords, countSpawnRecords } from '../src/spawn-counter.js'; +import { createTempDir, cleanupTempDir } from './test-utils.js'; + +describe('spawn counter', () => { + let dir: string; + let file: string; + + beforeEach(() => { + dir = createTempDir(); + file = path.join(dir, 'spawns.log'); + process.env.WL_SPAWN_COUNT_FILE = file; + }); + + afterEach(() => { + delete process.env.WL_SPAWN_COUNT_FILE; + delete process.env.WL_SPAWN_COUNT; + cleanupTempDir(dir); + }); + + it('records spawn lines with kind, timestamp and pid', () => { + recordSpawn('read-work'); + recordSpawn('cache-hit'); + const recs = readSpawnRecords(file); + expect(recs).toHaveLength(2); + expect(recs[0].kind).toBe('read-work'); + expect(recs[1].kind).toBe('cache-hit'); + expect(recs[0].pid).toBe(process.pid); + expect(Number.isFinite(recs[0].ts)).toBe(true); + }); + + it('counts records, optionally filtered by kind', () => { + recordSpawn('read-work'); + recordSpawn('cache-hit'); + recordSpawn('read-work'); + expect(countSpawnRecords(file)).toBe(3); + expect(countSpawnRecords(file, 'read-work')).toBe(2); + expect(countSpawnRecords(file, 'cache-hit')).toBe(1); + }); + + it('handles an empty or missing file', () => { + expect(readSpawnRecords(file)).toEqual([]); + expect(countSpawnRecords(file)).toBe(0); + }); + + it('is a no-op without env configuration (never throws)', () => { + delete process.env.WL_SPAWN_COUNT_FILE; + delete process.env.WL_SPAWN_COUNT; + expect(() => recordSpawn('read-work')).not.toThrow(); + expect(fs.existsSync(file)).toBe(false); + }); +}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index a90eb0cb1..17a83c03d 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -7,6 +7,29 @@ import * as path from 'path'; import * as os from 'os'; import { describe as vitestDescribe, it as vitestIt } from 'vitest'; +/** + * Resolve the `tsx` CLI binary path. + * + * Worktrees have an empty local `node_modules` (deps resolve upward to the + * main checkout), so a plain `<repoRoot>/node_modules/.bin/tsx` path breaks + * for tests that spawn tsx as a subprocess from a worktree. Walk up parent + * directories until a real `node_modules/.bin/tsx` is found. + */ +export function resolveTsxBin(fromDir: string): string { + let dir = path.resolve(fromDir); + for (;;) { + const candidate = path.join(dir, 'node_modules', '.bin', 'tsx'); + if (fs.existsSync(candidate)) { + return candidate; + } + const parent = path.dirname(dir); + if (parent === dir) { + throw new Error(`tsx binary not found walking up from ${fromDir}`); + } + dir = parent; + } +} + /** * Create a temporary directory for test files */ diff --git a/tests/unit/resolve-pi-dir.test.ts b/tests/unit/resolve-pi-dir.test.ts index b68f46db3..57e8f5c5d 100644 --- a/tests/unit/resolve-pi-dir.test.ts +++ b/tests/unit/resolve-pi-dir.test.ts @@ -1,12 +1,10 @@ /** - * Tests for `.pi` folder discovery — resolvePiDir() and the repo-root-aware - * settings resolution used by loadSettings()/persistSettings(). + * Tests for `.pi` folder discovery — resolvePiDir(). * - * `.pi` discovery mirrors resolveWorklogDir(): it walks up from the current - * directory to the git repo root, preferring the nearest directory that owns - * a `.pi/settings.json` (a local settings file in cwd or a closer ancestor - * overrides the repo root), and falls back to the repo root when no settings - * file exists anywhere (so persistSettings() writes project-level config). + * `.pi` discovery walks up from the current directory to the git repo root, + * preferring the nearest directory that owns a `.pi/settings.json` (a local + * settings file in cwd or a closer ancestor overrides the repo root), and + * falls back to the repo root when no settings file exists anywhere. * * These tests use real temp directories with `.git` markers; `git rev-parse * --show-toplevel` is served by the test-local mock git binary (see @@ -16,7 +14,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -27,11 +25,6 @@ vi.mock('@earendil-works/pi-coding-agent', () => ({ })); import { resolvePiDir } from '../../src/worklog-paths.js'; -import { - loadSettings, - persistSettings, - DEFAULT_SETTINGS, -} from '../../packages/tui/extensions/Worklog/settings-config.js'; /** Create a temp git repo (`.git` marker directory) and return its root. */ function makeRepo(): string { @@ -116,102 +109,3 @@ describe('resolvePiDir', () => { expect(resolvePiDir('/nonexistent/wl-pi-dir-fake')).toBe('/nonexistent/wl-pi-dir-fake'); }); }); - -// ── loadSettings integration ────────────────────────────────────────── - -describe('loadSettings with repo-root-aware .pi discovery', () => { - let repoRoot: string; - let subdir: string; - - beforeEach(() => { - repoRoot = makeRepo(); - subdir = join(repoRoot, 'packages', 'herdr'); - mkdirSync(subdir, { recursive: true }); - }); - - afterEach(() => { - try { rmSync(repoRoot, { recursive: true }); } catch { /* ignore */ } - }); - - it('loads settings from the repo root .pi/settings.json when running from a subdirectory', () => { - makeSettingsFile(repoRoot, { browseItemCount: 21 }); - const settings = loadSettings(subdir); - expect(settings.browseItemCount).toBe(21); - expect(settings.showIcons).toBe(false); - }); - - it('prefers local .pi/settings.json in the working directory over the repo root', () => { - makeSettingsFile(repoRoot, { browseItemCount: 21 }); - makeSettingsFile(subdir, { browseItemCount: 33 }); - const settings = loadSettings(subdir); - expect(settings.browseItemCount).toBe(33); - }); - - it('returns defaults when no settings file exists anywhere', () => { - expect(loadSettings(subdir)).toEqual(DEFAULT_SETTINGS); - }); - - it('behaves unchanged when running from the repo root', () => { - makeSettingsFile(repoRoot, { browseItemCount: 42 }); - const settings = loadSettings(repoRoot); - expect(settings.browseItemCount).toBe(42); - }); - - it('still resolves when invoked without an explicit cwd (process.cwd())', () => { - // When no cwd is passed, resolution starts at process.cwd(). In the test - // environment process.cwd() is the repository checkout, which owns a - // .pi/settings.json — the result must be a directory, not throw. - const resolved = loadSettings(); - expect(resolved.browseItemCount).toBeGreaterThanOrEqual(1); - }); -}); - -// ── persistSettings integration ─────────────────────────────────────── - -describe('persistSettings with repo-root-aware .pi discovery', () => { - let repoRoot: string; - let subdir: string; - - beforeEach(() => { - repoRoot = makeRepo(); - subdir = join(repoRoot, 'packages', 'herdr'); - mkdirSync(subdir, { recursive: true }); - }); - - afterEach(() => { - try { rmSync(repoRoot, { recursive: true }); } catch { /* ignore */ } - }); - - it('writes to the repo root .pi/settings.json when running from a subdirectory without local settings', () => { - makeSettingsFile(repoRoot); - persistSettings({ showHelpText: false }, subdir); - - const written = JSON.parse(readFileSync(join(repoRoot, '.pi', 'settings.json'), 'utf-8')); - expect(written['context-hub'].showHelpText).toBe(false); - // Existing values are preserved - expect(written['context-hub'].browseItemCount).toBe(9); - // Nothing is written to the local subdirectory - expect(existsSync(join(subdir, '.pi', 'settings.json'))).toBe(false); - }); - - it('creates .pi/settings.json at the repo root when none exists anywhere', () => { - persistSettings({ browseItemCount: 6 }, subdir); - - const written = JSON.parse(readFileSync(join(repoRoot, '.pi', 'settings.json'), 'utf-8')); - expect(written['context-hub'].browseItemCount).toBe(6); - expect(existsSync(join(subdir, '.pi', 'settings.json'))).toBe(false); - }); - - it('writes to the local .pi/settings.json when one exists in the working directory', () => { - makeSettingsFile(repoRoot); - makeSettingsFile(subdir); - persistSettings({ browseItemCount: 50 }, subdir); - - const local = JSON.parse(readFileSync(join(subdir, '.pi', 'settings.json'), 'utf-8')); - expect(local['context-hub'].browseItemCount).toBe(50); - - // The repo root settings file is untouched - const root = JSON.parse(readFileSync(join(repoRoot, '.pi', 'settings.json'), 'utf-8')); - expect(root['context-hub'].browseItemCount).toBe(9); - }); -}); diff --git a/tests/verify-blessed-removal.test.ts b/tests/verify-blessed-removal.test.ts index 1e3808f4f..166df9134 100644 --- a/tests/verify-blessed-removal.test.ts +++ b/tests/verify-blessed-removal.test.ts @@ -95,12 +95,12 @@ describe('Current baseline: relocated files', () => { expect(projectPathExists('packages/tui/extensions/wl-integration.ts')).toBe(true); }); - it('packages/tui/extensions/Worklog/chatPane.ts exists', () => { - expect(projectPathExists('packages/tui/extensions/Worklog/chatPane.ts')).toBe(true); + it('packages/tui/extensions/Worklog/chatPane.ts no longer exists (TUI removed)', () => { + expect(projectPathExists('packages/tui/extensions/Worklog/chatPane.ts')).toBe(false); }); - it('packages/tui/extensions/Worklog/actionPalette.ts exists', () => { - expect(projectPathExists('packages/tui/extensions/Worklog/actionPalette.ts')).toBe(true); + it('packages/tui/extensions/Worklog/actionPalette.ts no longer exists (TUI removed)', () => { + expect(projectPathExists('packages/tui/extensions/Worklog/actionPalette.ts')).toBe(false); }); it('cli-output.ts imports from new markdown-renderer path', () => { @@ -169,22 +169,60 @@ describe('Current baseline: status-stage-validation works from new path', () => }); // --------------------------------------------------------------------------- -// Current baseline: pi.json extension paths updated +// Current baseline: pi.json and browse modules removed (TUI browse removed) // --------------------------------------------------------------------------- -describe('Current baseline: pi.json paths updated', () => { - it('pi.json bin entry points to piman.js', () => { - const content = readProjectFile('packages/tui/pi.json'); - expect(content).not.toBeNull(); - const parsed = JSON.parse(content); - expect(parsed.bin['wl-piman']).toBe('../dist/commands/piman.js'); +describe('Current baseline: TUI browse removal', () => { + it('packages/tui/pi.json no longer exists (TUI manifest removed)', () => { + expect(projectPathExists('packages/tui/pi.json')).toBe(false); + }); + + it('packages/tui/tests directory no longer exists (TUI-only tests removed)', () => { + expect(projectPathExists('packages/tui/tests')).toBe(false); + }); + + it('src/commands/tui.ts no longer exists (alias removed)', () => { + expect(projectPathExists('src/commands/tui.ts')).toBe(false); + }); + + it('src/commands/piman.ts no longer exists (alias removed)', () => { + expect(projectPathExists('src/commands/piman.ts')).toBe(false); + }); + + it('browse/chat modules are removed from the extension', () => { + expect(projectPathExists('packages/tui/extensions/Worklog/lib/browse.ts')).toBe(false); + expect(projectPathExists('packages/tui/extensions/Worklog/lib/tools.ts')).toBe(false); + expect(projectPathExists('packages/tui/extensions/Worklog/lib/settings.ts')).toBe(false); + expect(projectPathExists('packages/tui/extensions/Worklog/settings-config.ts')).toBe(false); + expect(projectPathExists('packages/tui/extensions/Worklog/shortcut-config.ts')).toBe(false); + expect(projectPathExists('packages/tui/extensions/Worklog/shortcuts.json')).toBe(false); + }); + + it('retained agent-side plugin modules exist', () => { + expect(projectPathExists('packages/tui/extensions/Worklog/activity-indicator.ts')).toBe(true); + expect(projectPathExists('packages/tui/extensions/Worklog/session-health.ts')).toBe(true); + expect(projectPathExists('packages/tui/extensions/Worklog/model-display.ts')).toBe(true); + expect(projectPathExists('packages/tui/extensions/Worklog/lease-release.ts')).toBe(true); + expect(projectPathExists('packages/tui/extensions/Worklog/lib/guardrails.ts')).toBe(true); + expect(projectPathExists('packages/tui/extensions/Worklog/lib/skill-path.ts')).toBe(true); + expect(projectPathExists('packages/tui/extensions/Worklog/lib/recovery/register-recovery.ts')).toBe(true); + expect(projectPathExists('packages/tui/extensions/Worklog/terminal-utils.ts')).toBe(true); + expect(projectPathExists('packages/tui/extensions/wl-integration.ts')).toBe(true); }); - it('pi.json extensions point to new locations', () => { - const content = readProjectFile('packages/tui/pi.json'); + it('the extension entry point registers only retained modules (no /wl command)', () => { + const content = readProjectFile('packages/tui/extensions/Worklog/index.ts'); expect(content).not.toBeNull(); - const parsed = JSON.parse(content); - expect(parsed.pi.extensions).toContain('./extensions/Worklog/chatPane.ts'); - expect(parsed.pi.extensions).toContain('./extensions/Worklog/actionPalette.ts'); + expect(content).toContain('registerActivityIndicator'); + expect(content).toContain('registerModelDisplay'); + expect(content).toContain('registerSessionHealth'); + expect(content).toContain('INSTALL_GUARDRAILS'); + expect(content).toContain('registerSkillPathTool'); + expect(content).toContain('registerRecoveryModule'); + expect(content).toContain('registerLeaseRelease'); + expect(content).not.toContain("registerCommand('wl'"); + expect(content).not.toContain('registerShortcut'); + expect(content).not.toContain('registerScheduler'); + expect(content).not.toContain('registerAutoInject'); }); }); @@ -196,8 +234,8 @@ describe('Current baseline: Blessed TUI state after F3 (removed)', () => { expect(projectPathExists('src/tui')).toBe(false); }); - it('src/commands/tui.ts still exists (now an alias to piman)', () => { - expect(projectPathExists('src/commands/tui.ts')).toBe(true); + it('src/commands/tui.ts no longer exists (alias removed)', () => { + expect(projectPathExists('src/commands/tui.ts')).toBe(false); }); it('src/types/blessed.d.ts no longer exists', () => { @@ -286,8 +324,23 @@ describe('Post-removal verification: F4 and F5 (completed)', () => { expect(checkDir(srcDir)).toBe(false); }); - it('documentation references to Blessed TUI are removed', () => { - // F5 will handle documentation updates - expect(true).toBe(true); + it('documentation references to the Pi-based TUI are removed', () => { + // The Pi-based TUI browse docs have been removed (TUI.md, + // docs/tutorials/04-using-the-tui.md, docs/TUI_PROFILING.md, + // docs/ux/design-checklist.md). The retained README/CLI docs must not + // describe `wl tui`/`wl piman` as available commands. + expect(projectPathExists('TUI.md')).toBe(false); + expect(projectPathExists('docs/tutorials/04-using-the-tui.md')).toBe(false); + expect(projectPathExists('docs/TUI_PROFILING.md')).toBe(false); + expect(projectPathExists('docs/ux/design-checklist.md')).toBe(false); + + const cli = readProjectFile('CLI.md'); + expect(cli).not.toBeNull(); + expect(cli).not.toContain('### `tui`'); + expect(cli).not.toContain('### `piman`'); + + const readme = readProjectFile('README.md'); + expect(readme).not.toBeNull(); + expect(readme).not.toContain('wl tui'); }); }); diff --git a/tests/wl-process-healthcheck.test.ts b/tests/wl-process-healthcheck.test.ts new file mode 100644 index 000000000..6bd262b78 --- /dev/null +++ b/tests/wl-process-healthcheck.test.ts @@ -0,0 +1,234 @@ +/** + * Tests for scripts/wl-process-healthcheck.sh (WL-0MSBVYH3W0066KJS) + * + * The script counts concurrent node `wl`/`worklog` CLI processes via `ps` + * and alerts when the count exceeds a threshold for a sustained number of + * consecutive checks (see docs/dev/wl-process-spawning-investigation.md §6). + * + * These tests run the real script with a fake `ps` injected into PATH so the + * process mix is deterministic and no real wl processes are required. + */ +import { execaSync } from 'execa'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const SCRIPT = path.resolve(__dirname, '..', 'scripts', 'wl-process-healthcheck.sh'); + +interface RunResult { + exitCode: number; + stdout: string; + stderr: string; + output: string; // stdout + stderr combined +} + +/** + * Install an executable fake `ps` in `dir` that serves lines from a fixture + * file. Fixture lines follow `ps -eo pid,ppid,etime,args` output format: + * `<pid> <ppid> <etime> <args...>`. + */ +function installFakePs(dir: string): void { + const fakePs = path.join(dir, 'ps'); + fs.writeFileSync( + fakePs, + `#!/usr/bin/env bash +set -euo pipefail +# Fake ps for wl-process-healthcheck tests. +# Reads lines from \${PS_FIXTURE} in the format: <pid> <ppid> <etime> <args...> +if [[ "\${1:-}" != "-eo" ]]; then + echo "fake ps: unsupported invocation: $*" >&2 + exit 1 +fi +case "\${2:-}" in + args) sed -E 's/^[0-9]+[[:space:]]+[0-9]+[[:space:]]+[^[:space:]]+[[:space:]]+//' "\$PS_FIXTURE" ;; + pid,ppid,etime,args) cat "\$PS_FIXTURE" ;; + *) echo "fake ps: unsupported columns: $*" >&2; exit 1 ;; +esac +` + ); + fs.chmodSync(fakePs, 0o755); +} + +describe('wl-process-healthcheck.sh', () => { + let tmp: string; + let countFile: string; + let ticksFile: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wl-healthcheck-test-')); + fs.mkdirSync(path.join(tmp, 'bin'), { recursive: true }); + installFakePs(path.join(tmp, 'bin')); + countFile = path.join(tmp, 'count'); + ticksFile = path.join(tmp, 'ticks'); + }); + + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + /** Run the real script against a fixture process list. */ + function run(fixtureLines: string[], args: string[] = []): RunResult { + const fixture = path.join(tmp, 'ps-fixture'); + fs.writeFileSync(fixture, fixtureLines.join('\n') + '\n'); + const env = { + ...process.env, + PATH: `${path.join(tmp, 'bin')}:${process.env.PATH ?? ''}`, + PS_FIXTURE: fixture, + }; + try { + const res = execaSync('bash', [SCRIPT, '--count-file', countFile, '--ticks-file', ticksFile, ...args], { + env, + encoding: 'utf-8', + }); + return { exitCode: res.exitCode, stdout: res.stdout, stderr: res.stderr, output: res.stdout + res.stderr }; + } catch (err: any) { + // execa throws on non-zero exit (the ALERT case exits 2) + return { + exitCode: err.exitCode ?? 1, + stdout: err.stdout ?? '', + stderr: err.stderr ?? '', + output: (err.stdout ?? '') + (err.stderr ?? ''), + }; + } + } + + const wlLine = (pid: number, args: string) => `${pid} 1 05:32:01 ${args}`; + const manyWl = (startPid: number, count: number) => + Array.from({ length: count }, (_, i) => wlLine(startPid + i, 'node /usr/local/bin/wl list --json')); + + it('reports OK and logs the count below the watch threshold', () => { + const res = run([wlLine(1001, 'node /usr/local/bin/wl list --json')]); + + expect(res.exitCode).toBe(0); + expect(res.stdout).toContain('OK'); + expect(res.stdout).toContain('1'); + expect(fs.readFileSync(countFile, 'utf-8').trim()).toBe('1'); + expect(fs.existsSync(ticksFile)).toBe(false); + }); + + it('reports WATCH between the watch and alert thresholds and logs the count', () => { + const res = run(manyWl(2000, 30)); + + expect(res.exitCode).toBe(0); + expect(res.stdout).toContain('WATCH'); + expect(fs.readFileSync(countFile, 'utf-8').trim()).toBe('30'); + expect(fs.existsSync(ticksFile)).toBe(false); + }); + + it('logs the count on WATCH while above the alert threshold but not yet sustained', () => { + const res = run(manyWl(3000, 60)); + + expect(res.exitCode).toBe(0); + expect(res.stdout).toContain('WATCH'); + expect(fs.readFileSync(countFile, 'utf-8').trim()).toBe('60'); + expect(fs.readFileSync(ticksFile, 'utf-8').trim()).toBe('1'); + }); + + it('alerts with the process tree after N consecutive high readings and exits non-zero', () => { + const lines = manyWl(4000, 60); + + const first = run(lines); + expect(first.exitCode).toBe(0); + expect(fs.readFileSync(ticksFile, 'utf-8').trim()).toBe('1'); + + const second = run(lines); + expect(second.exitCode).toBe(0); + expect(fs.readFileSync(ticksFile, 'utf-8').trim()).toBe('2'); + + const third = run(lines); + expect(third.exitCode).toBe(2); + expect(third.output).toContain('ALERT'); + // Process tree with pid/ppid/etime/args columns is emitted + expect(third.output).toContain('node /usr/local/bin/wl list --json'); + expect(third.output).toContain('4000'); + // Tick counter resets after a completed alert + expect(fs.existsSync(ticksFile)).toBe(false); + }); + + it('resets the tick counter when a later check drops below the alert threshold', () => { + run(manyWl(5000, 60)); + expect(fs.readFileSync(ticksFile, 'utf-8').trim()).toBe('1'); + + const res = run([wlLine(1001, 'node /usr/local/bin/wl list --json')]); + + expect(res.exitCode).toBe(0); + expect(res.stdout).toContain('OK'); + expect(fs.existsSync(ticksFile)).toBe(false); + expect(fs.readFileSync(countFile, 'utf-8').trim()).toBe('1'); + }); + + it('alerts immediately when --sustained is 1', () => { + const res = run(manyWl(6000, 7), ['--alert-threshold', '5', '--sustained', '1']); + + expect(res.exitCode).toBe(2); + expect(res.output).toContain('ALERT'); + }); + + it('honors a custom watch threshold via --watch-threshold', () => { + const res = run(manyWl(7000, 12), ['--watch-threshold', '10']); + + expect(res.exitCode).toBe(0); + expect(res.stdout).toContain('WATCH'); + }); + + it('emits machine-readable JSON with --json', () => { + const res = run([wlLine(1001, 'node /usr/local/bin/wl list --json')], ['--json']); + + expect(res.exitCode).toBe(0); + const parsed = JSON.parse(res.stdout); + expect(parsed.level).toBe('OK'); + expect(parsed.count).toBe(1); + expect(parsed.watch_threshold).toBe(20); + expect(parsed.alert_threshold).toBe(50); + expect(parsed.sustained).toBe(3); + expect(parsed.ticks).toBe(0); + }); + + it('emits an ALERT JSON payload with the process tree on stderr when sustained', () => { + const lines = manyWl(8000, 60); + run(lines, ['--json']); + run(lines, ['--json']); + const res = run(lines, ['--json']); + + expect(res.exitCode).toBe(2); + const parsed = JSON.parse(res.stdout); + expect(parsed.level).toBe('ALERT'); + expect(parsed.count).toBe(60); + expect(res.stderr).toContain('node /usr/local/bin/wl list --json'); + }); + + it('counts direct node invocations of the worklog dist CLI', () => { + const res = run([wlLine(1001, 'node /usr/local/lib/node_modules/worklog/dist/cli.js list --json')]); + + expect(res.exitCode).toBe(0); + expect(fs.readFileSync(countFile, 'utf-8').trim()).toBe('1'); + }); + + it('counts a bare worklog bin invocation', () => { + const res = run([wlLine(1001, 'node /usr/local/bin/worklog sync')]); + + expect(res.exitCode).toBe(0); + expect(fs.readFileSync(countFile, 'utf-8').trim()).toBe('1'); + }); + + it('ignores unrelated node processes and ps/grep artifacts', () => { + const res = run([ + wlLine(1001, 'node /usr/local/bin/other-server --port 3000'), + wlLine(1002, 'grep -E ^node .*/(wl|worklog)([ /]|$)'), + wlLine(1003, 'ps -eo args'), + wlLine(1004, 'bash scripts/wl-process-healthcheck.sh --json'), + ]); + + expect(res.exitCode).toBe(0); + expect(fs.readFileSync(countFile, 'utf-8').trim()).toBe('0'); + }); + + it('prints usage with --help and exits 0', () => { + const res = run([], ['--help']); + + expect(res.exitCode).toBe(0); + expect(res.stdout).toContain('Usage'); + expect(res.stdout).toContain('--alert-threshold'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index c3366a1bf..3703ceebe 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,8 +9,8 @@ export default defineConfig({ // Run setup to inject mock git into PATH for spawn-based calls setupFiles: ['./tests/setup-tests.ts'], // Memory guardrails: limit worker count and enable OOM detection. - // The worklog-browse-extension.test.ts file (1700+ lines) can consume - // significant memory during module initialization. These settings + // Some test files can consume significant memory during module + // initialization. These settings // prevent unbounded memory growth and ensure OOM failures are caught // early rather than hanging the test runner. // Use 'forks' pool (child_process) instead of 'threads' (worker_threads)