diff --git a/.gitignore b/.gitignore index 2a0f3148..47f7faa6 100644 --- a/.gitignore +++ b/.gitignore @@ -177,3 +177,16 @@ __pycache__/ # Effort-and-risk skill output dumps written to repo root final-WL-*.json + +# Stray tsc emissions next to TypeScript sources (regression: b3f0bead +# committed 328 of these). .js import specifiers resolve to .ts sources at +# build/test time, so compiled artifacts in source trees are never tracked. +src/**/*.js +src/**/*.d.ts +src/**/*.map +tests/**/*.js +tests/**/*.d.ts +tests/**/*.map +packages/*/src/**/*.js +packages/*/src/**/*.d.ts +packages/*/src/**/*.map diff --git a/AGENTS.md b/AGENTS.md index acd97f04..23034524 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,4 +2,36 @@ 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. -## Project-specific guidance \ No newline at end of file +## Project-specific guidance + +### Architecture Notes for Agents + +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). \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9321f385..65428bf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## v1.0.10 (2026-08-12) +### Features +- Idle detection now only counts local activity, so remote-only traffic won't block downtime dispatch. (WL-0MSL2ZQIF006QB4Q) +- Press P n to instantly open a fresh Pi session with a blank prompt. (WL-0MSKIL6P00045UOC) +- Show a preview of the selected work item's description in the list panel. (WL-0MSFZKQL700381P3) +- Work items now show a Related Docs row listing all linked markdown files, and you can open any of them from the detail view. (WL-0MSGTLSUT002NF29) +- Worklist rows now show an agent status icon so you can see at a glance which items have active agents. (WL-0MSBQUJQX005RAT9) +- Command input forms now display as a full-width page instead of a dialog box. (WL-0MSFZUS4Z006IRI3) +- Press S in the worklist to start a release — type "ship" to confirm, Esc to cancel. (WL-0MSGG5N5Z0074TLY) +- Markdown descriptions and episode files now display with full formatting, including tables, lists, and styled text. (WL-0MSKFFJWD002BQJ5) +- Footer now shows the command on the left and model on the right, giving the command more space. (WL-0MSHENVKR002HZT5) +- Docs work items can now be implemented with the "i" shortcut. (WL-0MSNMPCHZ002SIQJ) +### Bug Fixes +- Work list now stays live when you're working in another pane of the same tab. (WL-0MSJNJPRM009RM35) +- The in_review filter now shows all items awaiting review instead of none. (WL-0MSKCRX730052IIW) +- Fixed a crash when opening the worklist pane. (WL-0MSNULGSM00018I4) +- Whitespace-only edits no longer reset item timestamps. (WL-0MSORD6HC005QVZX) +- `wl next --stage` now only returns items at that stage, even when blockers exist. (WL-0MSP1XJSO007LE3K) +- Fixed duplicate audit sessions by preventing the same work item from being auto-dispatched twice. (WL-0MSLIY8ZR004QUSY) +- Sync no longer reverts completed items awaiting review to open status. (WL-0MSPZP7FE009YXPG) +- Downtime tasks now pause new code and audits during release freezes, resuming automatically when the freeze lifts. (WL-0MSQ0RPQP00636JY) +- Fixed cases where completed audits didn't update the stage/status. (WL-0MSN7OUOI005F6CE) +### Other +- Fixed overnight downtime dispatches not firing during idle windows. (WL-0MSK9TUCA00206M7) +- Cleaned up stray build files so the game runs smoother and updates install correctly. (WL-0MSOKOI4J0046GE5) + ## v1.0.9 (2026-08-08) ### Features - Herdr to consume local LLM in downtime (WL-0MSF49FMW009M06K) diff --git a/CLI.md b/CLI.md index 1060e480..8e571e58 100644 --- a/CLI.md +++ b/CLI.md @@ -461,6 +461,27 @@ In blocker-surfacing and critical-escalation paths, child blockers are never ret In batch mode (`-n `), children of returned parents are also excluded from subsequent results, ensuring the batch never contains items from the same subtree. +#### Risk / effort filters + +`--risk ` and `--effort ` restrict candidates with **at-most** ordinal +semantics (WL-0MSMAIP5F003WAGG): + +- Risk: `low` < `medium` < `high` < `severe` (alias `critical`). `--risk low` matches only + Low-risk items; `--risk medium` matches Low and Medium; and so on. +- Effort: `extra-small`/`xs` < `small`/`s` < `medium`/`m` < `large`/`l` < + `extra-large`/`xl`. `--effort small` matches Extra Small and Small items; + both the short (`XS`, `S`, …) and long-form (`Extra Small`, `Small`, …) + spellings are recognized, case-insensitively. +- **Fail-closed:** items with unset/empty risk or effort never match — an + absent estimate is not "≤ low/small". +- Filters compose with `--stage`, priority selection, and the existing + dependency-blocked exclusion, and apply to surfaced blockers too. + +```sh +wl next --stage plan_complete --risk low --effort small +wl next --stage plan_complete --risk low --effort small -n 10 +``` + #### Automatic re-sort By default, `wl next` re-sorts all active items by score before selecting candidates. This ensures that recently created or re-prioritized items are immediately reflected in the selection order without requiring a manual `wl re-sort`. The re-sort uses the same scoring logic as `wl re-sort` (priority weight, age, and optional recency policy). @@ -563,6 +584,8 @@ wl next -n 10 -g 4 wl next -a alice --search "bug" wl next --stage idea wl next --stage in_progress +wl next --risk low --effort small +wl next --stage plan_complete --risk low --effort small -n 10 wl next --include-blocked wl next --no-re-sort wl next --recency-policy prefer diff --git a/docs/dev/pause-when-hidden.md b/docs/dev/pause-when-hidden.md index 84db5e27..7c90719d 100644 --- a/docs/dev/pause-when-hidden.md +++ b/docs/dev/pause-when-hidden.md @@ -21,7 +21,7 @@ Two pollers were fixed: | Poller | File | Cadence | Fix | |--------|------|---------|-----| | Pi extension browse selection widget | `packages/tui/extensions/Worklog/lib/browse.ts` | 5s interval (4–5 wl spawns/tick) | **Idle gating**: pause fetch + `wl sync --if-idle` after 30s without keypresses (`IDLE_PAUSE_MS`); resume immediately on the next keypress. | -| Herdr worklist pane | `packages/herdr/src/worklist.ts` | 30s refresh + 60s sync timers (~4–5 wl spawns/tick per pane) | **Pane-focus gating**: skip timer ticks when the pane is hidden (`HERDR_PANE_ID` → `herdr pane get` → `result.pane.focused === false`); fail-open otherwise. | +| Herdr worklist pane | `packages/herdr/src/worklist.ts` | 30s refresh + 60s sync timers (~4–5 wl spawns/tick per pane) | **Tab-focus gating**: skip timer ticks when the pane's tab is hidden (`HERDR_TAB_ID` → `herdr tab get` → `result.tab.focused === false`); fail-open otherwise. | ## Behavior summary @@ -38,20 +38,25 @@ Two pollers were fixed: - Manual actions (navigation, shortcuts, `r`/`S`-style refreshes) are never gated; the detail-view widget (no interval) is unaffected. -### Herdr worklist pane (pane-focus gating) - -- Visibility signal: `HERDR_PANE_ID` → `herdr pane get ` → - `result.pane.focused`. A hidden (non-focused) tab reports `focused: false`. -- When hidden, the auto-refresh and auto-sync timer ticks are skipped → - **zero fetcher / `wl sync --if-idle` spawns**. -- Fail-open: no `HERDR_PANE_ID`, herdr CLI missing/erroring, or unparseable +### Herdr worklist pane (tab-focus gating) + +- Visibility signal: `HERDR_TAB_ID` → `herdr tab get ` → + `result.tab.focused`. A hidden (non-focused) tab reports `focused: false`. + Tab focus is the visibility signal — a pane is visible when its TAB is + focused, regardless of which pane in the tab holds keyboard focus (multi- + pane split fix, WL-0MSJNJPRM009RM35). There is no pane-focus fallback; a + pane zoomed-over within a focused tab is treated as visible (documented + limitation). +- When the tab is hidden, the auto-refresh and auto-sync timer ticks are + skipped → **zero fetcher / `wl sync --if-idle` spawns**. +- Fail-open: no `HERDR_TAB_ID`, herdr CLI missing/erroring, or unparseable output → the pane is treated as visible and polling continues as before. Standalone runs (outside Herdr) are unaffected. -- Visible-pane cadence unchanged (30s refresh / 60s sync defaults). +- Visible-tab cadence unchanged (30s refresh / 60s sync defaults). - The list header shows `[paused — hidden]` while gating is active. - Manual `S` (sync), navigation, chords, and the initial data load are never gated. -- `PollGate` (TTL ~2s) shares one `herdr pane get` call across refresh+sync +- `PollGate` (TTL ~2s) shares one `herdr tab get` call across refresh+sync ticks in a cycle (≤1 visibility exec per cycle). ## Verification procedure @@ -69,7 +74,7 @@ ps -eo args | grep -E 'wl (next|list|sync)' | grep -v grep | wc -l Expected while **active/visible**: - Pi browse widget actively browsed: ~4–5 `wl` spawns per 5s tick (transient). -- Herdr worklist pane focused: ~4–5 `wl` spawns per 30s tick (transient). +- Herdr worklist pane tab focused: ~4–5 `wl` spawns per 30s tick (transient). Expected while **hidden/idle**: @@ -91,14 +96,14 @@ Expected while **hidden/idle**: ### 3. Per-pane measurement (herdr worklist) -1. Open the worklist pane (`prefix+l` or the plugin action). Note its pane id: - `echo $HERDR_PANE_ID` inside the pane, or `herdr pane current`. -2. Focus the pane and confirm auto-refresh spawns: +1. Open the worklist pane (`prefix+l` or the plugin action). Note its tab id: + `echo $HERDR_TAB_ID` inside the pane, or `herdr tab current`. +2. Focus the tab and confirm auto-refresh spawns: `ps -eo args | grep -E 'wl (next|list|sync)' | wc -l` shows transient spawns every 30s, and the header shows no `[paused — hidden]` marker. -3. Switch to another tab (hiding the worklist pane). Within ~2s the header - shows `[paused — hidden]` and the `wl` process count from that pane drops - to zero. +3. Switch to another tab (hiding the worklist pane's tab). Within ~2s the + header shows `[paused — hidden]` and the `wl` process count from that pane + drops to zero. 4. Switch back — the pane resumes its normal cadence. ### 4. System-level idle test diff --git a/package.json b/package.json index a3eb98ff..7b5f8f10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "worklog", - "version": "1.0.9", + "version": "1.0.10", "description": "A simple experimental issue tracker for AI agents", "main": "dist/index.js", "type": "module", diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 7d3fb2c9..41b985b9 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -5,12 +5,12 @@ 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. 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) +- **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 root item in the selected stage matching the stage's status rule (open items for most stages; `completed`/`in-progress`/`open` for the in_review stage) — no `browseItemCount` cap and no `wl next` selection omission (WL-0MSDT8X1V003206G, WL-0MSKCRX730052IIW) - **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`) - **Command output** — When a chord resolves to a non-`/wl` command (e.g., `!!wl update --priority high`), the resolved command is executed **visibly in a new herdr pane** (see `scripts/run-in-pane.sh`) so the user sees the command line and its output; the wrapper keeps the pane's process alive so the pane stays open for inspection — dismiss it with Enter or close it with `prefix+x` -- **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). +- **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 form is a full-pane page (no border/centering) that wraps text at the pane width and 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). @@ -18,10 +18,10 @@ A Herdr plugin that provides a keyboard-navigable work item selection list for b - **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). +- **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, plus a **description preview** (first up-to-3 lines of the item's description) so you can see what an item is about at a glance. 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. 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). +- **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, full GFM rendering: headings, lists, tables, blockquotes, code, links) as a preview. The description section is rendered with the same markdown renderer. 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). @@ -96,7 +96,7 @@ The plugin pane will then be available via the Herdr plugin system. - 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)) + - Filtered views show every root item in the selected stage matching the stage's status rule (see [Selection List Behaviour](#selection-list-behaviour)) - Press `Escape` to cancel an incomplete chord 4. Workflow shortcuts (single-key): @@ -105,6 +105,7 @@ The plugin pane will then be available via the Herdr plugin system. - Press `n` — Run the intake workflow on the selected item (idea stage) - Press `p` — Run the plan workflow on the selected item (intake_complete stage) - Press `s` — Insert a search command + - Press `S` (Shift+s) — **Ship It**: run the dev→main release. A typed-confirmation dialog anchored to the bottom of the list (the list stays visible above it) asks you to type `ship` (case-insensitive) and press Enter to dispatch `/skill:ship release`; Esc cancels. The release is a global command — no work item id is involved. `S` is distinct from lowercase `s` (Search). See [Ship It confirmation dialog](#ship-it-confirmation-dialog). 5. Producer review shortcut: - Press `r` — Toggle 'Needs Producer Review' flag and add a comment to the selected item @@ -174,7 +175,7 @@ Settings are persisted in `~/.config/herdr/worklog-plugin.json`. Key settings in - `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: `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 +- `showHelpText` — Show the shortcut hint line at the bottom of the list (default: `true`). When `false`, **all** shortcut hint lines are hidden — including the chord-in-progress footer (`chord: _ `) — consistent with the pi browse widget (WL-0MSGJDSMJ004128E). Chord key *handling* still works while hints are hidden; only rendering is affected. Changes apply on the next render without a plugin restart - `showIcons` — Toggle icons in the list (default: `true`); changes apply on the next render without a plugin restart ### Downtime worker (local-LLM idle dispatch) @@ -182,9 +183,12 @@ Settings are persisted in `~/.config/herdr/worklog-plugin.json`. Key settings in 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): +visible (non-focus-stealing) pi agent pane. Dispatch priority +(WL-0MSI8H3HP000K0RG, WL-0MSMAYPQP001FLR6): first a completed/in_review item **without a valid audit** → `/skill:audit -`; else `/skill:plan` on the next `intake_complete` item; else falls back +`; else the highest-priority open `plan_complete` item with risk `Low` +and effort `Small`/`Extra Small` → `/skill:implement `; 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 @@ -214,10 +218,14 @@ identity data the worker fails closed to all-slots-free for `0 < N < total`; 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. +Idle means: llama-server running, no active **local** query (when the proxy +serves `local_active_query` — preferred over the global `active_query`, so +remote-only streams with free local slots do not block dispatch; absent on +pre-fix proxies, the global `active_query` is used as the fallback), 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 @@ -225,21 +233,57 @@ 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 +named `Downtime audit`). The audit tier additionally applies the +**dispatched-marker exclusion** (WL-0MSLIY8ZR004QUSY): an item the downtime +worker has already dispatched for `/skill:audit` (a `kind:audit` entry in +`.worklog/downtime-dispatches.log`) is never re-selected while it still +lacks a fresh audit — closing the loop where a dispatched audit run reverts +the item to completed/in_review without recording a fresh audit. The +exclusion composes with the freshness rule: a *fresh* audit since the +dispatch still governs (fresh → not a candidate). A missing or unreadable +log is treated as empty (fail-safe), so audit dispatch keeps working on a +fresh worklog. + +> **Audit-tier selection note (WL-0MSMAIP5F003WAGG):** the audit tier keeps +> its `wl list --status completed --stage in_review --json` selection rather +> than converting to `wl next --stage in_review`. `wl next` is strictly +> root-only, so converting would silently drop completed child items from +> audit dispatch (32 children in the completed/in_review queue as of the +> decision); the audit tier must audit the full completed/in_review set, +> including children. The conversion was scoped to the implement tier only +> (AC5 escape hatch — decision recorded). + +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 +--stage idea --json` and dispatches `/skill:intake `; if all four 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` / +cannot select it. Panes are named `Downtime audit` / `Downtime implement` / +`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 +**Implement tier (WL-0MSMAYPQP001FLR6)** — after the audit tier, the worker +runs `wl next --stage plan_complete --risk low --effort small -n 10 --json` +and selects the first candidate that is `status: open` (wl next keeps +completed epics under a stage filter — the implement tier filters them out +client-side), re-verifying the risk/effort thresholds fail-closed (only risk +exactly `Low`; effort `Small`/`Extra Small`/`XS`; unset values never match), +and excluding items already dispatched for `/skill:implement` (a `kind: +implement` entry in `.worklog/downtime-dispatches.log` — AC6 dispatched- +marker exclusion, same pattern as the audit tier). Dependency-blocked items +are excluded by `wl next` itself. Dispatch is `/skill:implement ` (pane +named `Downtime implement`). A `wl`/CLI error or empty result at the +implement tier is fail-closed (never a candidate) and does **not** +short-circuit the plan/intake fallback (AC5/AC6). + +**Empty-backlog cooldown** — when the implement, plan, and intake `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 @@ -249,6 +293,20 @@ 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. +**Code-freeze gate (WL-0MSQ0RPQP00636JY)** — the dispatcher honours the +ship-it code-freeze marker (see [Code Freeze](#code-freeze)): the marker is +re-read **fresh on every dispatch attempt** (never cached), so a freeze that +starts or ends mid-idle-period is honored on the next dispatch. While the +marker is **frozen or ambiguous** (fail-closed), the audit and implement tiers +are skipped — no new audits or implementation work starts during a release — +and dispatch continues with the plan/intake tiers, which are low-risk prep +work. A freeze skip is never treated as an empty backlog: it reports reason +`code-freeze` (never `no-candidate`), so it does **not** trigger the +no-candidate cooldown — polling continues and implement/audit dispatch +resumes immediately when the freeze lifts. The implement skill's own +freeze enforcement remains the backstop for the TOCTOU window between the +dispatcher's marker read and the pane spawn. + **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* @@ -294,12 +352,36 @@ comment on the dispatched item (`wl comment add`, author 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 +kept). The `kind:audit` entries in this log double as the dispatched-marker +exclusion source for the audit tier (WL-0MSLIY8ZR004QUSY): an item the +worker already dispatched for `/skill:audit` is excluded from later audit +tier selection while it still lacks a fresh audit (plan/intake markers are +scoped to their own tiers and never suppress audit selection). 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. +**Failure-path logging** — a complete account of what each dispatch outcome +leaves behind (documented for WL-0MSKUG2WW0058A7W, audit gap AC2): + +| Outcome | Trace in `.worklog/downtime-dispatches.log` | Notes | +|---|---|---| +| Successful dispatch | comment on the item + JSONL entry (`kind`, `itemId`, `dispatchedAt`) | the only fully-visible outcome | +| Genuine empty backlog (no-candidate) | **none** — intentionally silent | full cooldown pause (default 60 min); worker stops polling | +| 1–2 transient wl CLI errors (strikes) | **none** — silent | one strike per `wl-error` outcome; retries on the next idle window | +| 3rd consecutive wl CLI error | `recordError` JSONL entry | three-strike pause; the only failure path that logs | +| Audit-tier wl/parse failure | **none** — silent, and **no strike** | `getNextAuditCandidate` collapses the failure to `null` like an empty tier (index.ts:389-391); worker falls through to the plan tier and looks healthy — known silent path, follow-up WL-0MSLWJ2KP0002SV0 | +| Claim failure (`wl update --status in_progress`) | **none** — result discarded | dispatch proceeds and is still recorded as a success; the claim is the cross-pane serialization guard, so a failed claim leaves the item selectable by another pane — known silent path, follow-up WL-0MSLWJ310000ND0X | +| Pane spawn failure (`send-to-pi.sh`) | **none** — invisible | spawn is detached/`stdio: ignore`/`unref`d with no `error`/`exit` handler; a failed spawn (or a script that exits non-zero) is never observed and the dispatch is still logged as a success — known silent path, follow-up WL-0MSLWJ3I70031Z8U | +| `recordDispatch` / `recordError` write failure | **none** | fail-closed by design: logging must never crash or block the worker | + +Consequence: the log's *absence* of an entry is ambiguous — it cannot +distinguish "no candidate (paused)" from "worker disabled" or from any of +the silent failure paths above; only the three-strike and success paths leave +entries. The follow-up items above close the three code-level silent paths. + 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 @@ -314,29 +396,38 @@ auto-sync timers pause so hidden panes stop spawning `wl` processes (~4–5 per 30s tick per pane). With many open panes this previously caused heavy `wl` process churn and memory pressure (WL-0MSB1N0HB0007N6N). -- **Visibility signal** — Herdr sets `HERDR_PANE_ID` for panes it spawns; a - hidden (non-focused) tab reports `result.pane.focused === false` from - `herdr pane get `. The plugin checks this via `visibility.ts`. -- **Fail-open** — when visibility cannot be determined (no `HERDR_PANE_ID` +- **Visibility signal** — Herdr sets `HERDR_TAB_ID` for panes it spawns; a + hidden (non-focused) tab reports `result.tab.focused === false` from + `herdr tab get `. The plugin checks this via `visibility.ts`. Tab + focus is the visibility signal: a pane is visible when its TAB is focused, + regardless of which pane in the tab holds keyboard focus (so a visible + worklist pane in a multi-pane split keeps refreshing while an adjacent + pane holds focus). +- **Fail-open** — when visibility cannot be determined (no `HERDR_TAB_ID` env, herdr CLI missing/erroring, unparseable output) the pane is treated as visible and polling proceeds exactly as before. Standalone runs (outside Herdr) are unaffected. -- **Cadence unchanged when visible** — while the pane is focused (or +- **Cadence unchanged when visible** — while the pane's tab is focused (or fail-open), auto-refresh/auto-sync keep their existing intervals (30s / 60s defaults). -- **Header indicator** — while the pane is hidden the list header shows - `[paused — hidden]` so operators can tell gating is active; the indicator - clears as soon as the list refreshes after the pane becomes visible. -- **Immediate refresh on resume** — while the pane is hidden a lightweight - resume poll (2s interval, `herdr pane get` only — never `wl`) watches for - the hidden → visible transition; the moment the tab regains focus the list - re-fetches immediately (with a "Refreshed" notification) instead of waiting - for the next 30s tick, then the normal cadence resumes. -- **Never gated** — manual actions (navigation, `S` manual sync, shortcut - chords, the initial data load) work regardless of pane visibility. +- **Header indicator** — while the pane's tab is hidden the list header + shows `[paused — hidden]` so operators can tell gating is active; the + indicator clears as soon as the list refreshes after the tab regains + focus. +- **Immediate refresh on resume** — while the pane's tab is hidden a + lightweight resume poll (2s interval, `herdr tab get` only — never `wl`) + watches for the hidden → visible transition; the moment the tab regains + focus the list re-fetches immediately (with a "Refreshed" notification) + instead of waiting for the next 30s tick, then the normal cadence resumes. +- **Never gated** — manual actions (navigation, shortcut chords, the initial + data load) work regardless of tab visibility. - **Shared visibility check** — the `PollGate` TTL memoizer (~2s) makes the - refresh and sync ticks in one cycle share a single `herdr pane get` call + refresh and sync ticks in one cycle share a single `herdr tab get` call (≤1 visibility exec per cycle). +- **Zoom-over limitation** — a pane zoomed-over within a focused tab is + treated as visible (tab focus is the sole signal; there is no pane-focus + fallback) and keeps refreshing. Zoom-over detection is out of scope for + the visibility gate (approved plan, WL-0MSJNJPRM009RM35). - **No settings toggle** — pause-when-hidden is always on. ### Selection List Behaviour @@ -362,13 +453,20 @@ 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, or `/wl `) show -**every open** root item in the selected stage (WL-0MSDT8X1V003206G): +every root item in the selected stage matching the stage's status rule +(WL-0MSDT8X1V003206G, WL-0MSKCRX730052IIW): -- All open items (`status=open`) in the stage are listed — no +- Most stages show **every open** item (`status=open`) in the stage — 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. + priority algorithm deprioritises are still visible. Items with status + `blocked`, `in-progress`, or `completed` are excluded even when their + stage matches. +- The **in_review** stage (press `f` + `r`, or `/wl review`) is the + exception: it shows items with status `completed`, `in-progress`, or + `open`. Per the project workflow, advancing an item to `in_review` sets + its status to `completed` (or leaves it `in-progress` while being + re-worked after review feedback) — restricting the filter to + `status=open` would empty the review queue (WL-0MSKCRX730052IIW). - 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 @@ -402,6 +500,13 @@ clamped to a minimum of 3 rows so it is always usable. - 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). +- When the item has a description, the panel shows a **`Description`** + preview: the first up-to-3 non-empty lines of the description (markdown + source as-is, each line truncated to the pane width), so you can see what + an item is about without opening the detail view. Items without a + description omit the section entirely. The preview sits between the + metadata rows and the last-command line and scrolls with the rest of the + panel content. - The panel scrolls **independently** of the list: press `m` to scroll the panel down and `M` to scroll it up. A `[m/M scroll N%]` indicator appears on the last panel line whenever the content overflows. Navigating the @@ -430,10 +535,25 @@ that file with a generic markdown viewer instead of showing only the raw description. The viewer: - skips the YAML frontmatter block; -- renders ATX headings, bullet lists, fenced code blocks, and paragraphs; +- renders the full GFM construct set used in `.podcast.md` episode files and + work-item descriptions: ATX heading hierarchy (`#`…`######` with distinct + glyphs per level), ordered and nested bullet lists, blockquotes, GFM + tables (aligned columns), fenced and inline code, bold/italic/ + strikethrough, links, horizontal rules, and paragraphs (word-wrapped to + the terminal width); +- renders inline `[NOTE : ...]` markers as `↗` links (see + [Inline note links](#inline-note-links)); - is preview-only (no notes editor); - falls back to the raw description when the file is missing/unreadable. +The **description section** of the detail view is rendered with the same +markdown renderer, so GFM-heavy descriptions (tables, bold/italic, inline +code, links, lists) display properly instead of as raw text. Markdown is +parsed with the [`marked`](https://github.com/markedjs/marked) library (a +declared dependency of `packages/herdr`), which is also present in the pi +agent dependency tree (`@earendil-works/pi-tui`), so the compatibility is +proven. + 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 @@ -496,16 +616,30 @@ never breaks dispatch. ## Command input form -When a chord shortcut resolves to a command that contains **unknown identifiers** — angle-bracket placeholders other than the known `` (e.g. `--status `, `--stage `, `--reason `) — the plugin displays a modal form overlay instead of dispatching the command directly: +When a chord shortcut resolves to a command that contains **unknown identifiers** — angle-bracket placeholders other than the known `` (e.g. `--status `, `--stage `, `--reason `) — the plugin displays a modal form page instead of dispatching the command directly: - One labeled input field per unknown identifier; `Tab`/`↑`/`↓` navigate between fields, `Enter` submits, `Esc` cancels. - Identifiers may declare an inline default: ``. The field is pre-filled with the default (which is used verbatim if you submit without editing it), and you can clear it or type over it before submitting. - The active field shows a block cursor at the end of its value; the typed value is substituted into the command on submit (`` remains auto-substituted with the selected item's ID). -- The dialog width is **80% of the pane width** (clamped to a 40-column minimum and to the pane width minus borders), and stays horizontally centered. -- The description and field values **wrap at the dialog's inner width**; as a value wraps to more lines the dialog **expands downward**, bounded by the terminal height so it never overflows the pane. +- The form renders as a **full-pane page layout** — no border box, no centering: content starts at the top-left corner of the pane and wraps at the full pane width. +- The description and field values **wrap at the full pane width**; as a value wraps to more lines the form **expands downward**, bounded by the terminal height so it never overflows the pane. Rendering is ANSI-aware: visible width is measured by stripping SGR escape sequences (no external width/wrap dependencies). +## Ship It confirmation dialog + +The Ship It shortcut (`S`, Shift+s) triggers a **dev→main release** via the ship skill (`/skill:ship release`), so it must never fire on a stray keypress. Pressing `S` opens a typed-confirmation dialog anchored to the **bottom** of the selection list — the list stays visible above it (no full-screen takeover, unlike the command input form and the centered Code Freeze notice): + +- The dialog shows a prompt ("Ship it? Type 'ship' to confirm, Esc to cancel") and reflects your typed input with a block cursor. +- Type `ship` (case-insensitive: `ship`, `Ship`, `SHIP`, …) and press **Enter** to dispatch `/skill:ship release` via the standard command-routing path — a global release, so **no work item id** is substituted. A "Sent" toast confirms. +- Typing anything else and pressing **Enter** clears the input buffer and keeps the dialog open so you can retry — nothing is dispatched. +- **Esc** dismisses the dialog and returns to the selection list without dispatching anything. +- While the dialog is open all keys are consumed by it (modal input); navigation resumes after Esc. + +Implementation: `ship-it-dialog.ts` holds the dialog state (`ShipItDialogState`), renders the box (`formatShipItDialog`), and composes it over the list output (`overlayShipItDialog` — bottom-anchored, within the pane height budget). The `S` entry in `src/shortcuts.json` is a single-key chord with `code_freeze` omitted, so it stays available during a Code Freeze (the ship skill gates itself). + +> **Behavior change:** the former manual-sync `S` binding (immediate `wl sync` with a toast) was removed; background auto-sync on the timer is unchanged. + ## Architecture ``` @@ -521,6 +655,7 @@ packages/herdr/ │ ├── icons.ts # Icon and colour helpers │ ├── code-freeze.ts # Code Freeze marker detection (fail-open) │ ├── form-dialog.ts # Form state + rendering for parameter input (unknown ) +│ ├── ship-it-dialog.ts # Ship It typed-confirmation dialog (bottom-anchored, S shortcut) │ ├── md-viewer.ts # Generic markdown viewer + inline [NOTE : ...] link rendering │ ├── command-log.ts # Command log: record/get last command per work item │ ├── settings.ts # User settings management @@ -548,10 +683,10 @@ packages/herdr/ - **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. +- **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, `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), and `P-n` opens a brand-new blank session (`/prompt:` with an empty prompt — no form dialog, no injected text, and no work-item association). 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). +- **Parameter input form** — Chord commands containing unknown `` placeholders open a modal input form (`form-dialog.ts`) before dispatch. The form renders as a **simple full-pane page**: no border or centering decorations, content starts at the top-left of the pane, and the description and field values wrap at the full pane width — bounded by the terminal height (see WL-0MSFZUS4Z006IRI3). - **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. @@ -590,18 +725,25 @@ Semantics: |---|---| | File present with `active: true` | **ON** — implementation blocked | | File absent | OFF | -| `active: false` (or missing) | OFF | -| Corrupt / unreadable file | OFF (fail-open) | +| `active: false` | OFF | +| Corrupt / unreadable file / wrong shape (non-object, missing or non-boolean `active`) | **Ambiguous** — banner shown; the downtime dispatcher treats it as ON (fail-closed) | -Fail-open is deliberate: a broken or missing marker must never block browsing the worklist. +Fail-open is deliberate: a broken or missing marker must never block browsing the worklist. The module exposes two reads: `isCodeFreezeActive()` / `readCodeFreezeState()` keep the fail-open semantics for browsing and shortcut blocking, while `readCodeFreezeStatus()` adds a third **ambiguous** state for fail-closed consumers (the downtime dispatcher, the ambiguous-marker banner — WL-0MSQ0RPQP00636JY). ### Plugin behaviour while frozen - **Banner** — The selection list renders a prominent red `⛔ CODE FREEZE` banner above the header, warning that implementation is blocked. The banner respects the `rows - 1` pane-height budget (see WL-0MSAAON63003N6LO). +- **Ambiguous marker banner** — When the marker is present but cannot be trusted (unreadable file, corrupt JSON, wrong shape), the selection list renders a distinct amber `⚠ Ambiguous Codefreeze marker` banner. Browsing and all non-implement commands keep working (fail-open unchanged), but the downtime dispatcher treats the project as frozen until the marker is fixed, so the operator sees why implement/audit dispatch is disabled. - **Implement shortcut hidden** — The `i` / `/skill:implement` shortcut in `shortcuts.json` carries `"code_freeze": "block"`, so while a freeze is active it is filtered out of the shortcut registry: it does not appear in the footer/chord help hints and pressing it does nothing (no dialog, no dispatch). See [Shortcut filtering during a freeze](#shortcut-filtering-during-a-freeze). - **Implement commands blocked** — Any implement command (`/skill:implement`, `/skill:implement-single`, `/skill:implementall`, via single-key `i`, chord, or typed dispatch) is **not** routed: no pi agent pane is spawned, no work item is claimed, and no `` substitution happens. The marker is re-read at dispatch time, so a freeze that starts between refreshes is still enforced. - **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. +- **Downtime dispatcher freeze gate** — While the marker is frozen **or ambiguous**, the downtime worker skips its audit and implement dispatch tiers (no new audits/implementations during a release); the plan/intake tiers continue. See [Downtime worker](#downtime-worker-local-llm-idle-dispatch). +- **Other commands unaffected** — Audit, intake, plan, review, priority, + search, and navigation continue to work normally during a freeze. The Ship + It shortcut (`S`) also stays available during a freeze: the release + command is NOT `code_freeze: "block"` — the ship skill gates itself, so + the confirmation dialog still opens and the user can consciously dispatch + the release even while a freeze is active. ### Shortcut filtering by work-item type @@ -612,7 +754,7 @@ limiting the shortcut to work items whose issue type is listed | `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 | +| `["bug","docs","feature","task","chore","epic"]` | Shortcut visible only on code and docs work item types | | omitted | Always shown (backward compatible) | Semantics: @@ -630,33 +772,38 @@ Semantics: 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 + and `i` (implement) carry `work_item_types: ["bug","docs","feature", + "task","chore","epic"]`, so they are hidden on non-code and non-docs types + (e.g. `podcast`). 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. + chords (e.g. a `w` chord leader → `wiki-podcast-script` for `podcast` + items) via the project-local `shortcuts.json` mechanism above. -### Podcast-progression dispatch markers (OSL-0MSKFXM380098LFL) +### Podcast-progression dispatch markers (OSL-0MSKFXM380098LFL, +OSL-0MSKVB5K6008XFOQ) 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 +`shortcuts.json` that dispatch the podcast pipeline skills. Four 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 ` | +| `` | `w s` write-script sub-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 ` | +| `` | `w r` write-review sub-chord: first `.podcast.md` Key File in raw form (runs the 6 reviews with `--review`; belt-and-braces error when no script exists yet) | `/skill:wiki-podcast-script --review ` | +| `` | `w b` write-both sub-chord: first `.podcast.md` Key File in raw form (runs reviews + rewrite in one pass with `--review-rewrite`, 7 LLM calls; belt-and-braces error when no script exists yet) | `/skill:wiki-podcast-script --review-rewrite ` | | `` | `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`). +All markers require the chord entry to carry `work_item_types: ["podcast"]` +so they are only visible on podcast-typed items (see [Shortcut filtering by +work-item type](#shortcut-filtering-by-work-item-type)); the `w` sub-chords +should additionally be stage-limited to the podcast lifecycle +(`w s`: `intake_complete`, `plan_complete`, `in_review`, `done`; `w r` / +`w b`: `plan_complete`, `in_review`, `done` — the stages where a script +exists). ### Shortcut filtering during a freeze diff --git a/packages/herdr/docs/downtime-config-audit-2026-08-08.md b/packages/herdr/docs/downtime-config-audit-2026-08-08.md new file mode 100644 index 00000000..442fe31a --- /dev/null +++ b/packages/herdr/docs/downtime-config-audit-2026-08-08.md @@ -0,0 +1,103 @@ +# Downtime worker configuration audit — 2026-08-08 idle window + +**Date:** 2026-08-08/09 · **Work items:** WL-0MSKUG2WW0058A7W (evaluation), parent RCA WL-0MSK9TUCA00206M7 + +## Purpose + +Document the enabled/disabled/paused/window-rule configuration state of every +dispatch-capable herdr downtime worker pane that observed the idle window of +2026-08-07 21:55Z → 2026-08-08 (RCA: zero dispatches overnight despite +continuous status polling). This answers RCA hypothesis 4 (config/disablement) +and is acceptance-criterion 1 of WL-0MSKUG2WW0058A7W: *"Config state +(enabled/disabled/paused/window rules) documented for every dispatch-capable +pane that observed the idle window."* + +## Method + +1. Settings schema + defaults read from source (`packages/herdr/src/settings.ts`, + `packages/herdr/src/downtime-worker.ts`). +2. Live settings file inspected: `~/.config/herdr/worklog-plugin.json`. +3. Per-project dispatch logs inspected: + `/.worklog/downtime-dispatches.log` (written by every successful + dispatch and by the three-strike `recordError` path). + +## 1. Settings schema — no dispatch-window-rule setting exists + +The `PluginSettings` schema (`packages/herdr/src/settings.ts:43-60`) has +exactly these downtime keys: `downtimeEnabled`, `downtimeIdleThresholdMs`, +`downtimeRequiredFreeSlots`, `downtimePollIntervalMs`, `downtimeProxyUrl`, +`downtimeModel`, `downtimeNoCandidateCooldownMs`. + +There is **no dispatch-window-rule setting** (no start/end time, no +day-of-week gate). The worker dispatches whenever the proxy reports idle +continuously for `downtimeIdleThresholdMs` — there is no calendar/window +constraint to disable. + +The only gating that exists is the pane-visibility rule, and the downtime task +is the **one** scheduler task that is deliberately **NOT** visibility-gated +(`packages/herdr/src/worklist.ts:3378-3385`): unlike refresh/sync it runs +while the worklist pane is open, hidden or not. A hidden pane still dispatches. + +## 2. Defaults in force + +`defaultSettings` (`packages/herdr/src/settings.ts:73-79`) and module constants +(`packages/herdr/src/downtime-worker.ts:80,117`): + +| Setting | Default | Meaning | +|---|---|---| +| `downtimeEnabled` | `true` | worker enabled | +| `downtimeProxyUrl` | `http://192.168.0.199:8000` | llama-proxy endpoint | +| `downtimeNoCandidateCooldownMs` | `3_600_000` (60 min) | pause after genuine empty backlog | +| `downtimeIdleThresholdMs` | `240_000` (4 min) | min continuous idle before dispatch | +| `downtimePollIntervalMs` | `30_000` | proxy poll cadence | +| `downtimeModel` | `plan` | pi model for dispatched panes | + +Missing keys fall back to these defaults (`settings.ts:149-166`). + +## 3. Live config — defaults in force + +`~/.config/herdr/worklog-plugin.json` (inspected 2026-08-09) contains **no** +`downtime*` keys — only `browseItemCount`, `showHelpText`, `autoRefresh`, +`refreshIntervalMs`, `showIcons`, `autoSync`, `syncIntervalMs`. Therefore every +pane loading this settings file runs the worker **enabled** with the defaults +above. + +## 4. Dispatch-capable panes observed in the idle window + +Five worklist panes — one per project worklog root on this host — each with an +active `.worklog/downtime-dispatches.log`: + +| Worklog root | Log entries | Error entries | Last dispatch (UTC) | +|---|---|---|---| +| `/home/rgardler/projects/ContextHub` | 29 | 0 | 2026-08-09T13:49:30Z | +| `/home/rgardler/projects/SorraAgents` | 17 | 0 | 2026-08-09T13:05:09Z | +| `/home/rgardler/projects/Tableau-Card-Engine` | 18 | 0 | 2026-08-09T13:49:30Z | +| `/home/rgardler/projects/dev-scripts` | 15 | 0 | 2026-08-09T10:22:16Z | +| `/home/rgardler/projects/open_source_llm` | 22 | 0 | 2026-08-09T00:46:25Z | + +Evidence notes: + +- **Enabled:** all panes load the shared settings file with no `downtime*` + keys → `downtimeEnabled: true` (defaults in force). +- **Not paused (no-candidate cooldown):** a paused worker stops proxy polling + entirely. The proxy log shows continuous ~16 polls/min status polling + through the idle window (per Map's comment on WL-0MSKUG2WW0058A7W, + 2026-08-08T23:56Z), so no pane was in the 60-min no-candidate pause. +- **No three-strike pause:** `recordError` writes to the same rolling log; all + five logs have **zero error entries**, so no pane ever hit three consecutive + wl CLI errors. +- **Dispatch-capable after the window:** dispatch activity resumed 2026-08-09 + (ContextHub/SorraAgents/Tableau/dev-scripts entries from 05:36Z onward), + corroborating enabled, working workers. + +## Conclusion + +Every dispatch-capable pane that observed the idle window was **enabled with +default settings**: no window rules exist to constrain dispatch, no worker was +in no-candidate cooldown (polling never paused), and no three-strike pause +ever fired (zero error entries). **RCA hypothesis 4 (config/disablement) is +refuted** for these panes. The zero-dispatch night is attributed to the +proxy-side status evaluation (stale dispatch lease / stuck global +`active_query`), captured in follow-ups WL-0MSL2ZQIF006QB4Q (prefer +`local_active_query`) and WL-0MRE6JDT3004OSTF (proactive lease release) — see +the RCA WL-0MSK9TUCA00206M7. diff --git a/packages/herdr/package-lock.json b/packages/herdr/package-lock.json new file mode 100644 index 00000000..9e759ec7 --- /dev/null +++ b/packages/herdr/package-lock.json @@ -0,0 +1,449 @@ +{ + "name": "@worklog/herdr-plugin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@worklog/herdr-plugin", + "version": "0.1.0", + "dependencies": { + "@worklog/shared": "file:../../packages/shared", + "marked": "^18.0.5" + } + }, + "node_modules/@worklog/shared": { + "version": "1.0.0", + "resolved": "file:../shared", + "license": "MIT", + "dependencies": { + "better-sqlite3": "^12.6.2" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "node_modules/marked": { + "version": "18.0.9", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.9.tgz", + "integrity": "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + } + } +} diff --git a/packages/herdr/package.json b/packages/herdr/package.json index e536275a..4824f63a 100644 --- a/packages/herdr/package.json +++ b/packages/herdr/package.json @@ -9,6 +9,7 @@ "dev": "tsx src/index.ts" }, "dependencies": { - "@worklog/shared": "file:../../packages/shared" + "@worklog/shared": "file:../../packages/shared", + "marked": "^18.0.5" } } diff --git a/packages/herdr/src/code-freeze.test.ts b/packages/herdr/src/code-freeze.test.ts index f01f318a..8d8c4657 100644 --- a/packages/herdr/src/code-freeze.test.ts +++ b/packages/herdr/src/code-freeze.test.ts @@ -12,13 +12,15 @@ */ import { describe, it, expect, afterEach, beforeEach } from 'vitest'; -import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { CODE_FREEZE_MARKER_FILENAME, codeFreezeMarkerPath, readCodeFreezeState, + readCodeFreezeStatus, + readCodeFreezeStatusForRoot, isCodeFreezeActive, type CodeFreezeState, } from './code-freeze.js'; @@ -127,6 +129,77 @@ describe('isCodeFreezeActive', () => { }); }); +// ── Tri-state read (WL-0MSQ0RPQP00636JY) ───────────────────────────── +// The downtime dispatcher must distinguish "not frozen" from "cannot tell": +// an ambiguous marker (unreadable file, corrupt JSON, wrong shape) is +// treated as frozen (fail-closed) so no implement/audit work starts during +// a release. `readCodeFreezeState` / `isCodeFreezeActive` keep their +// fail-open semantics for browsing — only the new tri-state read changes. + +describe('readCodeFreezeStatus (tri-state)', () => { + it('is frozen for an active marker', () => { + writeMarker(JSON.stringify({ active: true, reason: 'ship release' })); + expect(readCodeFreezeStatus(tmpDir)).toBe('frozen'); + }); + + it('is not-frozen when the marker file is absent', () => { + expect(readCodeFreezeStatus(tmpDir)).toBe('not-frozen'); + }); + + it('is not-frozen when active is false', () => { + writeMarker(JSON.stringify({ active: false, reason: 'released' })); + expect(readCodeFreezeStatus(tmpDir)).toBe('not-frozen'); + }); + + it('is ambiguous for corrupt JSON (fail-closed)', () => { + writeMarker('{ not json !!!'); + expect(readCodeFreezeStatus(tmpDir)).toBe('ambiguous'); + }); + + it('is ambiguous when the marker path is unreadable (e.g. a directory)', () => { + const markerAsDir = join(tmpDir, CODE_FREEZE_MARKER_FILENAME); + mkdirSync(markerAsDir); // marker path exists as a dir → readFileSync throws EISDIR + expect(readCodeFreezeStatus(tmpDir)).toBe('ambiguous'); + }); + + it('is ambiguous for a non-object marker (array / string / number)', () => { + writeMarker('[1, 2, 3]'); + expect(readCodeFreezeStatus(tmpDir)).toBe('ambiguous'); + writeMarker('"hello"'); + expect(readCodeFreezeStatus(tmpDir)).toBe('ambiguous'); + writeMarker('42'); + expect(readCodeFreezeStatus(tmpDir)).toBe('ambiguous'); + }); + + it('is ambiguous when active is missing (wrong shape)', () => { + writeMarker(JSON.stringify({ reason: 'oops' })); + expect(readCodeFreezeStatus(tmpDir)).toBe('ambiguous'); + }); + + it('is ambiguous when active is not a boolean (wrong shape)', () => { + writeMarker(JSON.stringify({ active: 'yes' })); + expect(readCodeFreezeStatus(tmpDir)).toBe('ambiguous'); + writeMarker(JSON.stringify({ active: null })); + expect(readCodeFreezeStatus(tmpDir)).toBe('ambiguous'); + }); +}); + +describe('readCodeFreezeStatusForRoot', () => { + it('resolves the marker under /.worklog', () => { + mkdirSync(join(tmpDir, '.worklog'), { recursive: true }); + writeFileSync( + join(tmpDir, '.worklog', CODE_FREEZE_MARKER_FILENAME), + JSON.stringify({ active: true, reason: 'ship release' }), + 'utf8', + ); + expect(readCodeFreezeStatusForRoot(tmpDir)).toBe('frozen'); + }); + + it('is not-frozen when the root has no marker', () => { + expect(readCodeFreezeStatusForRoot(join(tmpDir, 'no-marker-root'))).toBe('not-frozen'); + }); +}); + // ── Type-level sanity ───────────────────────────────────────────────────── describe('CodeFreezeState type', () => { diff --git a/packages/herdr/src/code-freeze.ts b/packages/herdr/src/code-freeze.ts index 4fa2cb98..32e6332c 100644 --- a/packages/herdr/src/code-freeze.ts +++ b/packages/herdr/src/code-freeze.ts @@ -21,6 +21,13 @@ * The plugin only READS the marker; writing/clearing it is the ship skill's * job (tracked in SorraAgents). Fail-open is deliberate: a broken or missing * marker must never block browsing the worklist. + * + * For fail-closed consumers (the downtime dispatcher, the ambiguous-marker + * banner — WL-0MSQ0RPQP00636JY) the module also exposes a tri-state read + * (`readCodeFreezeStatus`): frozen / not-frozen / ambiguous. A marker that + * cannot be trusted (unreadable, corrupt, wrong shape) is 'ambiguous', which + * the dispatcher treats as frozen — never dispatch implement/audit work + * during a release just because the marker cannot be parsed. */ import { existsSync, readFileSync } from 'node:fs'; @@ -108,3 +115,78 @@ export function readCodeFreezeState(worklogDir?: string): CodeFreezeState { export function isCodeFreezeActive(worklogDir?: string): boolean { return readCodeFreezeState(worklogDir).active; } + +// ── Tri-state read (WL-0MSQ0RPQP00636JY) ───────────────────────────── + +/** + * Tri-state interpretation of the code-freeze marker: + * + * - `'frozen'` — marker present with `active: true` (project frozen). + * - `'not-frozen'` — marker absent, or present with `active: false`. + * - `'ambiguous'` — marker present but cannot be trusted: unreadable + * file, corrupt JSON, or wrong shape (non-object, or + * missing/non-boolean `active`). Callers that must fail + * closed (the downtime dispatcher, the ambiguous-marker + * banner) treat this as frozen. + */ +export type CodeFreezeStatus = 'frozen' | 'not-frozen' | 'ambiguous'; + +/** + * Read the code-freeze marker as a tri-state status. + * + * `readCodeFreezeState` / `isCodeFreezeActive` keep their fail-open + * semantics (everything not provably frozen reads as not-frozen) for + * worklist browsing and shortcut blocking — this read ADDS the ability to + * distinguish "not frozen" from "cannot tell", so fail-closed consumers + * (the downtime dispatcher, the ambiguous-marker banner) can act on + * ambiguity without changing any existing caller's behavior. + * + * @param worklogDir - Optional explicit worklog directory (defaults to the + * configured worklog dir). + * @returns 'frozen' when the marker is active, 'not-frozen' when it is + * absent or inactive, 'ambiguous' when it cannot be parsed. + */ +export function readCodeFreezeStatus(worklogDir?: string): CodeFreezeStatus { + const markerPath = codeFreezeMarkerPath(worklogDir); + if (!markerPath) { + return 'not-frozen'; + } + + let raw: string; + try { + if (!existsSync(markerPath)) { + return 'not-frozen'; + } + raw = readFileSync(markerPath, 'utf8'); + } catch { + // Unreadable file → cannot tell → ambiguous (fail-closed for dispatch). + return 'ambiguous'; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return 'ambiguous'; // corrupt JSON + } + if (typeof parsed !== 'object' || parsed === null) { + return 'ambiguous'; // wrong shape: array, string, number, null... + } + const active = (parsed as Record).active; + if (typeof active !== 'boolean') { + return 'ambiguous'; // wrong shape: `active` missing or not a boolean + } + return active ? 'frozen' : 'not-frozen'; +} + +/** + * Read the code-freeze tri-state for a worklog ROOT directory (the project + * root that contains `.worklog/`) — e.g. the downtime worker's `cwd`. The + * marker lives at `/.worklog/code-freeze.json`. + * + * @param root - Worklog root directory (contains `.worklog/`). + * @returns The tri-state freeze status for the root's marker. + */ +export function readCodeFreezeStatusForRoot(root: string): CodeFreezeStatus { + return readCodeFreezeStatus(join(root, '.worklog')); +} diff --git a/packages/herdr/src/downtime-log.test.ts b/packages/herdr/src/downtime-log.test.ts index d6d4a46d..0c4bfef3 100644 --- a/packages/herdr/src/downtime-log.test.ts +++ b/packages/herdr/src/downtime-log.test.ts @@ -13,6 +13,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { appendDowntimeLogEntry, + auditDispatchedItemIds, + implementDispatchedItemIds, + readDowntimeLogEntries, DOWNTIME_LOG_FILE, DOWNTIME_LOG_MAX_ENTRIES, } from './downtime-log.js'; @@ -77,3 +80,80 @@ describe('downtime rolling log', () => { expect(JSON.parse(lines[lines.length - 1])).toEqual({ n: total - 1 }); }); }); + +describe('readDowntimeLogEntries (fail-safe lookup)', () => { + it('returns [] when the log file does not exist', async () => { + const cwd = makeTempCwd(); + expect(await readDowntimeLogEntries(cwd)).toEqual([]); + }); + + it('returns [] when the log file is unreadable', async () => { + const cwd = makeTempCwd(); + // A directory at the log path cannot be read as a file (EISDIR). + mkdirSync(join(cwd, '.worklog', DOWNTIME_LOG_FILE), { recursive: true }); + expect(await readDowntimeLogEntries(cwd)).toEqual([]); + }); + + it('skips malformed JSONL lines and parses the valid ones', async () => { + const cwd = makeTempCwd(); + mkdirSync(join(cwd, '.worklog')); + writeFileSync( + join(cwd, '.worklog', DOWNTIME_LOG_FILE), + '{"itemId":"WL-A","kind":"audit"}\nnot-json\n{"itemId":"WL-B","kind":"plan"}\n\n', + 'utf8', + ); + expect(await readDowntimeLogEntries(cwd)).toEqual([ + { itemId: 'WL-A', kind: 'audit' }, + { itemId: 'WL-B', kind: 'plan' }, + ]); + }); + + it('round-trips entries written by appendDowntimeLogEntry', async () => { + const cwd = makeTempCwd(); + const entry = { itemId: 'WL-A', kind: 'audit', dispatchedAt: '2026-01-01T00:00:00.000Z', cwd }; + await appendDowntimeLogEntry(cwd, JSON.stringify(entry)); + expect(await readDowntimeLogEntries(cwd)).toEqual([entry]); + }); +}); + +describe('auditDispatchedItemIds (audit-tier-only scope guard)', () => { + it('collects only audit-kind entries that carry an itemId', () => { + const ids = auditDispatchedItemIds([ + { itemId: 'WL-A', kind: 'audit' }, + { itemId: 'WL-B', kind: 'plan' }, + { itemId: 'WL-C', kind: 'intake' }, + { kind: 'audit' }, // error-style entry without itemId → ignored + { itemId: 'WL-D', kind: 'audit' }, + ]); + expect([...ids].sort()).toEqual(['WL-A', 'WL-D']); + }); + + it('returns an empty set for empty input', () => { + expect([...auditDispatchedItemIds([])]).toEqual([]); + }); +}); + +describe('implementDispatchedItemIds (implement-tier-only scope guard)', () => { + it('collects only implement-kind entries that carry an itemId', () => { + const ids = implementDispatchedItemIds([ + { itemId: 'WL-A', kind: 'implement' }, + { itemId: 'WL-B', kind: 'plan' }, + { itemId: 'WL-C', kind: 'audit' }, + { kind: 'implement' }, // error-style entry without itemId → ignored + { itemId: 'WL-D', kind: 'implement' }, + ]); + expect([...ids].sort()).toEqual(['WL-A', 'WL-D']); + }); + + it('does not collect audit markers (implement tier is scoped to kind implement only)', () => { + const ids = implementDispatchedItemIds([ + { itemId: 'WL-AUD', kind: 'audit' }, + { itemId: 'WL-IMP', kind: 'implement' }, + ]); + expect([...ids]).toEqual(['WL-IMP']); + }); + + it('returns an empty set for empty input', () => { + expect([...implementDispatchedItemIds([])]).toEqual([]); + }); +}); diff --git a/packages/herdr/src/downtime-log.ts b/packages/herdr/src/downtime-log.ts index 2f52149e..b4faa1f3 100644 --- a/packages/herdr/src/downtime-log.ts +++ b/packages/herdr/src/downtime-log.ts @@ -8,6 +8,13 @@ * 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). + * + * The log doubles as the dispatched-marker source for the audit tier + * (WL-0MSLIY8ZR004QUSY): `readDowntimeLogEntries` + `auditDispatchedItemIds` + * let the audit-tier selection exclude items the downtime worker has already + * dispatched for `/skill:audit`, closing the re-selection loop where a + * dispatched run reverts the item to completed/in_review without a fresh + * audit. */ import { mkdir, readFile, writeFile } from 'node:fs/promises'; @@ -19,6 +26,90 @@ 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; +/** + * One parsed line of the downtime dispatch log: either a dispatch event + * (itemId/kind/dispatchedAt/title) or a persistent-error event + * (cwd/at/message, three-strike trail). All fields optional so malformed + * or foreign lines never break parsing. + */ +export interface DowntimeLogEntry { + itemId?: string; + kind?: string; + dispatchedAt?: string; + cwd?: string; + title?: string; + at?: string; + message?: string; +} + +/** + * Read the bounded rolling dispatch log at `/.worklog/downtime-dispatches.log`. + * + * FAIL-SAFE by contract (WL-0MSLIY8ZR004QUSY): a missing or unreadable log + * yields `[]` (a missing log is the normal first-run state — treating it as + * empty keeps audit-tier dispatch working on a fresh worklog, and a + * corrupted log must not silently disable the audit tier either), and + * malformed JSONL lines are skipped without throwing. This function never + * throws. + */ +export async function readDowntimeLogEntries(cwd: string): Promise { + const file = join(cwd, '.worklog', DOWNTIME_LOG_FILE); + let raw: string; + try { + raw = await readFile(file, 'utf8'); + } catch { + return []; // missing or unreadable → empty (fail-safe) + } + const entries: DowntimeLogEntry[] = []; + for (const line of raw.split('\n')) { + if (line.trim() === '') continue; + try { + const parsed = JSON.parse(line) as unknown; + if (typeof parsed === 'object' && parsed !== null) { + entries.push(parsed as DowntimeLogEntry); + } + } catch { + // malformed line → skip (fail-safe) + } + } + return entries; +} + +/** + * Build the set of itemIds the downtime worker has already dispatched for + * `/skill:audit` (`kind === 'audit'` entries only). Plan/intake markers are + * scoped to their own tiers and must NOT suppress audit-tier selection + * (audit-tier-only scope guard, WL-0MSLIY8ZR004QUSY). Entries without an + * itemId (e.g. persistent-error events) are ignored. + */ +export function auditDispatchedItemIds(entries: DowntimeLogEntry[]): Set { + const ids = new Set(); + for (const e of entries) { + if (e.kind === 'audit' && typeof e.itemId === 'string' && e.itemId.length > 0) { + ids.add(e.itemId); + } + } + return ids; +} + +/** + * Build the set of itemIds the downtime worker has already dispatched for + * `/skill:implement` (`kind === 'implement'` entries only). Audit/plan/ + * intake markers are scoped to their own tiers and must NOT suppress + * implement-tier selection (implement-tier-only scope guard, + * WL-0MSMAYPQP001FLR6 AC6). Entries without an itemId (e.g. persistent-error + * events) are ignored. + */ +export function implementDispatchedItemIds(entries: DowntimeLogEntry[]): Set { + const ids = new Set(); + for (const e of entries) { + if (e.kind === 'implement' && typeof e.itemId === 'string' && e.itemId.length > 0) { + ids.add(e.itemId); + } + } + return ids; +} + /** * Append one entry (a JSONL line) to `/.worklog/downtime-dispatches.log`, * creating the `.worklog` directory if needed and trimming the file back to diff --git a/packages/herdr/src/downtime-worker.test.ts b/packages/herdr/src/downtime-worker.test.ts index 491d9ae7..bbafeaad 100644 --- a/packages/herdr/src/downtime-worker.test.ts +++ b/packages/herdr/src/downtime-worker.test.ts @@ -11,6 +11,13 @@ * and fail-closed parsing from WL-0MSG80254005ZNE9, and the idle tracker, * dispatch orchestration, pane spawn and worker orchestrator from * WL-0MSG80AG700429M8). + * + * Implement-tier tests (WL-0MSMAYIKX005LLO4): test-first matrix for the + * implement dispatch tier — dispatch priority (audit → implement → plan → + * intake), status=open client-side filter, dispatched-marker exclusion, + * parseImplementCandidatesOutput / selectImplementCandidate, and + * implement prompt/pane helpers. Red phase: these fail until the implement + * tier lands (WL-0MSMAYPQP001FLR6). */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -29,6 +36,8 @@ import { buildDowntimeSpawnOptions, BLOCKED_QUESTIONS_INSTRUCTION, parseNextItemOutput, + parseImplementCandidatesOutput, + selectImplementCandidate, parseAuditCandidatesOutput, selectAuditCandidate, toDowntimeCandidate, @@ -49,6 +58,7 @@ import { type DowntimeWorkerDeps, type DowntimeSpawn, type AuditCandidate, + type ImplementCandidate, } from './downtime-worker.js'; import { statusFixtures, @@ -66,10 +76,14 @@ function makeDeps(overrides: Partial = {}): DowntimeWorkerDe return { getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), getNextAuditCandidate: vi.fn().mockResolvedValue(null), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), claimItem: vi.fn().mockResolvedValue(undefined), spawnAgentPane: vi.fn().mockResolvedValue(undefined), recordDispatch: vi.fn().mockResolvedValue(undefined), recordError: vi.fn().mockResolvedValue(undefined), + // Code-freeze gate (WL-0MSQ0RPQP00636JY): not frozen by default, so + // existing dispatch tests exercise the unchanged audit/implement tiers. + readCodeFreezeStatus: vi.fn().mockReturnValue('not-frozen'), ...overrides, }; } @@ -114,6 +128,17 @@ describe('idle detection (isIdleStatus)', () => { expect(isIdleStatus({ ...idle, active_query: true }, 0)).toBe(false); }); + it('is idle during remote-only traffic when local_active_query=false (global active_query true)', () => { + // Remote provider streams keep the GLOBAL active_query true while the + // local model is idle with free slots; the proxy's local_active_query is + // the local-only signal (LP-0MSL2ZLLS009RVKR) and must not block dispatch. + expect(isIdleStatus({ ...idle, active_query: true, local_active_query: false }, 0)).toBe(true); + }); + + it('is busy when local_active_query=true (a local query is in flight)', () => { + expect(isIdleStatus({ ...idle, local_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); }); @@ -404,6 +429,454 @@ describe('dispatch audit tier', () => { }); }); +// ── Implement dispatch tier (WL-0MSMAYIKX005LLO4) ──────────────────── + +describe('dispatch implement tier', () => { + const implementCandidate: DowntimeCandidate = { + id: 'WL-IMP', + title: 'Implement me', + stage: 'implement', + }; + + it('dispatches /skill:implement on the implement candidate after the audit tier', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue(null), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('WL-IMP'); + expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextImplementCandidate).toHaveBeenCalledWith('/repo'); + expect(deps.claimItem).toHaveBeenCalledWith('WL-IMP'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:implement WL-IMP'), + { model: 'plan', cwd: '/repo' }, + ); + }); + + it('records the implement dispatch with kind implement', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue(null), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; + expect(event.itemId).toBe('WL-IMP'); + expect(event.kind).toBe('implement'); + expect(event.cwd).toBe('/repo'); + }); + + it('dispatch priority is audit → implement → plan → intake (audit stays first)', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ id: 'WL-AUD', title: 'Audit me', stage: 'audit' }), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: { id: 'WL-PLN', title: 'Plan me', stage: 'intake_complete' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Audit wins — the implement tier is never consulted when an audit + // candidate exists (audit is the release gate). + expect(outcome.kind).toBe('audit'); + expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); + expect(deps.getNextItem).not.toHaveBeenCalled(); + }); + + it('falls back to the implement tier when no audit candidate exists, and audit runs first', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue(null), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('implement'); + expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextItem).not.toHaveBeenCalled(); + }); + + it('falls back to plan (intake_complete) when no implement candidate exists', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue(null), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-PLN', title: 'Plan me', stage: 'intake_complete' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('plan'); + expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextItem).toHaveBeenCalledTimes(1); + expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete'); + }); + + it('falls back to intake (idea) when no implement or plan candidate exists', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue(null), + getNextImplementCandidate: 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(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea'); + }); + + it('a wl next CLI error at the implement tier does NOT short-circuit the plan/intake fallback', async () => { + // Mirrors the tier-2 (intake_complete) error handling: getNextImplementCandidate + // is fail-closed at the deps boundary (a wl failure yields null), so the + // plan/intake tiers still run. The implement tier itself never signals a + // hard error — null IS the fail-closed shape (AC6). + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue(null), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), // wl error → null + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-PLN', title: 'Plan me', stage: 'intake_complete' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('plan'); + expect(deps.getNextItem).toHaveBeenCalled(); + }); + + it('no-candidate when all tiers (audit, implement, plan, intake) are empty', async () => { + const deps = makeDeps(); // all four 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(); + expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextItem).toHaveBeenCalledTimes(2); + }); +}); + +// ── Code-freeze gate (WL-0MSQ0RPQP00636JY) ────────────────────────── +// The dispatcher must honour the ship-it code-freeze marker: while frozen +// (or ambiguous — fail-closed), the audit and implement tiers are skipped +// (no new implementation work / audits during a release) and the plan/intake +// tiers still dispatch (low-risk prep). The marker is re-read on EVERY +// dispatch (never cached) so a freeze that starts or ends mid-idle-period is +// honored on the next dispatch attempt. A freeze skip is reason +// 'code-freeze' — never 'no-candidate' — so it cannot trigger the worker's +// no-candidate cooldown: polling continues and resume is immediate. + +describe('dispatch code-freeze gate', () => { + const auditCandidate: DowntimeCandidate = { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }; + const implementCandidate: DowntimeCandidate = { id: 'WL-IMP', title: 'Implement me', stage: 'implement' }; + const planCandidate: DowntimeCandidate = { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete' }; + + it('skips the audit and implement tiers while frozen and still dispatches plan', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + getNextAuditCandidate: vi.fn().mockResolvedValue(auditCandidate), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: planCandidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Frozen → the audit/implement tiers are never consulted. + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); + // Plan/intake tiers still dispatch. + expect(deps.getNextItem).toHaveBeenCalledWith('intake_complete'); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(deps.claimItem).toHaveBeenCalledWith('WL-PLAN'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-PLAN'), + expect.anything(), + ); + }); + + it('treats an ambiguous marker as frozen (fail-closed): no audit/implement, plan still dispatches', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('ambiguous'), + getNextAuditCandidate: vi.fn().mockResolvedValue(auditCandidate), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: planCandidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + }); + + it('frozen with an empty plan/intake backlog reports code-freeze (never no-candidate)', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + // The freeze itself must never look like a genuine empty backlog: the + // worker must NOT enter the no-candidate cooldown (polling continues). + expect(outcome.reason).toBe('code-freeze'); + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('ambiguous with an empty plan/intake backlog also reports code-freeze', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('ambiguous'), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('code-freeze'); + }); + + it('frozen still surfaces wl CLI errors as wl-error (a strike, not hidden by the freeze)', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + getNextItem: vi.fn().mockResolvedValue({ ok: false }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('wl-error'); + }); + + it('re-reads the marker on every dispatch: implement/audit resume immediately when the freeze lifts', async () => { + // First dispatch: frozen → no audit/implement, empty plan/intake → code-freeze. + const freezeStatus = vi.fn().mockReturnValue('frozen'); + const deps = makeDeps({ + readCodeFreezeStatus: freezeStatus, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const frozenOutcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + expect(frozenOutcome.reason).toBe('code-freeze'); + expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); + + // The freeze lifts (marker now reads not-frozen): the SAME deps object is + // re-read on the next dispatch — no caching — and the implement tier + // dispatches again. + freezeStatus.mockReturnValue('not-frozen'); + deps.getNextImplementCandidate = vi.fn().mockResolvedValue(implementCandidate); + + const resumed = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + expect(resumed.dispatched).toBe(true); + expect(resumed.kind).toBe('implement'); + expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:implement WL-IMP'), + expect.anything(), + ); + }); + + it('not-frozen behaves as before: the audit tier still runs', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('not-frozen'), + getNextAuditCandidate: vi.fn().mockResolvedValue(auditCandidate), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('audit'); + expect(deps.getNextAuditCandidate).toHaveBeenCalledWith('/repo'); + }); +}); + +// ── Implement candidate selection (WL-0MSMAYIKX005LLO4) ────────────── + +describe('implement selection (selectImplementCandidate)', () => { + const open: ImplementCandidate = { + id: 'WL-OPEN', + title: 'open item', + status: 'open', + risk: 'low', + effort: 'small', + sortIndex: 100, + }; + const completed: ImplementCandidate = { + id: 'WL-DONE', + title: 'completed item', + status: 'completed', + risk: 'low', + effort: 'small', + sortIndex: 200, + }; + + it('selects the first open candidate in wl next priority order (ascending sortIndex)', () => { + expect(selectImplementCandidate([open, completed])?.id).toBe('WL-OPEN'); + }); + + it('excludes completed candidates (status=open client-side filter, AC2)', () => { + expect(selectImplementCandidate([completed, { ...open, id: 'WL-OPEN2', sortIndex: 300 }])?.id).toBe('WL-OPEN2'); + }); + + it('returns null when no open candidate exists', () => { + expect(selectImplementCandidate([completed])).toBeNull(); + }); + + it('returns null on an empty list', () => { + expect(selectImplementCandidate([])).toBeNull(); + }); + + it('sorts ascending by sortIndex (wl next priority order preserved)', () => { + const low = { ...open, id: 'WL-LOW', sortIndex: 50 }; + const high = { ...open, id: 'WL-HIGH', sortIndex: 900 }; + expect(selectImplementCandidate([high, low])?.id).toBe('WL-LOW'); + }); + + it('excludes candidates present in the dispatched marker set (kind implement)', () => { + const dispatched = new Set(['WL-DUP']); + const dup = { ...open, id: 'WL-DUP', sortIndex: 10 }; + const fresh = { ...open, id: 'WL-FRESH', sortIndex: 20 }; + expect(selectImplementCandidate([dup, fresh], dispatched)?.id).toBe('WL-FRESH'); + }); + + it('an empty dispatched set is a no-op', () => { + expect(selectImplementCandidate([open], new Set())?.id).toBe('WL-OPEN'); + }); + + // ── Risk/effort threshold boundaries (AC1) ────────────────────────── + // Belt-and-suspenders client-side guard: even though `wl next --risk low + // --effort small` filters server-side, the herdr tier verifies the + // thresholds again so a malformed/absent server filter can never dispatch + // a Medium+/Large+ item (fail-closed). + + it('only risk exactly Low is eligible (Medium/High/Critical excluded)', () => { + const medium = { ...open, id: 'WL-MED', risk: 'medium' as const }; + const high = { ...open, id: 'WL-HIGH-R', risk: 'high' as const }; + const critical = { ...open, id: 'WL-CRIT', risk: 'critical' as const }; + expect(selectImplementCandidate([medium, high, critical, open])?.id).toBe('WL-OPEN'); + expect(selectImplementCandidate([medium])).toBeNull(); + expect(selectImplementCandidate([high])).toBeNull(); + expect(selectImplementCandidate([critical])).toBeNull(); + }); + + it('effort Small and Extra Small eligible (Medium/Large/Extra Large excluded)', () => { + const xs = { ...open, id: 'WL-XS', effort: 'xs' as const }; + const medium = { ...open, id: 'WL-MED-E', effort: 'medium' as const }; + const large = { ...open, id: 'WL-LARGE', effort: 'large' as const }; + const xl = { ...open, id: 'WL-XL', effort: 'xl' as const }; + expect(selectImplementCandidate([medium, large, xl, open])?.id).toBe('WL-OPEN'); + expect(selectImplementCandidate([xs])?.id).toBe('WL-XS'); + expect(selectImplementCandidate([medium])).toBeNull(); + expect(selectImplementCandidate([large])).toBeNull(); + expect(selectImplementCandidate([xl])).toBeNull(); + }); + + it('unset/empty risk or effort items are excluded (fail-closed)', () => { + const noRisk = { ...open, id: 'WL-NORISK', risk: undefined }; + const noEffort = { ...open, id: 'WL-NOEFFORT', effort: undefined }; + expect(selectImplementCandidate([noRisk, open])?.id).toBe('WL-OPEN'); + expect(selectImplementCandidate([noEffort, open])?.id).toBe('WL-OPEN'); + expect(selectImplementCandidate([noRisk])).toBeNull(); + expect(selectImplementCandidate([noEffort])).toBeNull(); + }); + + it('recognizes long-form effort spellings (Small / Extra Small)', () => { + const small = { ...open, id: 'WL-S', effort: 'Small' as const }; + const extraSmall = { ...open, id: 'WL-ES', effort: 'Extra Small' as const }; + expect(selectImplementCandidate([small])?.id).toBe('WL-S'); + expect(selectImplementCandidate([extraSmall])?.id).toBe('WL-ES'); + }); +}); + +describe('parseImplementCandidatesOutput', () => { + it('parses the wl next -n N { workItems: [{ workItem }] } shape', () => { + const stdout = JSON.stringify({ + success: true, + count: 2, + requested: 10, + results: [ + { workItem: { id: 'WL-A', title: 'A', status: 'open', risk: 'low', effort: 'small' } }, + { workItem: { id: 'WL-B', title: 'B', status: 'open', risk: 'low', effort: 'xs' } }, + ], + workItems: [ + { workItem: { id: 'WL-A', title: 'A', status: 'open', risk: 'low', effort: 'small' } }, + { workItem: { id: 'WL-B', title: 'B', status: 'open', risk: 'low', effort: 'xs' } }, + ], + }); + const parsed = parseImplementCandidatesOutput(stdout); + expect(parsed).not.toBeNull(); + expect(parsed).toHaveLength(2); + expect(parsed?.[0]).toMatchObject({ id: 'WL-A', status: 'open', risk: 'low', effort: 'small' }); + expect(parsed?.[1]).toMatchObject({ id: 'WL-B', status: 'open', effort: 'xs' }); + }); + + it('fails closed (null) on malformed JSON', () => { + expect(parseImplementCandidatesOutput('not json')).toBeNull(); + }); + + it('fails closed (null) on output without a workItems list', () => { + expect(parseImplementCandidatesOutput(JSON.stringify({ success: true }))).toBeNull(); + }); + + it('returns an empty array for an empty workItems list', () => { + expect(parseImplementCandidatesOutput(JSON.stringify({ success: true, count: 0, workItems: [] }))).toEqual([]); + }); + + it('skips entries without an id but keeps valid ones', () => { + const stdout = JSON.stringify({ + success: true, + workItems: [{ workItem: { title: 'no id' } }, { workItem: { id: 'WL-OK', title: 'ok' } }], + }); + const parsed = parseImplementCandidatesOutput(stdout); + expect(parsed?.length).toBe(1); + expect(parsed?.[0].id).toBe('WL-OK'); + }); +}); + +describe('implement prompt & pane helpers', () => { + const candidate: DowntimeCandidate = { id: 'WL-IMP', title: 'Implement me', stage: 'implement' }; + + it('builds an /skill:implement prompt', () => { + const prompt = buildDowntimePrompt('implement', candidate); + expect(prompt).toContain('/skill:implement WL-IMP'); + }); + + it('builds a Downtime implement pane name', () => { + const args = buildDowntimePaneArgs('implement', 'Run /skill:implement WL-IMP — Implement me.', { + model: 'plan', + cwd: '/repo', + }); + expect(args).toContain('Downtime implement'); + expect(args).toContain('--no-focus'); + }); + + it('skillKindFromPrompt detects an implement prompt', () => { + expect(skillKindFromPrompt('Run /skill:implement WL-IMP — x.')).toBe('implement'); + }); + + it('buildDowntimeDispatchComment renders /skill:implement', () => { + const comment = buildDowntimeDispatchComment('WL-IMP', 'implement', '2026-01-01T00:00:00.000Z'); + expect(comment).toContain('/skill:implement WL-IMP'); + }); +}); + describe('audit selection (selectAuditCandidate)', () => { // Fixed clock so the 7-day recency filter is deterministic (all fixture // dates fall within the window). @@ -530,6 +1003,66 @@ describe('audit selection 7-day recency (selectAuditCandidate)', () => { }); }); +describe('audit selection dispatched-marker exclusion (selectAuditCandidate)', () => { + // Regression (WL-0MSGTLSUT002NF29): an item already dispatched for audit + // by the downtime worker must never be re-selected while it still lacks a + // fresh audit — even though it is completed/in_review (the dispatched run + // reverts the status without recording a fresh audit). + const NOW = new Date('2026-01-01T00:05:00.000Z').getTime(); + const dispatchedUnaudited: AuditCandidate = { + id: 'WL-DUP', + title: 'already dispatched, no audit', + sortIndex: 100, + }; + const otherUnaudited: AuditCandidate = { + id: 'WL-OTHER', + title: 'not dispatched', + sortIndex: 200, + }; + + it('excludes a candidate present in the dispatched set with no fresh audit (regression)', () => { + const dispatched = new Set(['WL-DUP']); + expect(selectAuditCandidate([dispatchedUnaudited], NOW, dispatched)).toBeNull(); + }); + + it('selects another candidate when the first is excluded', () => { + const dispatched = new Set(['WL-DUP']); + expect(selectAuditCandidate([dispatchedUnaudited, otherUnaudited], NOW, dispatched)?.id).toBe( + 'WL-OTHER', + ); + }); + + it('composes with audit freshness: a fresh audit still excludes the item (unchanged)', () => { + const freshDispatched: AuditCandidate = { + id: 'WL-FRESH', + title: 'fresh audit since dispatch', + auditedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:30.000Z', + sortIndex: 100, + }; + const dispatched = new Set(['WL-FRESH']); + // isAuditFresh governs: fresh → not a candidate, even without the marker. + expect(selectAuditCandidate([freshDispatched], NOW)).toBeNull(); + // Marker + fresh audit → still not a candidate (unchanged). + expect(selectAuditCandidate([freshDispatched], NOW, dispatched)).toBeNull(); + }); + + it('a stale/absent audit plus an audit marker is excluded', () => { + const staleDispatched: AuditCandidate = { + id: 'WL-STALE', + title: 'stale audit since dispatch', + auditedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:02:00.000Z', // >60s later -> stale + sortIndex: 100, + }; + expect(selectAuditCandidate([staleDispatched], NOW, new Set(['WL-STALE']))).toBeNull(); + }); + + it('an item without a marker is selected as before (empty set is a no-op)', () => { + expect(selectAuditCandidate([dispatchedUnaudited], NOW, new Set())?.id).toBe('WL-DUP'); + }); +}); + describe('parseAuditCandidatesOutput', () => { it('parses a { workItems: [...] } shape', () => { const stdout = JSON.stringify({ @@ -841,6 +1374,40 @@ describe('endpoint failures and poller', () => { }); }); +// ── local_active_query parsing (WL-0MSL2ZQIF006QB4Q) ────────────────── + +describe('parseLlamaStatus local_active_query', () => { + const base = { + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + available_slots: 4, + total_slots: 4, + }; + + it('exposes local_active_query=false when the proxy serves it', () => { + const status = parseLlamaStatus({ ...base, local_active_query: false }); + expect(status).not.toBeNull(); + expect(status!.local_active_query).toBe(false); + }); + + it('exposes local_active_query=true when the proxy serves it', () => { + const status = parseLlamaStatus({ ...base, local_active_query: true }); + expect(status).not.toBeNull(); + expect(status!.local_active_query).toBe(true); + }); + + it('leaves local_active_query undefined when absent (pre-fix proxy, backward compatible)', () => { + const status = parseLlamaStatus(base); + expect(status).not.toBeNull(); + expect(status!.local_active_query).toBeUndefined(); + }); + + it('treats a malformed (non-boolean) local_active_query as ambiguous → busy', () => { + expect(parseLlamaStatus({ ...base, local_active_query: 'yes' })).toBeNull(); + }); +}); + // ── Runtime idle evaluation (F2, AC5) ───────────────────────────────── describe('runtime idle evaluation (evaluateIdle)', () => { @@ -1121,6 +1688,24 @@ describe('downtime worker orchestrator (createDowntimeWorker)', () => { expect(deps.spawnAgentPane).not.toHaveBeenCalled(); }); + it('treats remote-only traffic as idle and dispatches after the threshold (local_active_query=false)', async () => { + // Integration (AC2): a stub status with global active_query=true but + // local_active_query=false (remote streams in flight, local slots free) + // must be treated as idle and dispatch after the idle threshold. + const { worker, deps, cfg } = makeWorker({ + status: { ...idleAllSlotsFree, active_query: true, local_active_query: false }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + expect(worker.idleSince).toBe(start); + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + it('does not dispatch until the idle duration reaches the threshold (AC1)', async () => { const { worker, deps, cfg } = makeWorker(); const start = 1_000_000; @@ -1384,6 +1969,28 @@ describe('downtime no-candidate cooldown (createDowntimeWorker)', () => { await outer; }); + it('does not enter the cooldown on a code-freeze skip (the freeze never pauses the worker)', async () => { + // Frozen + empty plan/intake backlog → reason 'code-freeze' (never + // 'no-candidate'): the worker must keep polling so implement/audit + // dispatch resumes immediately when the freeze lifts (WL-0MSQ0RPQP00636JY). + const { worker, deps } = makeEmptyBacklogWorker({ + deps: { + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + }, + }); + 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 freeze skip is NOT an empty backlog + expect(worker.errorStrikes).toBe(0); // ...and not a CLI-error strike either + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + 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; diff --git a/packages/herdr/src/downtime-worker.ts b/packages/herdr/src/downtime-worker.ts index e609ba84..29d548af 100644 --- a/packages/herdr/src/downtime-worker.ts +++ b/packages/herdr/src/downtime-worker.ts @@ -16,9 +16,24 @@ * 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:audit ` (audit tier, WL-0MSI8H3HP000K0RG), then the + * implement tier (WL-0MSMAYPQP001FLR6): the highest-priority open + * plan_complete item with risk Low / effort Small|XS → + * `/skill:implement `, 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 + * pre-dispatch claim, per-process single-flight. Code-freeze gate + * (WL-0MSQ0RPQP00636JY): the ship-it marker is re-read fresh on every + * dispatch; while frozen OR ambiguous (fail-closed) the audit and + * implement tiers are skipped (no new implementations/audits during a + * release) and plan/intake still dispatch; an empty plan/intake backlog + * during a freeze reports reason 'code-freeze' (never 'no-candidate'), + * so the freeze never triggers the no-candidate cooldown and dispatch + * resumes immediately when it lifts. The audit tier + * additionally excludes items the downtime worker has already dispatched + * for `/skill:audit` (durable dispatched-marker exclusion, + * WL-0MSLIY8ZR004QUSY) unless a fresh audit exists since, closing the + * re-selection loop where a dispatched run reverts the item to + * completed/in_review without recording a fresh audit. 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) @@ -50,6 +65,7 @@ import { spawn } from 'node:child_process'; import { isAuditFresh } from './icons.js'; +import type { CodeFreezeStatus } from './code-freeze.js'; // ── Constants ───────────────────────────────────────────────────────── @@ -117,7 +133,22 @@ export const DEFAULT_DOWNTIME_MODEL = 'plan'; /** Shape of `GET /llama/local/status` as served by the llama-proxy. */ export interface LlamaStatus { llama_server_running: boolean; + /** + * GLOBAL query activity: any request in flight (local AND remote). + * + * Prefer `local_active_query` when present — remote provider streams keep + * this true while the local model is idle with free slots, so treating it + * as the busy signal would block downtime dispatch (RCA + * WL-0MSK9TUCA00206M7). + */ active_query: boolean; + /** + * LOCAL-only query activity (served by proxies exposing the + * `local_active_queries` counter, LP-0MSL2ZLLS009RVKR). Optional: ABSENT on + * pre-fix proxies. When present, `isIdleStatus` prefers it over the global + * `active_query`; `local_active_query=true` implies `active_query=true`. + */ + local_active_query?: boolean; model_switch_in_progress: boolean; local_lease_active: boolean; available_slots: number; @@ -144,7 +175,14 @@ export interface LlamaStatus { */ export function isIdleStatus(status: LlamaStatus, requiredFreeSlots: number): boolean { if (!status.llama_server_running) return false; - if (status.active_query) return false; + // Prefer the local-only signal when the proxy exposes it (LP-0MSL2ZLLS009RVKR): + // remote streams keep the GLOBAL active_query true while the local model is + // idle with free slots, so they must not block downtime dispatch. Fall back + // to the global active_query for pre-fix proxies that do not serve + // local_active_query (backward compatible). + const queryActive = + status.local_active_query !== undefined ? status.local_active_query : status.active_query; + if (queryActive) return false; if (status.model_switch_in_progress) return false; if (status.local_lease_active) return false; @@ -245,6 +283,15 @@ export function parseLlamaStatus(raw: unknown): LlamaStatus | null { if (typeof o.active_query !== 'boolean') return null; if (typeof o.model_switch_in_progress !== 'boolean') return null; + // Optional local-only signal (LP-0MSL2ZLLS009RVKR): absent on pre-fix + // proxies, in which case isIdleStatus falls back to the global + // active_query. A malformed (non-boolean) value is ambiguous → busy. + let localActiveQuery: boolean | undefined; + if (o.local_active_query !== undefined) { + if (typeof o.local_active_query !== 'boolean') return null; + localActiveQuery = o.local_active_query; + } + const available = o.available_slots; const total = o.total_slots; if (typeof available !== 'number' || !Number.isFinite(available) || available < 0) return null; @@ -265,6 +312,7 @@ export function parseLlamaStatus(raw: unknown): LlamaStatus | null { return { llama_server_running: o.llama_server_running, active_query: o.active_query, + local_active_query: localActiveQuery, model_switch_in_progress: o.model_switch_in_progress, local_lease_active: localLeaseActive, available_slots: available, @@ -352,8 +400,8 @@ export function createDowntimePoller( // ── Dispatch (implemented — F3) ─────────────────────────────────────── -export type DowntimeStage = 'intake_complete' | 'idea' | 'audit'; -export type DowntimeSkillKind = 'plan' | 'intake' | 'audit'; +export type DowntimeStage = 'intake_complete' | 'idea' | 'audit' | 'implement'; +export type DowntimeSkillKind = 'plan' | 'intake' | 'audit' | 'implement'; export interface DowntimeCandidate { id: string; @@ -361,6 +409,23 @@ export interface DowntimeCandidate { stage: DowntimeStage; } +/** + * A candidate for the implement tier, parsed from + * `wl next --stage plan_complete --risk low --effort small -n N --json`. + * The `status` field distinguishes open items from completed epics that + * `wl next` keeps under a stage filter (the implement tier must filter + * client-side to `status === 'open'`, AC2). `sortIndex` preserves wl next's + * priority order for deterministic selection. + */ +export interface ImplementCandidate { + id: string; + title: string; + status: string; + risk?: string; + effort?: string; + sortIndex?: number; +} + /** * A completed/in_review item candidate for the downtime audit tier. * Parsed from `wl list --status completed --stage in_review --json`. @@ -394,9 +459,34 @@ export interface DowntimeWorkerDeps { /** * 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). + * a wl failure yields no candidate (no dispatch). `cwd` is the worklog root + * whose `.worklog/downtime-dispatches.log` is consulted for the + * dispatched-marker exclusion (WL-0MSLIY8ZR004QUSY): items the downtime + * worker already dispatched for `/skill:audit` are never re-selected while + * they still lack a fresh audit. */ - getNextAuditCandidate(): Promise; + getNextAuditCandidate(cwd: string): Promise; + /** + * Look up the next implement-tier candidate (WL-0MSMAYPQP001FLR6): the + * highest-priority open plan_complete item with risk Low / effort Small|XS, + * excluding dependency-blocked items (wl next default) and items already + * dispatched for `/skill:implement` (kind `implement` dispatched markers, + * AC6). Fail-closed: a wl failure yields null (no dispatch) — the + * plan/intake fallback still runs. `cwd` is the worklog root whose + * `.worklog/downtime-dispatches.log` is consulted for the marker set. + */ + getNextImplementCandidate(cwd: string): Promise; + /** + * Read the current code-freeze marker status (tri-state: 'frozen' / + * 'not-frozen' / 'ambiguous'). `cwd` is the worklog root; the marker + * lives at `/.worklog/code-freeze.json`. Read fresh on EVERY + * dispatch (never cached) so a freeze that starts or ends between idle + * periods is honored on the next dispatch attempt (WL-0MSQ0RPQP00636JY). + * Fail-closed: an 'ambiguous' marker is treated as frozen by the + * dispatcher — no implement/audit dispatch while the marker cannot be + * trusted. + */ + readCodeFreezeStatus(cwd: string): CodeFreezeStatus; /** 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). */ @@ -453,20 +543,37 @@ export interface DowntimeDispatchOutcome { 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` → + * dispatch one downtime work item. Selection priority (audit tier first, + * WL-0MSI8H3HP000K0RG): a completed/in_review item WITHOUT a valid audit AND + * NOT already dispatched for audit by this worker → + * `/skill:audit ` (the dispatched-marker exclusion, + * WL-0MSLIY8ZR004QUSY, is applied by `deps.getNextAuditCandidate`); then the + * implement tier (WL-0MSMAYPQP001FLR6): the highest-priority open + * plan_complete item with risk Low / effort Small|XS → `/skill:implement ` + * (fail-closed null on wl error or no candidate — never short-circuits the + * fallback, AC5/AC6); 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. + * if all four are empty, no dispatch. + * + * Code-freeze gate (WL-0MSQ0RPQP00636JY): the marker is re-read fresh on + * EVERY dispatch (never cached). While the marker is frozen OR ambiguous + * (fail-closed), the audit and implement tiers are skipped — no new + * implementation work (or audits) starts during a release freeze — and + * dispatch continues with the plan/intake tiers, which are low-risk prep + * and still allowed. A freeze skip with an empty plan/intake backlog is + * reported as reason 'code-freeze' (NEVER 'no-candidate'), so it never + * triggers the worker's no-candidate cooldown: polling continues and + * implement/audit dispatch resumes immediately when the freeze lifts. + * + * 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, @@ -477,12 +584,35 @@ export async function dispatchDowntimeWork( } 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' }; + // Code-freeze gate (WL-0MSQ0RPQP00636JY): re-read the marker fresh on + // every dispatch — never cached, so a freeze that starts or ends + // mid-idle-period is honored on the next dispatch attempt. Frozen OR + // ambiguous (fail-closed) → the audit and implement tiers are skipped + // and dispatch falls through to the plan/intake tiers below. + const freezeStatus = deps.readCodeFreezeStatus(opts.cwd); + const frozen = freezeStatus === 'frozen' || freezeStatus === 'ambiguous'; + + if (!frozen) { + const auditCandidate = await deps.getNextAuditCandidate(opts.cwd); + 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' }; + } + // Implement tier (WL-0MSMAYPQP001FLR6): after the audit gate, dispatch + // /skill:implement for the highest-priority open plan_complete item with + // risk Low / effort Small|XS. getNextImplementCandidate is fail-closed + // (null on wl failure or no candidate), so a null here means the tier is + // exhausted and the plan/intake tiers below still run (AC5/AC6 — a wl + // error at the implement tier does NOT short-circuit the fallback). + const implementCandidate = await deps.getNextImplementCandidate(opts.cwd); + if (implementCandidate !== null) { + await deps.claimItem(implementCandidate.id); + await deps.spawnAgentPane(buildDowntimePrompt('implement', implementCandidate), opts); + await recordDispatchAudit(deps, 'implement', implementCandidate, opts.cwd); + return { dispatched: true, candidate: implementCandidate, kind: 'implement' }; + } } // 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 @@ -517,9 +647,15 @@ export async function dispatchDowntimeWork( // 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' }; + // Both tiers answered with no candidate → genuine empty backlog. The + // freeze gate must NOT pause the worker on an empty plan/intake + // backlog: during a freeze that is a freeze skip (reason + // 'code-freeze'), never the no-candidate cooldown — polling continues + // so implement/audit dispatch resumes immediately when the freeze + // lifts (WL-0MSQ0RPQP00636JY). + return frozen + ? { dispatched: false, reason: 'code-freeze' } + : { 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 @@ -786,8 +922,10 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker tracker.record(false); } } - // Any other non-dispatch outcome (dispatch-in-flight) is neutral: - // no strike, no cooldown — the next idle period retries. + // Any other non-dispatch outcome (dispatch-in-flight, code-freeze + // skip) is neutral: no strike, no cooldown — the next idle period + // retries (a freeze skip keeps polling so implement/audit dispatch + // resumes immediately when the freeze lifts, WL-0MSQ0RPQP00636JY). return { polled: true, dispatched: outcome.dispatched, idle: true }; } finally { dispatching = false; @@ -812,7 +950,11 @@ export const BLOCKED_QUESTIONS_INSTRUCTION = /** 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'; + const skill = + kind === 'plan' ? '/skill:plan' + : kind === 'audit' ? '/skill:audit' + : kind === 'implement' ? '/skill:implement' + : '/skill:intake'; return [ `Run ${skill} ${candidate.id} — ${candidate.title}.`, BLOCKED_QUESTIONS_INSTRUCTION, @@ -831,7 +973,11 @@ export function buildDowntimeDispatchComment( dispatchedAt: string, title?: string, ): string { - const skill = kind === 'plan' ? '/skill:plan' : kind === 'audit' ? '/skill:audit' : '/skill:intake'; + const skill = + kind === 'plan' ? '/skill:plan' + : kind === 'audit' ? '/skill:audit' + : kind === 'implement' ? '/skill:implement' + : '/skill:intake'; const suffix = title ? ` (${title.replace(/[\r\n]+/g, ' ')})` : ''; return `Auto-dispatched by the herdr downtime worker at ${dispatchedAt} — running ${skill} ${itemId}${suffix}.`; } @@ -863,10 +1009,12 @@ export function parseNextItemOutput(stdout: string, stage: DowntimeStage): Downt /** * Derive the dispatch kind from a prompt built by `buildDowntimePrompt` - * (the pane name is `Downtime plan` / `Downtime intake` / `Downtime audit`). + * (the pane name is `Downtime plan` / `Downtime intake` / `Downtime audit` / + * `Downtime implement`). */ export function skillKindFromPrompt(prompt: string): DowntimeSkillKind { if (prompt.includes('/skill:audit ')) return 'audit'; + if (prompt.includes('/skill:implement ')) return 'implement'; return prompt.includes('/skill:plan ') ? 'plan' : 'intake'; } @@ -916,6 +1064,16 @@ export function parseAuditCandidatesOutput(stdout: string): AuditCandidate[] | n * unaudited/stale (or the list is empty). Missing auditedAt/updatedAt means * not fresh → selected. * + * Dispatched-marker exclusion (WL-0MSLIY8ZR004QUSY): candidates whose id is + * present in `dispatchedItemIds` (itemIds the downtime worker has already + * dispatched for `/skill:audit`) are excluded UNLESS they carry a fresh + * audit — the exclusion composes with `isAuditFresh` above, so an item + * whose most recent audit is fresh is governed by the existing freshness + * logic (fresh → not a candidate, unchanged) and an item with a + * stale/absent audit since its dispatch is never re-selected. This closes + * the re-selection loop where a dispatched audit run reverts the item to + * completed/in_review without recording a fresh audit. + * * 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 @@ -926,10 +1084,12 @@ export function parseAuditCandidatesOutput(stdout: string): AuditCandidate[] | n export function selectAuditCandidate( candidates: AuditCandidate[], now: number = Date.now(), + dispatchedItemIds?: ReadonlySet, ): AuditCandidate | null { const recencyCutoff = now - DOWNTIME_AUDIT_RECENCY_WINDOW_MS; const target = candidates .filter((c) => !isAuditFresh(c.auditedAt, c.updatedAt)) + .filter((c) => !(dispatchedItemIds?.has(c.id) ?? false)) .filter((c) => { if (!c.updatedAt) return true; // missing → include const updated = new Date(c.updatedAt).getTime(); @@ -948,6 +1108,131 @@ export function toDowntimeCandidate(candidate: AuditCandidate): DowntimeCandidat return { id: candidate.id, title: candidate.title, stage: 'audit' }; } +// ── Implement-tier selection (WL-0MSMAYPQP001FLR6) ─────────────────── + +/** + * Ordinal rank of a risk level on the canonical scale + * (Low < Medium < High < Severe/Critical), mirroring the wl next DB filter + * semantics (packages/shared riskOrdinal) for the belt-and-suspenders + * client-side guard. Unset/unknown values map to null (fail-closed). + */ +function riskOrdinal(risk: string | undefined | null): number | null { + switch ((risk ?? '').trim().toLowerCase()) { + case 'low': return 1; + case 'medium': return 2; + case 'high': return 3; + case 'severe': + case 'critical': return 4; + default: return null; + } +} + +/** + * Ordinal rank of an effort level on the canonical scale + * (Extra Small < Small < Medium < Large < Extra Large), mirroring the wl + * next DB filter semantics (packages/shared effortOrdinal). Accepts both + * the short CLI spellings (XS/S/M/L/XL) and long-form spellings, + * normalized case-insensitively. Unset/unknown values map to null + * (fail-closed). + */ +function effortOrdinal(effort: string | undefined | null): number | null { + switch ((effort ?? '').trim().toLowerCase().replace(/[\s-]+/g, '')) { + case 'xs': + case 'extrasmall': return 1; + case 's': + case 'small': return 2; + case 'm': + case 'medium': return 3; + case 'l': + case 'large': return 4; + case 'xl': + case 'extralarge': return 5; + default: return null; + } +} + +/** + * Parse the stdout of `wl next --stage plan_complete --risk low --effort + * small -n N --json` into typed implement candidates. Accepts the + * `{ workItems: [{ workItem: {...} }] }` shape the CLI emits for a batch + * (`-n N`); entries without an id are skipped. Malformed JSON or output + * without a workItems list yields null (fail-closed); an empty list yields + * `[]` (a genuine empty backlog). + */ +export function parseImplementCandidatesOutput(stdout: string): ImplementCandidate[] | null { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return null; + } + const items = + 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: ImplementCandidate[] = []; + for (const raw of items) { + if (typeof raw !== 'object' || raw === null) continue; + const entry = raw as { workItem?: Record }; + const o = entry.workItem; + if (typeof o !== 'object' || o === null) continue; + if (typeof o.id !== 'string' || o.id.length === 0) continue; + candidates.push({ + id: o.id, + title: typeof o.title === 'string' ? o.title : '', + status: typeof o.status === 'string' ? o.status : '', + risk: typeof o.risk === 'string' ? o.risk : undefined, + effort: typeof o.effort === 'string' ? o.effort : undefined, + sortIndex: typeof o.sortIndex === 'number' && Number.isFinite(o.sortIndex) ? o.sortIndex : undefined, + }); + } + return candidates; +} + +/** + * Select the next implement candidate from parsed wl next output: the + * first candidate that is open (`status === 'open'`, AC2), carries risk + * exactly Low and effort Small/Extra Small (AC1 threshold boundaries, + * fail-closed on unset/unknown), is not in the dispatched-marker set + * (kind `implement`, AC6), sorted ascending by `sortIndex` (wl next + * priority order preserved). Returns null when no candidate qualifies + * (or the list is empty). + * + * Belt-and-suspenders client-side guard (AC1): even though `wl next + * --risk low --effort small` filters server-side, the herdr tier verifies + * the thresholds again so a malformed/absent server filter can never + * dispatch a Medium+/Large+ item. + */ +export function selectImplementCandidate( + candidates: ImplementCandidate[], + dispatchedItemIds?: ReadonlySet, +): ImplementCandidate | null { + const target = candidates + .filter((c) => c.status === 'open') + .filter((c) => { + const risk = riskOrdinal(c.risk); + if (risk === null || risk !== 1) return false; // only risk exactly Low + const effort = effortOrdinal(c.effort); + if (effort === null || effort > 2) return false; // Small (2) + Extra Small (1) + return true; + }) + .filter((c) => !(dispatchedItemIds?.has(c.id) ?? false)) + .sort((a, b) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0)); + return target[0] ?? null; +} + +/** + * Turn a parsed implement candidate into a dispatachable `DowntimeCandidate` + * (stage `implement`) for the implement tier. + */ +export function toImplementCandidate(candidate: ImplementCandidate): DowntimeCandidate { + return { id: candidate.id, title: candidate.title, stage: 'implement' }; +} + // ── Settings clamps (implemented — wired into settings.ts by F2) ────── /** diff --git a/packages/herdr/src/fetcher.ts b/packages/herdr/src/fetcher.ts index 5af804d3..52271783 100644 --- a/packages/herdr/src/fetcher.ts +++ b/packages/herdr/src/fetcher.ts @@ -498,17 +498,36 @@ export async function fetchNextItems(count?: number): Promise { return regroupWorkItems(selected); } +/** + * Status set used per stage by the stage-filtered worklist fetch. + * + * Most stages show only open items (`status=open`) — the "every open item + * in the stage" rule from WL-0MSDT8X1V003206G. The in_review stage is the + * exception (WL-0MSKCRX730052IIW): per the project workflow, advancing an + * item to in_review sets its status to `completed` (or leaves it + * `in-progress` while it is being re-worked after review feedback), so + * restricting to `status=open` would hide the entire review queue. `open` + * is included for robustness (no in_review item carries it today). + */ +const STAGE_STATUS: Record = { + in_review: 'completed,in-progress,open', +}; + /** * 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. + * Status per stage (WL-0MSKCRX730052IIW): the in_review stage fetches items + * with status `completed`, `in-progress`, or `open` — in_review items carry + * `completed`/`in-progress` status per the project workflow. All other + * stages fetch open items only (WL-0MSDT8X1V003206G): 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', '--status', 'open', '--stage', stage, '--root-only']); + const status = STAGE_STATUS[stage] ?? 'open'; + const output = await runWl(['list', '--status', status, '--stage', stage, '--root-only']); const payload = extractJson(output); return extractItems(payload); } diff --git a/packages/herdr/src/form-dialog.test.ts b/packages/herdr/src/form-dialog.test.ts index 1a0f4dcd..c638b10d 100644 --- a/packages/herdr/src/form-dialog.test.ts +++ b/packages/herdr/src/form-dialog.test.ts @@ -4,11 +4,11 @@ * Run: npx vitest run packages/herdr/src/form-dialog.test.ts * * Covers the rendering contract of `FormState.render(maxCols, maxRows)`: - * - dialog width = 80% of pane width (with min/max clamps) - * - every box content line is exactly as wide as the border lines - * - long descriptions and field values wrap at the inner content width - * - the dialog grows downward with wrapped content, bounded by `maxRows` - * - interactions (Tab/↑↓ navigation, Enter submit, Esc cancel) are preserved + * - No border box decoration (no ┌, ┐, └, ┘, │, ─ edges) + * - Content starts at top-left (no centering, no leading blank lines) + * - Text wraps at the full pane width (maxCols) + * - Output is bounded by maxRows + * - Interactions (Tab/↑↓ navigation, Enter submit, Esc cancel) are preserved */ import { describe, it, expect } from 'vitest'; @@ -19,9 +19,6 @@ import { substituteIdentifiers, } from './form-dialog.js'; -// ── Helpers ───────────────────────────────────────────────────────────── - -/** Strip ANSI SGR codes so visible width/length checks work. */ const visible = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); interface FieldOpts { @@ -54,161 +51,158 @@ function makeForm(opts: { return state; } -/** The dialog box lines (top border .. bottom border, inclusive). */ -function boxLines(out: string): string[] { - const lines = out.split('\n'); - const top = lines.findIndex((l) => visible(l).includes('┌')); - const bottom = lines.findIndex((l) => visible(l).includes('└')); - return lines.slice(top, bottom + 1); -} - -/** Visible width of the dialog box (excluding leading centering padding). */ -function boxWidth(out: string): number { - const top = out.split('\n').find((l) => visible(l).includes('┌')); - if (!top) throw new Error('no dialog top border in output'); - return visible(top).trimStart().length; -} - -// ── Dialog width: 80% of pane width ───────────────────────────────────── - -describe('FormState.render — dialog width', () => { - it('uses 80% of the pane width (no 60-column cap)', () => { - expect(boxWidth(makeForm().render(100, 30))).toBe(80); - expect(boxWidth(makeForm().render(120, 30))).toBe(96); - expect(boxWidth(makeForm().render(200, 30))).toBe(160); - }); - - it('clamps to the 40-column minimum on narrow panes', () => { - expect(boxWidth(makeForm().render(50, 30))).toBe(40); - expect(boxWidth(makeForm().render(44, 30))).toBe(40); - }); - - it('never exceeds the pane width (box and full line incl. padding)', () => { - for (const cols of [44, 50, 60, 80, 100, 140, 200]) { - const out = makeForm().render(cols, 30); - expect(boxWidth(out)).toBeLessThanOrEqual(cols); - const top = out.split('\n').find((l) => visible(l).includes('┌')); - expect(visible(top!).length).toBeLessThanOrEqual(cols); +// ── No border box decoration ──────────────────────────────────────────── + +describe('FormState.render — no border box', () => { + it('does not render any box-drawing characters', () => { + const out = makeForm().render(100, 30); + const vis = visible(out); + expect(vis).not.toContain('┌'); + expect(vis).not.toContain('┐'); + expect(vis).not.toContain('└'); + expect(vis).not.toContain('┘'); + expect(vis).not.toContain('│'); + const lines = out.split('\n'); + for (const line of lines) { + const visLine = visible(line).trim(); + expect(visLine).not.toMatch(/^─+$/); } }); }); -// ── Border alignment: all box lines equal width ───────────────────────── +// ── Top-left alignment ────────────────────────────────────────────────── -describe('FormState.render — border alignment', () => { - it('renders every box line exactly as wide as the borders', () => { - for (const cols of [44, 50, 60, 80, 100, 140]) { - const widths = boxLines(makeForm().render(cols, 40)).map((l) => visible(l).length); - expect(new Set(widths).size, `cols=${cols}`).toBe(1); - } +describe('FormState.render — top-left alignment', () => { + it('starts content on the first line (no leading blank lines)', () => { + const out = makeForm().render(100, 30); + const firstLine = out.split('\n')[0]; + expect(visible(firstLine)).toMatch(/Command Input/); }); - it('keeps all lines aligned with a long description and long values', () => { - const form = makeForm({ - description: 'A very long description that exceeds the dialog width. '.repeat(3).trim(), - fields: [ - { name: 'status', value: 'this is a long status value '.repeat(4).trim() }, - { name: 'stage', value: '' }, - ], - }); - const widths = boxLines(form.render(80, 40)).map((l) => visible(l).length); - expect(new Set(widths).size).toBe(1); + it('content is not center-padded', () => { + const out = makeForm().render(100, 30); + const firstNonBlank = out.split('\n').find((l) => visible(l).trim().length > 0); + expect(firstNonBlank).toBeDefined(); + expect(visible(firstNonBlank!).charAt(0)).not.toBe(' '); }); }); -// ── Text wrapping ─────────────────────────────────────────────────────── - -describe('FormState.render — text wrapping', () => { - /** Inner region between the two │ border chars of a content line. */ - const region = (l: string): string => { - const v = visible(l); - return v.slice(v.indexOf('│') + 1, v.lastIndexOf('│')); - }; - - /** Assert every box line is exactly border width with a padded inner region. */ - const assertAligned = (box: string[]): void => { - const borderLineLen = visible(box[0]).length; // full line incl. leftPad - const innerLen = region(box.find((l) => visible(l).includes('│'))!).length; - for (const l of box) { - const v = visible(l); - expect(v.length).toBe(borderLineLen); - if (v.includes('│')) expect(region(l).length).toBe(innerLen); - } - }; +// ── Full-width wrapping ───────────────────────────────────────────────── - it('wraps a long description onto multiple lines within the inner width', () => { - const desc = 'word '.repeat(50).trim(); // ~250 chars - const out = makeForm({ description: desc }).render(100, 40); - const box = boxLines(out); - const descLines = box.filter((l) => /word/.test(visible(l))); +describe('FormState.render — full-width wrapping', () => { + it('wraps description at full pane width (maxCols)', () => { + const longDesc = 'word '.repeat(50).trim(); + const out = makeForm({ description: longDesc }).render(40, 30); + const lines = out.split('\n').map(visible); + const descLines = lines.filter((l) => /word/.test(l)); expect(descLines.length).toBeGreaterThan(1); - assertAligned(box); - // no single line packs more than the inner width of description text - const wordsPerLine = descLines.map( - (l) => (visible(l).match(/word/g) ?? []).length, - ); - expect(Math.max(...wordsPerLine)).toBeLessThanOrEqual(15); + for (const dl of descLines) { + expect(dl.length).toBeLessThanOrEqual(40); + } }); - it('wraps a long field value onto multiple lines within the inner width', () => { + it('wraps field values at full pane width', () => { const out = makeForm({ - fields: [{ name: 'status', value: 'x'.repeat(300) }], - }).render(100, 40); - const box = boxLines(out); - const valueLines = box.filter((l) => /x{4,}/.test(visible(l))); + fields: [{ name: 'status', value: 'x'.repeat(200) }], + }).render(50, 30); + const lines = out.split('\n').map(visible); + const valueLines = lines.filter((l) => /x{4,}/.test(l)); expect(valueLines.length).toBeGreaterThan(1); - assertAligned(box); - // no spurious truncation markers when there is plenty of room - expect(visible(out)).not.toContain('…'); - // wrapped lines fill the inner width (minus indent and cursor column) - const innerWidth = boxWidth(out) - 4; - for (const l of valueLines) { - const xs = visible(l).match(/x+/g) ?? ['']; - expect(Math.max(...xs.map((x) => x.length))).toBeLessThanOrEqual( - innerWidth, - ); + for (const vl of valueLines) { + expect(vl.length).toBeLessThanOrEqual(50); } - expect(Math.max(...valueLines.map((l) => (visible(l).match(/x+/g) ?? ['']).map((x) => x.length)).flat())).toBe( - innerWidth - 3, - ); }); -}); -// ── Downward expansion within maxRows ─────────────────────────────────── - -describe('FormState.render — downward expansion within maxRows', () => { - it('grows the dialog as a value wraps to more lines', () => { - const short = makeForm({ fields: [{ name: 'status', value: 'short' }] }).render(100, 40); - const long = makeForm({ fields: [{ name: 'status', value: 'x'.repeat(300) }] }).render(100, 40); - expect(boxLines(long).length).toBeGreaterThan(boxLines(short).length); + it('more wrapping at narrower width', () => { + const longDesc = 'a '.repeat(100).trim(); + const out80 = makeForm({ description: longDesc }).render(80, 30); + const out100 = makeForm({ description: longDesc }).render(100, 30); + const aLines80 = out80.split('\n').map(visible).filter((l) => /a/.test(l)); + const aLines100 = out100.split('\n').map(visible).filter((l) => /a/.test(l)); + expect(aLines80.length).toBeGreaterThan(aLines100.length); + for (const l of out80.split('\n').map(visible)) expect(l.length).toBeLessThanOrEqual(80); + for (const l of out100.split('\n').map(visible)) expect(l.length).toBeLessThanOrEqual(100); }); +}); + +// ── Content within pane bounds ────────────────────────────────────────── - it('never exceeds maxRows even with overflowing content', () => { +describe('FormState.render — pane bounds', () => { + it('never exceeds maxRows lines', () => { const out = makeForm({ description: 'd '.repeat(500).trim(), fields: [{ name: 'status', value: 'x'.repeat(500) }], }).render(80, 12); - expect(out.split('\n').length).toBe(12); - expect(visible(out)).toContain('└'); + expect(out.split('\n').length).toBeLessThanOrEqual(12); }); - it('keeps the cursor indicator on the last value line when it wraps', () => { - const out = makeForm({ - fields: [{ name: 'status', value: 'a'.repeat(120) }], - activeField: 0, - }).render(100, 40); - const box = boxLines(out); - const valueLines = box.filter((l) => /a{4,}/.test(visible(l))); - expect(valueLines.length).toBeGreaterThan(1); - // cursor (reversed space) sits on the final wrapped value line - const last = valueLines[valueLines.length - 1]; - expect(last).toContain('\x1b[7m'); - for (const l of valueLines.slice(0, -1)) { - expect(l).not.toContain('\x1b[7m'); + it('never has a line whose visible width exceeds maxCols', () => { + for (const cols of [20, 30, 40, 50, 80, 100, 140, 200]) { + const out = makeForm({ + description: 'very long description text '.repeat(10), + fields: [{ name: 'status', value: 'y'.repeat(200) }], + }).render(cols, 30); + for (const line of out.split('\n')) { + const vis = visible(line); + expect(vis.length).toBeLessThanOrEqual(cols); + } } }); }); +// ── Content retained ─────────────────────────────────────────────────── + +describe('FormState.render — content retained', () => { + it('renders the Command Input heading', () => { + const out = makeForm().render(100, 30); + expect(visible(out)).toContain('Command Input'); + }); + + it('renders the description text', () => { + const out = makeForm({ description: 'My custom description' }).render(100, 30); + expect(visible(out)).toContain('My custom description'); + }); + + it('renders field labels', () => { + const out = makeForm({ fields: [{ name: 'myField' }] }).render(100, 30); + expect(visible(out)).toContain('myField'); + }); + + it('renders field values', () => { + const out = makeForm({ fields: [{ name: 'status', value: 'in_progress' }] }).render(100, 30); + expect(visible(out)).toContain('in_progress'); + }); + + it('renders action hints', () => { + const out = makeForm().render(100, 30); + expect(visible(out)).toContain('navigate'); + expect(visible(out)).toContain('submit'); + expect(visible(out)).toContain('cancel'); + }); + + it('shows the cursor indicator on the active field', () => { + const out = makeForm({ fields: [{ name: 'status' }], activeField: 0 }).render(100, 30); + expect(out).toContain('\x1b[7m'); + }); + + it('places a space between icon glyphs and adjacent text', () => { + const out = makeForm().render(100, 30); + expect(visible(out)).toMatch(/⌨ Command/); + }); +}); + +// ── Downward expansion ────────────────────────────────────────────────── + +describe('FormState.render — downward expansion', () => { + it('grows the page as a value wraps to more lines', () => { + const short = makeForm({ fields: [{ name: 'status', value: 'short' }] }).render(100, 40); + const long = makeForm({ fields: [{ name: 'status', value: 'x'.repeat(300) }] }).render(100, 40); + const sNonBlank = short.split('\n').filter((l) => visible(l).trim().length > 0).length; + const lNonBlank = long.split('\n').filter((l) => visible(l).trim().length > 0).length; + expect(lNonBlank).toBeGreaterThan(sNonBlank); + }); +}); + // ── Interactions preserved ────────────────────────────────────────────── describe('FormState interactions', () => { @@ -218,9 +212,7 @@ describe('FormState interactions', () => { 'wl update --status --stage ', 'd', [{ name: 'status', default: '' }, { name: 'stage', default: '' }], - (r) => { - result = r; - }, + (r) => { result = r; }, () => {}, ); for (const ch of 'in') state.handleInput(ch); @@ -239,7 +231,7 @@ describe('FormState interactions', () => { expect(cancelled).toBe(true); }); - it('navigates fields with Tab and arrow keys (with wrap-around)', () => { + it('navigates fields with Tab and arrow keys', () => { const state = new FormState( 'cmd ', 'd', @@ -278,7 +270,6 @@ describe('FormState interactions', () => { ); expect(state.fields[0].value).toBe(''); expect(state.fields[1].value).toBe('medium'); - // Submitting without edits uses the default (empty description stays empty) expect(state.getResult()).toBe('wl create --priority medium'); }); @@ -291,38 +282,31 @@ describe('FormState interactions', () => { () => {}, ); state.handleInput('\t'); - // Clear the pre-filled default before typing a replacement for (let i = 0; i < 'medium'.length; i++) state.handleInput('\x7f'); for (const ch of 'high') state.handleInput(ch); expect(state.getResult()).toBe('wl create --priority high'); }); }); -// ── Identifier helpers (auto-substitution contract) ───────────────────── +// ── Identifier helpers ────────────────────────────────────────────────── describe('identifier helpers', () => { it('extracts unique identifiers in order', () => { - expect( - extractIdentifiers('wl update --status --status '), - ).toEqual([ + expect(extractIdentifiers('wl update --status --status ')).toEqual([ { name: 'id', default: '' }, { name: 'status', default: '' }, ]); }); it('extracts inline defaults alongside identifiers', () => { - expect( - extractIdentifiers('wl create --priority '), - ).toEqual([ + expect(extractIdentifiers('wl create --priority ')).toEqual([ { name: 'description', default: '' }, { name: 'priority', default: 'medium' }, ]); }); it('supports single-quoted defaults', () => { - expect( - extractIdentifiers('wl create --priority '), - ).toEqual([ + expect(extractIdentifiers("wl create --priority ")).toEqual([ { name: 'description', default: '' }, { name: 'priority', default: 'medium' }, ]); @@ -335,18 +319,16 @@ describe('identifier helpers', () => { }); it('reports defaults on unknown identifiers', () => { - expect( - getUnknownIdentifiers('wl create --priority '), - ).toEqual([ + expect(getUnknownIdentifiers('wl create --priority ')).toEqual([ { name: 'description', default: '' }, { name: 'priority', default: 'medium' }, ]); }); it('substitutes provided values and leaves unknown placeholders intact', () => { - expect( - substituteIdentifiers('wl update --status ', { status: 'in_progress' }), - ).toBe('wl update --status in_progress'); + expect(substituteIdentifiers('wl update --status ', { status: 'in_progress' })).toBe( + 'wl update --status in_progress', + ); }); it('substitutes the inline default when no explicit value is given', () => { diff --git a/packages/herdr/src/form-dialog.ts b/packages/herdr/src/form-dialog.ts index b003abf7..ec899ee5 100644 --- a/packages/herdr/src/form-dialog.ts +++ b/packages/herdr/src/form-dialog.ts @@ -1,10 +1,10 @@ /** - * packages/herdr/src/form-dialog.ts — Form dialog for unknown identifiers + * packages/herdr/src/form-dialog.ts — Form page for unknown identifiers * * Provides identifier extraction, form state management, rendering, and * input handling for chord commands that contain unknown * patterns. Known identifiers like are auto-resolved; unknown ones - * trigger an interactive form overlay in the TUI. + * trigger an interactive full-pane form page in the TUI. */ // ── Identifier extraction ───────────────────────────────────────────── @@ -134,7 +134,7 @@ const IS_SGR_RE = /^\x1b\[[0-9;]*m$/; * sequences. Consistent with the convention used elsewhere in this package * (e.g. `worklist.ts`) — no external width/wrap dependencies. */ -function visibleWidth(content: string): number { +export function visibleWidth(content: string): number { return content.replace(SGR_RE, '').length; } @@ -145,6 +145,10 @@ function visibleWidth(content: string): number { * inside a styled region, the open style codes are re-emitted at the start * of the continuation line so wrapped text keeps its styling. ANSI codes * themselves consume no width. `\n` in the input acts as a hard break. + * + * Words that do not fit on the current line move to the next line whole + * (rewinding to the last space), so no produced line exceeds `width`; + * a single word longer than `width` is hard-broken at the width. */ function wrapContent(content: string, width: number): string[] { if (width < 1) return [content]; @@ -153,11 +157,14 @@ function wrapContent(content: string, width: number): string[] { let current = ''; let currentWidth = 0; let openAnsi: string[] = []; + // Index into `current` of the last whitespace char appended (or -1). + let lastSpacePos = -1; const flush = (): void => { lines.push(current); current = ''; currentWidth = 0; + lastSpacePos = -1; }; for (const seg of segments) { @@ -180,8 +187,21 @@ function wrapContent(content: string, width: number): string[] { // lines do not start with a stray space (and stay within width). if (/\s/.test(ch)) continue; } + // A non-space char that would overflow the width: if the current + // line contains a space, move the trailing word to the next line + // whole instead of overflowing; otherwise fall through to the + // hard character break at the width. + if (!/\s/.test(ch) && currentWidth + 1 > width && lastSpacePos >= 0) { + const word = current.slice(lastSpacePos + 1); + current = current.slice(0, lastSpacePos); // drop trailing space + word + flush(); + current += openAnsi.join('') + word; + currentWidth = visibleWidth(word); + lastSpacePos = -1; + } current += ch; currentWidth += 1; + if (/\s/.test(ch)) lastSpacePos = current.length - 1; } } flush(); @@ -192,10 +212,11 @@ function wrapContent(content: string, width: number): string[] { /** * Truncate content to a maximum visible width, preserving ANSI styling and - * appending '…' when truncated. Used as a defensive guard in `padLine` so - * no content line can ever exceed the dialog border width. + * appending '…' when truncated. Used as a defensive guard so no rendered + * line can ever exceed the pane width. + * Exported for reuse by ship-it-dialog.ts (WL-0MSGG5N5Z0074TLY). */ -function truncateToWidth(content: string, maxWidth: number): string { +export function truncateToWidth(content: string, maxWidth: number): string { if (visibleWidth(content) <= maxWidth) return content; const segments = content.split(/(\x1b\[[0-9;]*m)/g).filter((s) => s !== ''); let out = ''; @@ -220,11 +241,11 @@ function truncateToWidth(content: string, maxWidth: number): string { // ── FormState ───────────────────────────────────────────────────────── /** - * Mutable state for a multi-field form dialog overlay. + * Mutable state for a multi-field form page layout. * * Manages field input, navigation between fields, and submission/cancel. - * Renders itself as a terminal overlay with a border, description, - * labelled input fields, and action hints. + * Renders itself as a full-pane page (no border box, no centering) starting + * at the top-left corner, while keeping the modal keyboard interaction. */ export class FormState { /** Form fields (one per unknown identifier) */ @@ -336,69 +357,44 @@ export class FormState { } /** - * Render the form dialog as a terminal overlay string. + * Render the form as a full-pane page layout. * - * The overlay has a border box, description header, labeled input fields - * (with active field highlighted), and submit/cancel instructions. + * The form is rendered without a border box — no corner decorations, no + * side borders, no horizontal edge lines. Content starts at the top-left + * of the pane (no centering, no leading blank lines). Long text wraps at + * the full pane width (`maxCols`), and the output is bounded by `maxRows` + * so it never exceeds the terminal height. * - * The dialog width is 80% of the pane width (clamped to a 40-column - * minimum and to the pane width minus borders), the description and field - * values wrap at the inner content width, and the box grows downward as - * wrapped content grows — bounded by `maxRows` so it never overflows the - * terminal. Every content line is padded to exactly the border width. + * All inner content is retained: the "⌨ Command Input" heading, the + * description, labeled fields, action hints, and the active-field cursor + * indicator. Icon glyphs (`⌨`, `▶`) are followed by a visible space. * * @param maxCols - Terminal width * @param maxRows - Terminal height - * @returns The rendered overlay string, ready for stdout + * @returns The rendered page string, ready for stdout */ render(maxCols: number, maxRows: number): string { - // ── Width: 80% of the pane width, clamped ─────────────────────── - const minWidth = 40; - const maxWidth = Math.max(minWidth, maxCols - 4); - const effectiveWidth = Math.min( - Math.max(Math.floor(maxCols * 0.8), minWidth), - maxWidth, - ); - const leftPad = Math.max(0, Math.floor((maxCols - effectiveWidth) / 2)); - const innerWidth = effectiveWidth - 4; // `│ ` + content + ` │` - - const padLine = (content: string): string => { - const truncated = truncateToWidth(content, innerWidth); - const visibleLen = visibleWidth(truncated); - // 4 non-content columns: `│ ` left border + ` │` right border. - const rightPad = Math.max(0, effectiveWidth - visibleLen - 4); - return ( - ' '.repeat(leftPad) + - `│ ${truncated}${' '.repeat(rightPad)} │` - ); - }; - - const borderLine = (left: string, right: string): string => { - return ' '.repeat(leftPad) + `${left}${'─'.repeat(effectiveWidth - 2)}${right}`; - }; - - // Row budget for the content between the two borders (border lines and - // one blank line above/below the box are outside the budget). - const availableRows = Math.max(5, maxRows - 4); + // Row budget: leave a couple of rows for the hint line, minimum 2 for + // content. + const availableRows = Math.max(2, maxRows - 2); const content: string[] = []; const pushContent = (c: string): void => { if (content.length < availableRows) content.push(c); }; - // ── Build form content ──────────────────────────────────────── + // ── Build form content (left-aligned, no border) ────────────── // Title pushContent(`${ANSI.bold}${ANSI.fg(76)}⌨ Command Input${ANSI.reset}`); pushContent(''); - // Description — wrapped at the inner width - const descContent = ` ${ANSI.fg(33)}${this.description}${ANSI.reset}`; - for (const dl of wrapContent(descContent, innerWidth)) { + // Description — wrapped at full pane width + const descContent = `${ANSI.fg(33)}${this.description}${ANSI.reset}`; + for (const dl of wrapContent(descContent, maxCols)) { pushContent(dl); } - // Separator - pushContent(` ${ANSI.dim}${'─'.repeat(Math.min(effectiveWidth - 6, 40))}${ANSI.reset}`); + // Blank line after description pushContent(''); // Fields @@ -407,21 +403,17 @@ export class FormState { const isActive = i === this.activeFieldIndex; // Label line - const labelPrefix = isActive ? `${ANSI.fg(76)}▶${ANSI.reset} ` : ' '; + const labelPrefix = isActive ? `${ANSI.fg(76)}▶ ${ANSI.reset}` : ' '; const labelStyle = isActive ? `${ANSI.bold}${ANSI.fg(76)}` : `${ANSI.dim}`; const labelLine = `${labelPrefix}${labelStyle}${field.name}:${ANSI.reset}`; pushContent(labelLine); - // Value lines — wrapped; the active field reserves one column for - // the cursor indicator so the last line stays within the border. - // The 2-column indent is part of the rendered line, so the wrap - // width for the text itself is `innerWidth - 2` (minus one more - // column for the active field's cursor). + // Value lines — wrapped at full pane width. The active field + // reserves one column for the cursor indicator, so the wrap width + // is reduced by 1 for active fields. const displayValue = field.value || ''; const valueStyle = isActive ? `${ANSI.fg(33)}` : `${ANSI.dim}`; - const wrapWidth = isActive - ? Math.max(1, innerWidth - 3) - : Math.max(1, innerWidth - 2); + const wrapWidth = isActive ? Math.max(1, maxCols - 1) : maxCols; let valueLines = wrapContent( `${valueStyle}${displayValue}${ANSI.reset}`, wrapWidth, @@ -431,7 +423,7 @@ export class FormState { const shown: string[] = []; for (const vl of valueLines) { if (content.length >= availableRows) break; - content.push(` ${vl}`); + content.push(vl); shown.push(vl); } @@ -439,19 +431,15 @@ export class FormState { const lastIdx = content.length - 1; // Truncation marker when the row budget clipped wrapped lines. if (shown.length < valueLines.length) { - content[lastIdx] = truncateToWidth(`${content[lastIdx]}…`, innerWidth); + content[lastIdx] = truncateToWidth(`${content[lastIdx]}…`, maxCols); } - // Cursor indicator (active) / minimum field width (inactive) on - // the last shown line. + // Cursor indicator (active) on the last shown line. The line is + // capped to maxCols-1 first so the indicator never overflows the + // pane (relevant when the truncation marker was also appended). if (isActive) { - content[lastIdx] = `${content[lastIdx]}${ANSI.reverse} ${ANSI.reset}`; - } else { - const vis = visibleWidth(content[lastIdx]); - const pad = Math.min( - Math.max(0, 10 - vis), - Math.max(0, innerWidth - vis), - ); - content[lastIdx] = `${content[lastIdx]}${' '.repeat(pad)}`; + content[lastIdx] = + truncateToWidth(content[lastIdx], maxCols - 1) + + `${ANSI.reverse} ${ANSI.reset}`; } } @@ -461,31 +449,21 @@ export class FormState { } } - // Bottom separator + // Blank line before hint pushContent(''); - pushContent(` ${ANSI.dim}${'─'.repeat(Math.min(effectiveWidth - 6, 40))}${ANSI.reset}`); - // Instructions (short form on narrow dialogs so the hint fits) - pushContent(''); + // Instructions const fullHint = '[Tab/↑↓] navigate [Enter] submit [Esc] cancel'; - const shortHint = '[Tab] next [Enter] ok [Esc] cancel'; - const hint = visibleWidth(fullHint) <= innerWidth ? fullHint : shortHint; - pushContent(`${ANSI.dim}${hint}${ANSI.reset}`); - - // ── Assemble the box ────────────────────────────────────────── - const lines: string[] = ['']; - lines.push(borderLine('┌', '┐')); - for (const c of content) lines.push(padLine(c)); - lines.push(borderLine('└', '┘')); - lines.push(''); + const hint = visibleWidth(fullHint) <= maxCols ? fullHint : '[Tab] next [Enter] ok [Esc] cancel'; + pushContent(truncateToWidth(`${ANSI.dim}${hint}${ANSI.reset}`, maxCols)); // Fill remaining rows with blank lines (never exceeding maxRows). - const remaining = Math.max(0, maxRows - lines.length); + const remaining = Math.max(0, maxRows - content.length); for (let i = 0; i < remaining; i++) { - lines.push(''); + content.push(''); } - return lines.slice(0, maxRows).join('\n'); + return content.slice(0, maxRows).join('\n'); } } diff --git a/packages/herdr/src/implement-tier-blocked-exclusion.test.ts b/packages/herdr/src/implement-tier-blocked-exclusion.test.ts new file mode 100644 index 00000000..7916355b --- /dev/null +++ b/packages/herdr/src/implement-tier-blocked-exclusion.test.ts @@ -0,0 +1,71 @@ +/** + * End-to-end fixture test for the implement tier's dependency-blocked + * exclusion (WL-0MSMYP1CB003XVTZ, closes the parent AC8 coverage gap): + * creates a REAL blocked fixture in a temp worklog and proves the exact + * implement-tier invocation — `wl next --stage plan_complete --risk low + * --effort small -n 10 --json` (packages/herdr/src/index.ts + * getNextImplementCandidate) — never yields the dependency-blocked item. + * Feeding the real CLI output through the implement tier's own parser + * (`parseImplementCandidatesOutput`) and selector + * (`selectImplementCandidate`) proves a blocked candidate can never be + * dispatched. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execAsync, writeConfig, writeInitSemaphore, cliPath } from '../../../tests/cli/cli-helpers.js'; +import { createTempDir, cleanupTempDir } from '../../../tests/test-utils.js'; +import { parseImplementCandidatesOutput, selectImplementCandidate } from './downtime-worker.js'; + +describe('implement tier dependency-blocked exclusion (fixture, WL-0MSMYP1CB003XVTZ)', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + writeConfig(tempDir, 'Test Project', 'TEST'); + writeInitSemaphore(tempDir); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + async function createItemGetId(args: string[]): Promise { + const { stdout } = await execAsync(`tsx ${cliPath} --json create ${args.join(' ')}`, { cwd: tempDir }); + const parsed = JSON.parse(stdout); + return parsed.workItem.id; + } + + it( + 'the implement-tier invocation never yields a dependency-blocked candidate, so dispatch can never select one', + async () => { + // Fixture: a high-priority eligible blocker and an eligible item that + // depends on it. While the blocker is open, the dependent is + // dependency-blocked and must be excluded by wl next's default + // (includeBlocked=false) — the same exclusion the implement tier + // relies on (never passing --include-blocked). + const blockerId = await createItemGetId(['-t', '"Blocker"', '-p', 'high', '--stage', 'plan_complete', '--risk', 'Low', '--effort', 'Small', '--no-re-sort']); + const blockedId = await createItemGetId(['-t', '"Blocked"', '--stage', 'plan_complete', '--risk', 'Low', '--effort', 'Small', '--no-re-sort']); + await execAsync(`tsx ${cliPath} --json dep add ${blockedId} ${blockerId}`, { cwd: tempDir }); + + // The exact implement-tier invocation (packages/herdr/src/index.ts). + const { stdout } = await execAsync( + `tsx ${cliPath} --json next --stage plan_complete --risk low --effort small -n 10`, + { cwd: tempDir }, + ); + + // Feed the real CLI output through the implement tier's own parser + // and selector — the blocked candidate must never reach dispatch. + const candidates = parseImplementCandidatesOutput(stdout); + expect(candidates).not.toBeNull(); + const ids = (candidates ?? []).map((c) => c.id); + expect(ids).toContain(blockerId); + expect(ids).not.toContain(blockedId); + + const selected = selectImplementCandidate(candidates ?? [], new Set()); + expect(selected).not.toBeNull(); + expect(selected?.id).toBe(blockerId); + expect(selected?.id).not.toBe(blockedId); + }, + 60_000, + ); +}); diff --git a/packages/herdr/src/index.test.ts b/packages/herdr/src/index.test.ts index 40b8a604..305f288a 100644 --- a/packages/herdr/src/index.test.ts +++ b/packages/herdr/src/index.test.ts @@ -16,8 +16,8 @@ import { 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 { DOWNTIME_LOG_FILE, readDowntimeLogEntries } from './downtime-log.js'; +import { DOWNTIME_WL_TIMEOUT_MS, dispatchDowntimeWork } from './downtime-worker.js'; import { fetchItemsByStage, resetExecFileAsync, @@ -59,6 +59,16 @@ describe('buildSendToPiArgs', () => { ]); }); + it('passes an empty prompt arg for a bare /prompt: command (blank session)', () => { + expect(buildSendToPiArgs('/prompt:', '/project', 'plan')).toEqual([ + '--cwd', + '/project', + '--model', + 'plan', + '', + ]); + }); + it('passes /plan with the plan model', () => { expect(buildSendToPiArgs('/plan ', '/project', 'plan')).toEqual([ '--cwd', @@ -249,7 +259,7 @@ describe('shortcuts.json command routing', () => { } }); - it('binds P-p to the free-form prompt and P-a to the audit-gaps prompt', () => { + it('binds P-p to the free-form prompt, P-a to the audit-gaps prompt, and P-n to a blank session', () => { const freePrompt = entries.find((e) => e.chord.join(' ') === 'P p'); expect(freePrompt).toBeDefined(); expect(freePrompt!.command).toBe('/prompt:'); @@ -265,6 +275,17 @@ describe('shortcuts.json command routing', () => { expect(auditPrompt!.view).toBe('both'); expect(auditPrompt!.model).toBe('plan'); expect(routeCommand(auditPrompt!.command)).toBe('agent'); + + const blankSession = entries.find((e) => e.chord.join(' ') === 'P n'); + expect(blankSession).toBeDefined(); + expect(blankSession!.command).toBe('/prompt:'); + expect(blankSession!.view).toBe('both'); + expect(blankSession!.model).toBe('plan'); + // Empty /prompt: carries no placeholders: no command-input form, no + // work-item claim (AC2/AC3), and it routes to the agent channel. + expect(blankSession!.command).not.toContain(''); + expect(blankSession!.command).not.toContain(''); + expect(routeCommand(blankSession!.command)).toBe('agent'); }); it('keeps the single-key p chord bound to plan (P leader does not shadow it)', () => { @@ -627,7 +648,7 @@ describe('createDowntimeDeps', () => { setExecFileAsync(mockExec as never); const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); - const result = await deps.getNextAuditCandidate(); + const result = await deps.getNextAuditCandidate('/repo'); expect(mockExec).toHaveBeenCalledWith( 'wl', @@ -651,13 +672,13 @@ describe('createDowntimeDeps', () => { setExecFileAsync(mockExec as never); const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); - expect(await deps.getNextAuditCandidate()).toBeNull(); + expect(await deps.getNextAuditCandidate('/repo')).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(); + expect(await deps.getNextAuditCandidate('/repo')).toBeNull(); }); it('getNextAuditCandidate passes a bounded timeout so a hung wl fails closed', async () => { @@ -668,7 +689,7 @@ describe('createDowntimeDeps', () => { setExecFileAsync(mockExec as never); const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); - await deps.getNextAuditCandidate(); + await deps.getNextAuditCandidate('/repo'); // AC1: the audit-tier invocation must carry the bounded timeout too. const [, , options] = mockExec.mock.calls[0]; @@ -690,7 +711,7 @@ describe('createDowntimeDeps', () => { setExecFileAsync(hungExec as never); const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); - const resultPromise = deps.getNextAuditCandidate(); + const resultPromise = deps.getNextAuditCandidate('/repo'); await vi.advanceTimersByTimeAsync(DOWNTIME_WL_TIMEOUT_MS); await expect(resultPromise).resolves.toBeNull(); } finally { @@ -703,7 +724,367 @@ describe('createDowntimeDeps', () => { setExecFileAsync(mockExec as never); const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); - expect(await deps.getNextAuditCandidate()).toBeNull(); + expect(await deps.getNextAuditCandidate('/repo')).toBeNull(); + }); + + it('getNextAuditCandidate excludes an item already dispatched for audit (marker in the log, no fresh audit)', async () => { + // Regression (WL-0MSGTLSUT002NF29): a completed/in_review item present in + // the shared dispatch log with kind:audit and no fresh audit is excluded + // from audit-tier selection even though it is otherwise selectable. + const now = Date.now(); + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 2, + workItems: [ + { id: 'WL-DUP', title: 'already dispatched', auditedAt: null, updatedAt: new Date(now - 60_000).toISOString(), sortIndex: 100 }, + { id: 'WL-OTHER', title: 'fresh slot', auditedAt: null, updatedAt: new Date(now - 60_000).toISOString(), sortIndex: 200 }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + // A real dispatch log under a temp worklog root with an audit marker. + const cwd = makeTempDir(); + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync( + join(cwd, '.worklog', DOWNTIME_LOG_FILE), + JSON.stringify({ itemId: 'WL-DUP', kind: 'audit', dispatchedAt: new Date(now - 3_600_000).toISOString(), cwd }) + '\n', + 'utf8', + ); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextAuditCandidate(cwd); + expect(result).toEqual({ id: 'WL-OTHER', title: 'fresh slot', stage: 'audit' }); + }); + + it('getNextAuditCandidate does not exclude plan/intake markers (audit-tier-only scope guard)', async () => { + const now = Date.now(); + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 1, + workItems: [ + { id: 'WL-PLANNED', title: 'planned before', auditedAt: null, updatedAt: new Date(now - 60_000).toISOString(), sortIndex: 100 }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const cwd = makeTempDir(); + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync( + join(cwd, '.worklog', DOWNTIME_LOG_FILE), + JSON.stringify({ itemId: 'WL-PLANNED', kind: 'plan', dispatchedAt: new Date(now - 3_600_000).toISOString(), cwd }) + '\n', + 'utf8', + ); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextAuditCandidate(cwd); + expect(result).toEqual({ id: 'WL-PLANNED', title: 'planned before', stage: 'audit' }); + }); + + it('getNextAuditCandidate treats a missing dispatch log as empty (fail-safe, dispatch still works)', async () => { + const now = Date.now(); + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 1, + workItems: [ + { id: 'WL-FRESHWORKLOG', title: 'fresh worklog', auditedAt: null, updatedAt: new Date(now - 60_000).toISOString(), sortIndex: 100 }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + // cwd with no .worklog/downtime-dispatches.log at all. + const cwd = makeTempDir(); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextAuditCandidate(cwd); + expect(result).toEqual({ id: 'WL-FRESHWORKLOG', title: 'fresh worklog', stage: 'audit' }); + }); + + it('getNextAuditCandidate skips malformed log lines without failing the lookup', async () => { + const now = Date.now(); + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 1, + workItems: [ + { id: 'WL-OK', title: 'ok item', auditedAt: null, updatedAt: new Date(now - 60_000).toISOString(), sortIndex: 100 }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const cwd = makeTempDir(); + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync( + join(cwd, '.worklog', DOWNTIME_LOG_FILE), + 'not-json\n{"broken":\n' + JSON.stringify({ itemId: 'WL-DUP', kind: 'audit', dispatchedAt: new Date(now - 3_600_000).toISOString(), cwd }) + '\n', + 'utf8', + ); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextAuditCandidate(cwd); + expect(result).toEqual({ id: 'WL-OK', title: 'ok item', stage: 'audit' }); + }); + + it('getNextImplementCandidate runs wl next plan_complete risk/effort batch and selects the first open item', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 2, + requested: 10, + results: [ + { workItem: { id: 'WL-OPEN', title: 'Open low-risk', status: 'open', risk: 'low', effort: 'small' } }, + { workItem: { id: 'WL-DONE', title: 'Completed epic', status: 'completed', risk: 'low', effort: 'small' } }, + ], + workItems: [ + { workItem: { id: 'WL-OPEN', title: 'Open low-risk', status: 'open', risk: 'low', effort: 'small' } }, + { workItem: { id: 'WL-DONE', title: 'Completed epic', status: 'completed', risk: 'low', effort: 'small' } }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextImplementCandidate('/repo'); + + // The invocation must carry the risk/effort filters and a generous batch + // count (-n) so completed items can be filtered out client-side without + // starving selection (WL-0MSMAYIKX005LLO4 AC2/AC3). + expect(mockExec).toHaveBeenCalledWith( + 'wl', + ['next', '--stage', 'plan_complete', '--risk', 'low', '--effort', 'small', '-n', expect.any(String), '--json'], + expect.anything(), + ); + // wl next keeps completed items with a stage filter; the client-side + // status=open filter drops the completed epic and selects WL-OPEN. + expect(result).toEqual({ id: 'WL-OPEN', title: 'Open low-risk', stage: 'implement' }); + }); + + it('getNextImplementCandidate relies on wl next default dependency-blocked exclusion (no --include-blocked)', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 1, + workItems: [ + { workItem: { id: 'WL-OPEN', title: 'Open low-risk', status: 'open', risk: 'low', effort: 'small' } }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await deps.getNextImplementCandidate('/repo'); + + const [, args] = mockExec.mock.calls[0]; + // Dependency-blocked exclusion is wl next's default (includeBlocked=false). + // The implement tier must never opt into --include-blocked, or blocked + // candidates would reach the dispatch layer (WL-0MSMAYIKX005LLO4 AC3). + expect(args).not.toContain('--include-blocked'); + }); + + it('getNextImplementCandidate returns null when no open candidate exists (all completed)', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 1, + workItems: [ + { workItem: { id: 'WL-DONE', title: 'Completed epic', status: 'completed', risk: 'low', effort: 'small' } }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + expect(await deps.getNextImplementCandidate('/repo')).toBeNull(); + }); + + it('getNextImplementCandidate 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.getNextImplementCandidate('/repo')).toBeNull(); + }); + + it('getNextImplementCandidate 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.getNextImplementCandidate('/repo')).toBeNull(); + }); + + it('getNextImplementCandidate 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.getNextImplementCandidate('/repo'); + + const [, , options] = mockExec.mock.calls[0]; + expect(options).toMatchObject({ timeout: DOWNTIME_WL_TIMEOUT_MS }); + }); + + it('getNextImplementCandidate excludes an item already dispatched for implement (kind implement marker)', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 2, + workItems: [ + { workItem: { id: 'WL-DUP', title: 'already dispatched', status: 'open', risk: 'low', effort: 'small' } }, + { workItem: { id: 'WL-FRESH', title: 'fresh slot', status: 'open', risk: 'low', effort: 'small' } }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const cwd = makeTempDir(); + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync( + join(cwd, '.worklog', DOWNTIME_LOG_FILE), + JSON.stringify({ itemId: 'WL-DUP', kind: 'implement', dispatchedAt: new Date(Date.now() - 3_600_000).toISOString(), cwd }) + '\n', + 'utf8', + ); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextImplementCandidate(cwd); + expect(result).toEqual({ id: 'WL-FRESH', title: 'fresh slot', stage: 'implement' }); + }); + + it('getNextImplementCandidate does not exclude audit/plan/intake markers (implement-tier-only scope guard)', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 1, + workItems: [ + { workItem: { id: 'WL-IMP', title: 'implementable', status: 'open', risk: 'low', effort: 'small' } }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const cwd = makeTempDir(); + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync( + join(cwd, '.worklog', DOWNTIME_LOG_FILE), + JSON.stringify({ itemId: 'WL-IMP', kind: 'audit', dispatchedAt: new Date(Date.now() - 3_600_000).toISOString(), cwd }) + '\n', + 'utf8', + ); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextImplementCandidate(cwd); + expect(result).toEqual({ id: 'WL-IMP', title: 'implementable', stage: 'implement' }); + }); + + it('getNextImplementCandidate treats a missing dispatch log as empty (fail-safe, dispatch still works)', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 1, + workItems: [ + { workItem: { id: 'WL-FRESHWORKLOG', title: 'fresh worklog', status: 'open', risk: 'low', effort: 'small' } }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const cwd = makeTempDir(); // no .worklog/downtime-dispatches.log + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextImplementCandidate(cwd); + expect(result).toEqual({ id: 'WL-FRESHWORKLOG', title: 'fresh worklog', stage: 'implement' }); + }); + + it('getNextImplementCandidate applies --worklog-dir when the tab resolved a worklog root', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ success: true, count: 0, workItems: [] }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + setWorklogDir('/home/user/projects/SorraAgents/.worklog'); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await deps.getNextImplementCandidate('/repo'); + + expect(mockExec).toHaveBeenCalledWith( + 'wl', + [ + '--worklog-dir', + '/home/user/projects/SorraAgents/.worklog', + 'next', + '--stage', + 'plan_complete', + '--risk', + 'low', + '--effort', + 'small', + '-n', + expect.any(String), + '--json', + ], + expect.anything(), + ); + }); + + it('end-to-end: two consecutive idle windows dispatch a single unaudited candidate exactly once', async () => { + // AC5 (parent): with one unaudited completed/in_review candidate and two + // consecutive idle windows (no audit recorded between), the worker + // dispatches exactly once. The first dispatch writes the durable marker + // (kind:audit) to the shared log; the second window's candidate selection + // reads the marker back and excludes the item, so no second dispatch. + const now = Date.now(); + const candidate = { + id: 'WL-ONCE', + title: 'dispatch me once', + auditedAt: null, + updatedAt: new Date(now - 60_000).toISOString(), + sortIndex: 100, + }; + // wl list always returns the same single candidate; wl next returns no + // candidate so a second (wrong) audit dispatch would be the only path. + const mockExec = vi.fn((_bin: string, args: string[]) => { + if (args[0] === 'list') { + return Promise.resolve({ + stdout: JSON.stringify({ success: true, count: 1, workItems: [candidate] }), + stderr: '', + }); + } + return Promise.resolve({ stdout: JSON.stringify({ success: true, workItem: null }), stderr: '' }); + }); + setExecFileAsync(mockExec as never); + const spawnFn = vi.fn(() => ({ unref: vi.fn() })); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map', spawnFn); + const cwd = makeTempDir(); + + // Idle window 1: audit candidate selected and dispatched. + const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd }); + expect(first.dispatched).toBe(true); + expect(first.kind).toBe('audit'); + expect(first.candidate?.id).toBe('WL-ONCE'); + + // The durable marker landed in the shared log (kind:audit). + const entries = await readDowntimeLogEntries(cwd); + expect(entries.some((e) => e.itemId === 'WL-ONCE' && e.kind === 'audit')).toBe(true); + + // Idle window 2: the marker excludes the item; nothing else to dispatch. + const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd }); + expect(second.dispatched).toBe(false); + expect(second.reason).toBe('no-candidate'); + expect(spawnFn).toHaveBeenCalledTimes(1); }); it('claimItem runs wl update --status in_progress --assignee', async () => { diff --git a/packages/herdr/src/index.ts b/packages/herdr/src/index.ts index 93a01d64..9b793277 100644 --- a/packages/herdr/src/index.ts +++ b/packages/herdr/src/index.ts @@ -11,6 +11,8 @@ * * Environment: * HERDR_PANE_ID - Set by Herdr when running in a pane (optional) + * HERDR_TAB_ID - Set by Herdr when running in a pane; the tab-focus + * visibility signal for pause-when-hidden (optional) * WL_COUNT - Number of items to fetch (default: 20, now superseded by browseItemCount setting) * * Exit codes: @@ -36,6 +38,7 @@ import { import { AgentTracker, AGENT_PANES_FILE, mergeAgentStates } from './agent-tracker.js'; import { runWorklistTui, getTermSize } from './worklist.js'; import { loadShortcutConfig } from './shortcut-config.js'; +import { readCodeFreezeStatusForRoot } from './code-freeze.js'; import { loadSettings, getDefaultSettingsPath, clampBrowseItemCount, defaultSettings } from './settings.js'; import { createDowntimeWorker, @@ -44,8 +47,11 @@ import { spawnDowntimePane, parseNextItemOutput, parseAuditCandidatesOutput, + parseImplementCandidatesOutput, selectAuditCandidate, + selectImplementCandidate, toDowntimeCandidate, + toImplementCandidate, skillKindFromPrompt, type DowntimeWorker, type DowntimeWorkerDeps, @@ -59,7 +65,12 @@ import { buildDowntimeDispatchComment, DOWNTIME_WL_TIMEOUT_MS, } from './downtime-worker.js'; -import { appendDowntimeLogEntry } from './downtime-log.js'; +import { + appendDowntimeLogEntry, + auditDispatchedItemIds, + implementDispatchedItemIds, + readDowntimeLogEntries, +} 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 @@ -358,7 +369,13 @@ export function createDowntimeDeps( return { ok: false }; } }, - async getNextAuditCandidate(): Promise { + // Code-freeze gate (WL-0MSQ0RPQP00636JY): fresh tri-state read of the + // ship-it marker at `/.worklog/code-freeze.json` on EVERY dispatch + // (never cached). The dispatcher treats 'frozen' and 'ambiguous' + // (fail-closed) identically: no audit/implement dispatch during a + // release; plan/intake continue. + readCodeFreezeStatus: (cwd: string) => readCodeFreezeStatusForRoot(cwd), + async getNextAuditCandidate(cwd: string): Promise { try { // Audit tier (WL-0MSI8H3HP000K0RG): select the first completed / // in_review item WITHOUT a valid audit so the producer-review queue @@ -371,15 +388,77 @@ export function createDowntimeDeps( { encoding: 'utf8', timeout: DOWNTIME_WL_TIMEOUT_MS }, ); const candidates = parseAuditCandidatesOutput(stdout); - const selected = candidates === null ? null : selectAuditCandidate(candidates); + if (candidates === null) return null; + // Dispatched-marker exclusion (WL-0MSLIY8ZR004QUSY): read the shared + // rolling dispatch log for THIS worklog root (the same that + // recordDispatch writes) and exclude any candidate the downtime + // worker has already dispatched for /skill:audit unless a fresh + // audit exists since (the composition lives in selectAuditCandidate). + // Fail-safe: readDowntimeLogEntries never throws — a missing or + // unreadable log is treated as empty, so audit-tier dispatch still + // works on a fresh worklog (a corrupted log cannot silently disable + // the audit tier). + const entries = await readDowntimeLogEntries(cwd); + const dispatchedAuditIds = auditDispatchedItemIds(entries); + const selected = selectAuditCandidate(candidates, Date.now(), dispatchedAuditIds); return selected === null ? null : toDowntimeCandidate(selected); } catch { // Fail-closed: a wl failure yields no candidate (no dispatch). return null; } }, + async getNextImplementCandidate(cwd: string): Promise { + try { + // Implement tier (WL-0MSMAYPQP001FLR6): select the highest-priority + // open plan_complete item with risk Low / effort Small|XS. The wl + // next server-side at-most filters (--risk low --effort small, + // delivered by WL-0MSMAIP5F003WAGG) do the heavy lifting; a + // generous batch (-n 10) is fetched so completed epics (which wl + // next keeps under a stage filter) can be filtered out client-side + // without starving selection. The bounded timeout + // (WL-0MSJIPHD0001L1J9) kills a hung wl child. Fail-closed: a wl + // failure yields no candidate (no dispatch) and never short-circuits + // the plan/intake fallback (AC6). + const { stdout } = await getExecFileAsync()( + 'wl', + buildWlArgs([ + 'next', + '--stage', + 'plan_complete', + '--risk', + 'low', + '--effort', + 'small', + '-n', + '10', + '--json', + ]), + { encoding: 'utf8', timeout: DOWNTIME_WL_TIMEOUT_MS }, + ); + const candidates = parseImplementCandidatesOutput(stdout); + if (candidates === null) return null; + // Dispatched-marker exclusion (AC6): read the shared rolling + // dispatch log for THIS worklog root and exclude any candidate the + // downtime worker has already dispatched for /skill:implement + // (kind implement markers). Fail-safe: readDowntimeLogEntries never + // throws — a missing or unreadable log is treated as empty, so + // implement-tier dispatch still works on a fresh worklog. + const entries = await readDowntimeLogEntries(cwd); + const dispatchedImplementIds = implementDispatchedItemIds(entries); + const selected = selectImplementCandidate(candidates, dispatchedImplementIds); + return selected === null ? null : toImplementCandidate(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). + // claimWorkItem never throws — failures are returned, but the result is + // deliberately discarded here: a failed claim must not block the + // dispatch. Note this path does NOT log the failure (unlike + // claimItemForAgentCommand), so a claim failure is silent and the + // dispatch is still recorded as a success (known silent path, follow-up + // WL-0MSLWJ310000ND0X; see README "Failure-path logging"). await claimWorkItem(itemId, assignee); }, async spawnAgentPane(prompt: string, opts: { model: string; cwd: string }): Promise { diff --git a/packages/herdr/src/md-viewer.test.ts b/packages/herdr/src/md-viewer.test.ts index d5e8d08f..9e4b9928 100644 --- a/packages/herdr/src/md-viewer.test.ts +++ b/packages/herdr/src/md-viewer.test.ts @@ -98,8 +98,8 @@ describe('renderMarkdownViewer', () => { // Frontmatter is not rendered. expect(joined).not.toContain('pipeline_stage'); expect(joined).not.toContain('version:'); - // Heading rendered. - expect(joined).toContain('█ Episode One'); + // Heading rendered (h1 glyph; bold ANSI wraps the heading text). + expect(visibleOf(lines)).toContain('██ Episode One'); // Dialogue paragraphs rendered. expect(joined).toContain('Nova:'); expect(joined).toContain('Sorra:'); @@ -127,6 +127,101 @@ describe('renderMarkdownViewer', () => { }); }); +// ── GFM rendering (WL-0MSKFFJWD002BQJ5) ─────────────────────────────── + +/** Strip ANSI SGR codes so tests assert on visible text. */ +function visibleOf(lines: string[]): string { + return lines.join('\n').replace(/\x1b\[[0-9;]*m/g, ''); +} + +describe('renderMarkdownViewer — GFM constructs', () => { + it('renders heading hierarchy with distinct glyphs per depth', () => { + const joined = visibleOf(renderMarkdownViewer('# One\n\n## Two\n\n### Three\n\n###### Six', 80)); + expect(joined).toContain('██ One'); + expect(joined).toContain('█ Two'); + expect(joined).toContain('▌ Three'); + expect(joined).toContain('▌ Six'); + }); + + it('renders ordered and nested bullet lists', () => { + const md = [ + '1. first', + '2. second', + '', + '- top', + ' - nested one', + ' - deep two', + '- bottom', + ].join('\n'); + const joined = visibleOf(renderMarkdownViewer(md, 80)); + expect(joined).toContain('1. first'); + expect(joined).toContain('2. second'); + expect(joined).toContain('• top'); + expect(joined).toContain(' • nested one'); + expect(joined).toContain(' • deep two'); + expect(joined).toContain('• bottom'); + }); + + it('renders blockquotes indented with a quote glyph', () => { + const joined = visibleOf(renderMarkdownViewer('> quoted line one\n> quoted line two', 80)); + expect(joined).toContain('▌ quoted line one'); + expect(joined).toContain('▌ quoted line two'); + }); + + it('renders GFM tables as aligned columns with a header row', () => { + const md = [ + '| Left | Center | Right |', + '|:-----|:------:|------:|', + '| a | b | c |', + '| long | mid | x |', + ].join('\n'); + const visible = visibleOf(renderMarkdownViewer(md, 80)); + // Header row and alignment (center column padded both sides, right + // column right-aligned) render with column separators. + expect(visible).toContain('│ Left │'); + expect(visible).toContain('│ a │ b │ c │'); + expect(visible).toContain('│ long │ mid │ x │'); + // Separator row between header and body. + expect(visible).toMatch(/├.*┼.*┤/); + }); + + it('renders bold, italic, strikethrough, and inline code with styling', () => { + const md = 'Para **bold** *italic* ~~struck~~ `inline code`.'; + const joined = renderMarkdownViewer(md, 80).join('\n'); + // Visible text survives styling. + expect(joined).toContain('bold'); + expect(joined).toContain('italic'); + expect(joined).toContain('struck'); + // Styles applied: ANSI bold around bold text, italic around italic, + // strikethrough around struck, cyan inside backticks for code. + expect(joined).toContain(`\x1b[1mbold\x1b[0m`); + expect(joined).toContain(`\x1b[3mitalic\x1b[0m`); + expect(joined).toContain(`\x1b[9mstruck\x1b[0m`); + expect(joined).toContain('`\x1b[36minline code\x1b[0m`'); + }); + + it('renders links underlined/colored with the href shown dimmed', () => { + const joined = renderMarkdownViewer('See [the docs](https://example.com/guide).', 80).join('\n'); + expect(joined).toContain(`\x1b[4m\x1b[34mthe docs\x1b[0m`); + expect(joined).toContain(`\x1b[2m (https://example.com/guide)\x1b[0m`); + }); + + it('renders horizontal rules as a line of dashes', () => { + const joined = visibleOf(renderMarkdownViewer('before\n\n---\n\nafter', 80)); + expect(joined).toContain('before'); + expect(joined).toContain('after'); + expect(joined).toMatch(/\n─{3,}\n/); + }); + + it('renders NOTE markers as links inside GFM content', () => { + const md = '# H\n\nA line with [NOTE OSL-CCC: hidden note text] inside.'; + const joined = renderMarkdownViewer(md, 120).join('\n'); + expect(joined).toContain('OSL-CCC↗'); + expect(joined).not.toContain('[NOTE'); + expect(joined).not.toContain('hidden note text'); + }); +}); + // ── Podcast item stage grouping (standard stages only) ──────────────── /** Build a minimal WorkItem with a stage (podcast items use standard stages). */ @@ -248,6 +343,33 @@ describe('detail-view wiring', () => { expect(joined).not.toContain('[NOTE'); }); + it('formatDetailContent renders the description as GFM (table, bold, italic, code, links, lists)', () => { + const item = makeEpisodeItem( + [ + '**Bold** and *italic* and `code` and [a link](https://example.com).', + '', + '| A | B |', + '|---|---|', + '| 1 | 2 |', + '', + '- one', + '- two', + ].join('\n'), + ); + const visible = formatDetailContent(item, 120).join('\n').replace(/\x1b\[[0-9;]*m/g, ''); + // Bold/italic/code/link text renders. + expect(visible).toContain('Bold'); + expect(visible).toContain('italic'); + expect(visible).toContain('`code`'); + expect(visible).toContain('a link'); + // Table renders with aligned columns. + expect(visible).toContain('│ A │ B │'); + expect(visible).toContain('│ 1 │ 2 │'); + // Lists render as bullets. + expect(visible).toContain('• one'); + expect(visible).toContain('• two'); + }); + it('formatDetailContent embeds the md viewer section when readFile is provided', () => { const item = makeEpisodeItem( '**Key Files:**\n- `podcast/episode-one.podcast.md`\n\nSome description.', diff --git a/packages/herdr/src/md-viewer.ts b/packages/herdr/src/md-viewer.ts index e652feb0..fcecf898 100644 --- a/packages/herdr/src/md-viewer.ts +++ b/packages/herdr/src/md-viewer.ts @@ -2,20 +2,38 @@ * packages/herdr/src/md-viewer.ts — Generic markdown document viewer helpers * for the Herdr worklist-selection-list plugin. * - * Pure, dependency-free rendering helpers used to preview an episode file - * (opened from a work item's `Key Files:`) in a viewer pane, and to render - * inline `[NOTE : ...]` markers (PRD §7.1) as clickable links to the - * note work items (operator decision: marker-as-link rendering is required). + * Pure, dependency-injectable rendering helpers used to preview an episode + * file (opened from a work item's `Key Files:`) in a viewer pane, and to + * render inline `[NOTE : ...]` markers (PRD §7.1) as clickable links to + * the note work items (operator decision: marker-as-link rendering is + * required). * - * The functions here are intentionally minimal and side-effect free so they - * can be unit-tested without a terminal or pane harness: + * Markdown is parsed with the `marked` library (declared dependency of + * `packages/herdr`) and rendered to ANSI-styled terminal lines via a custom + * token walker that emits the constructs used in `.podcast.md` episode + * files and work-item descriptions: + * + * - ATX heading hierarchy (`#`…`######`, distinct glyphs per level), + * - ordered and nested bullet lists, + * - blockquotes, + * - GFM tables (aligned columns), + * - fenced code blocks and inline code spans, + * - bold / italic / strikethrough / links, + * - horizontal rules, + * - paragraphs (word-wrapped to the terminal width). + * + * The functions here are intentionally side-effect free so they can be + * unit-tested without a terminal or pane harness: + * - `renderMarkdown()` — core renderer (NOTE markers always rendered as + * links; optional YAML frontmatter skip for file bodies). * - `renderMarkdownViewer()` — formats a markdown file body as terminal - * lines (paragraphs, headings, lists, fenced code blocks) with NOTE - * markers rendered as links. + * lines with YAML frontmatter skipped. * - `renderNoteLinks()` — rewrites inline `[NOTE : ...]` markers into a * link representation (id + "↗" indicator) and returns the note ids. */ +import { lexer, type Token, type Tokens } from 'marked'; + // ── NOTE-marker handling ────────────────────────────────────────────── /** @@ -57,90 +75,357 @@ export function renderNoteLinks(text: string): string { return text.replace(NOTE_MARKER_RE, (_marker, id: string) => `${id}↗`); } -// ── Markdown viewer rendering ───────────────────────────────────────── +// ── ANSI styling and width helpers ──────────────────────────────────── + +/** ANSI escape sequences used for terminal markdown rendering. */ +const STYLE = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + italic: '\x1b[3m', + underline: '\x1b[4m', + strike: '\x1b[9m', + cyan: '\x1b[36m', + blue: '\x1b[34m', +} as const; /** ANSI dim/glyph helpers for viewer output (kept minimal). */ const VIEWER_GLYPHS = { + headingH1: '██', heading: '█', + headingDeep: '▌', bullet: '•', code: '│', + quote: '▌', + table: '│', + hr: '─', } as const; +/** Strip ANSI escape sequences (SGR codes) from a styled string. */ +function stripAnsi(s: string): string { + return s.replace(/\x1b\[[0-9;]*m/g, ''); +} + /** - * Render a markdown document body as terminal viewer lines. - * - * Supports the constructs used in `.podcast.md` episode files and work-item - * descriptions: ATX headings (`#`…`######`), bullet lists, fenced code - * blocks, blank-line-separated paragraphs, and inline `[NOTE : ...]` - * markers (rendered as links, see `renderNoteLinks`). YAML frontmatter - * (`---`-delimited) is skipped. - * - * @param mdText - Raw markdown document text. - * @param maxCols - Terminal width to wrap/truncate lines to. - * @returns Viewer lines (no trailing padding; callers pad to viewport). + * Truncate a styled line to at most `maxCols` visible characters, copying + * ANSI escape sequences through and closing any open styling at the end. */ -export function renderMarkdownViewer(mdText: string, maxCols: number): string[] { - if (!mdText) return []; - - const rawLines = mdText.split('\n'); - const lines: string[] = []; - let inFrontmatter = false; - let inCodeBlock = false; - let sawFrontmatterClose = false; - - for (const raw of rawLines) { - const line = raw.trimEnd(); - - // YAML frontmatter: skip everything between a leading `---` and its close. - if (!inFrontmatter && !sawFrontmatterClose && line.trim() === '---') { - inFrontmatter = true; - continue; - } - if (inFrontmatter) { - if (line.trim() === '---') { - inFrontmatter = false; - sawFrontmatterClose = true; +function truncateAnsi(line: string, maxCols: number): string { + if (stripAnsi(line).length <= maxCols) return line; + let result = ''; + let vis = 0; + let i = 0; + while (vis < maxCols && i < line.length) { + if (line[i] === '\x1b' && line[i + 1] === '[') { + const end = line.indexOf('m', i); + if (end >= 0) { + result += line.slice(i, end + 1); + i = end + 1; + continue; } - continue; } + result += line[i]; + vis += 1; + i += 1; + } + return `${result}${STYLE.reset}`; +} - // Fenced code blocks. - if (line.trim().startsWith('```')) { - inCodeBlock = !inCodeBlock; - lines.push(''); - continue; +/** + * Word-wrap a styled string to at most `maxCols` visible characters per + * line, honouring ANSI sequences for width. Words longer than `maxCols` + * are truncated (with open styling closed) so a single line never exceeds + * the terminal width. + */ +function wrapText(text: string, maxCols: number): string[] { + if (maxCols <= 0) return [text]; + const out: string[] = []; + let cur = ''; + let curPlain = 0; + for (const word of text.split(/\s+/)) { + if (word === '') continue; + const wordPlain = stripAnsi(word).length; + if (cur && curPlain + 1 + wordPlain > maxCols) { + out.push(cur); + cur = ''; + curPlain = 0; } - if (inCodeBlock) { - lines.push(` ${VIEWER_GLYPHS.code} ${renderNoteLinks(line)}`); + if (wordPlain > maxCols) { + if (cur) { + out.push(cur); + cur = ''; + curPlain = 0; + } + out.push(truncateAnsi(word, maxCols)); continue; } + if (cur) { + cur += ' '; + curPlain += 1; + } + cur += word; + curPlain += wordPlain; + } + if (cur) out.push(cur); + return out.length > 0 ? out : ['']; +} - const trimmed = line.trim(); +// ── Inline token rendering ──────────────────────────────────────────── - // ATX headings. - const heading = /^(#{1,6})\s+(.*)$/.exec(trimmed); - if (heading) { - lines.push(`${VIEWER_GLYPHS.heading} ${renderNoteLinks(heading[2])}`); - continue; +/** + * Render a list of inline tokens to an ANSI-styled string. + * + * @param tokens - Inline tokens (from a paragraph/heading/table cell/...). + * @returns Styled text; embedded `\n` (hard line breaks) are preserved. + */ +function renderInline(tokens: Token[]): string { + let out = ''; + for (const tok of tokens) { + switch (tok.type) { + case 'text': + case 'escape': + out += tok.text; + break; + case 'strong': + out += `${STYLE.bold}${renderInline(tok.tokens ?? [])}${STYLE.reset}`; + break; + case 'em': + out += `${STYLE.italic}${renderInline(tok.tokens ?? [])}${STYLE.reset}`; + break; + case 'del': + out += `${STYLE.strike}${renderInline(tok.tokens ?? [])}${STYLE.reset}`; + break; + case 'codespan': + out += `\`${STYLE.cyan}${tok.text}${STYLE.reset}\``; + break; + case 'link': { + const label = renderInline(tok.tokens ?? []); + out += `${STYLE.underline}${STYLE.blue}${label}${STYLE.reset}`; + if (tok.href && tok.href !== label) { + out += `${STYLE.dim} (${tok.href})${STYLE.reset}`; + } + break; + } + case 'image': + out += `${STYLE.dim}[image: ${tok.text}]${STYLE.reset}`; + break; + case 'checkbox': + out += tok.checked ? '[x]' : '[ ]'; + break; + case 'br': + out += '\n'; + break; + case 'html': + // Raw inline HTML tags are not rendered; strip them so the text + // content (if any) still shows. + out += tok.text.replace(/<[^>]*>/g, ''); + break; + default: + break; } + } + return out; +} - // Bullet lists. - const bullet = /^[-*+]\s+(.*)$/.exec(trimmed); - if (bullet) { - lines.push(` ${VIEWER_GLYPHS.bullet} ${renderNoteLinks(bullet[1])}`); - continue; +/** + * Inline children of a token, or the token itself as a single inline + * token when it carries no children (e.g. block-level `text` tokens from + * tight list items keep their inline-parsed children in `tokens`). + */ +function inlineChildren(tok: Token): Token[] { + return 'tokens' in tok && Array.isArray(tok.tokens) && tok.tokens.length > 0 + ? tok.tokens + : [tok]; +} + +// ── Block token rendering ───────────────────────────────────────────── + +/** Heading glyph per ATX depth: h1 = `██`, h2 = `█`, h3+ = `▌`. */ +function headingGlyph(depth: number): string { + if (depth <= 1) return VIEWER_GLYPHS.headingH1; + if (depth === 2) return VIEWER_GLYPHS.heading; + return VIEWER_GLYPHS.headingDeep; +} + +/** Render a GFM table as aligned terminal columns. */ +function renderTable(tok: Tokens.Table, pad: string): string[] { + const renderCell = (cell: Tokens.TableCell, header: boolean): { styled: string; plain: string } => { + const text = renderInline(cell.tokens); + const styled = header ? `${STYLE.bold}${text}${STYLE.reset}` : text; + return { styled, plain: stripAnsi(styled) }; + }; + const header = tok.header.map(c => renderCell(c, true)); + const rows = tok.rows.map(r => r.map(c => renderCell(c, false))); + + // Column widths from the visible (ANSI-stripped) text. + const widths = tok.header.map((_c, col) => { + let w = header[col]?.plain.length ?? 0; + for (const row of rows) w = Math.max(w, row[col]?.plain.length ?? 0); + return w; + }); + + const padCell = (cell: { styled: string; plain: string }, width: number, align: 'center' | 'left' | 'right' | null): string => { + const diff = width - cell.plain.length; + let left = 0; + let right = diff; + if (align === 'center') { + left = Math.floor(diff / 2); + right = Math.ceil(diff / 2); + } else if (align === 'right') { + left = diff; + right = 0; } + return `${' '.repeat(left)}${cell.styled}${' '.repeat(right)}`; + }; - // Blank line → blank viewer line. - if (trimmed === '') { - lines.push(''); - continue; + const renderRow = (cells: { styled: string; plain: string }[]): string => + `${VIEWER_GLYPHS.table}${cells.map((c, i) => ` ${padCell(c, widths[i], tok.align[i] ?? null)} `).join(VIEWER_GLYPHS.table)}${VIEWER_GLYPHS.table}`; + + const lines: string[] = []; + lines.push(pad + renderRow(header)); + lines.push(pad + `├${widths.map(w => `${VIEWER_GLYPHS.hr.repeat(w + 2)}`).join('┼')}┤`); + for (const row of rows) lines.push(pad + renderRow(row)); + return lines; +} + +/** + * Render a block-level token list to terminal lines. + * + * @param tokens - Block tokens (from `marked.lexer`). + * @param pad - Indentation prefix applied to every emitted line. + * @param maxCols - Terminal width used for word-wrapping/truncation. + * @returns Terminal lines (styled, no trailing padding). + */ +function renderBlocks(tokens: Token[], pad: string, maxCols: number): string[] { + const lines: string[] = []; + /** Push inline-rendered text as wrapped, padded lines. */ + const pushWrapped = (text: string): void => { + for (const seg of text.split('\n')) { + for (const l of wrapText(seg, maxCols - pad.length)) lines.push(`${pad}${l}`); + } + }; + for (const tok of tokens) { + switch (tok.type) { + case 'space': + // A blank source line renders as a blank viewer line (deduped). + if (lines.length > 0 && lines[lines.length - 1] !== '') lines.push(''); + break; + case 'heading': { + const t = tok as Tokens.Heading; + const text = renderInline(t.tokens ?? []); + const style = t.depth <= 2 ? STYLE.bold : `${STYLE.bold}${STYLE.dim}`; + lines.push(`${pad}${headingGlyph(t.depth)} ${style}${text}${STYLE.reset}`); + break; + } + case 'paragraph': + pushWrapped(renderInline(tok.tokens ?? [])); + break; + case 'text': + pushWrapped(renderInline(inlineChildren(tok))); + break; + case 'list': { + const list = tok as Tokens.List; + list.items.forEach((item, idx) => { + const marker = list.ordered + ? `${(list.start === '' ? 1 : Number(list.start)) + idx}.` + : VIEWER_GLYPHS.bullet; + const prefix = `${marker} `; + const contentPad = `${pad}${' '.repeat(prefix.length)}`; + const itemLines = renderBlocks(item.tokens, contentPad, maxCols); + if (itemLines.length === 0) { + lines.push(`${pad}${prefix}`); + return; + } + lines.push(`${pad}${prefix}${itemLines[0].slice(contentPad.length)}`); + lines.push(...itemLines.slice(1)); + }); + break; + } + case 'code': + for (const line of tok.text.split('\n')) { + lines.push(`${pad} ${VIEWER_GLYPHS.code} ${line}`); + } + break; + case 'blockquote': + lines.push(...renderBlocks(tok.tokens ?? [], `${pad}${VIEWER_GLYPHS.quote} `, maxCols)); + break; + case 'table': + lines.push(...renderTable(tok as Tokens.Table, pad)); + break; + case 'hr': { + const width = Math.min(30, Math.max(1, maxCols - pad.length)); + lines.push(`${pad}${VIEWER_GLYPHS.hr.repeat(width)}`); + break; + } + case 'html': + lines.push(`${pad}${tok.text.replace(/<[^>]*>/g, '').trim()}`); + break; + case 'def': + break; // definition links are consumed by the linker — nothing to show + default: + break; } + } + return lines; +} + +// ── Markdown rendering entry points ─────────────────────────────────── + +/** Leading YAML frontmatter block (`---` delimited at the start of file). */ +const YAML_FRONTMATTER_RE = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/; + +/** Strip a leading YAML frontmatter block, if present. */ +function stripYamlFrontmatter(text: string): string { + const match = text.match(YAML_FRONTMATTER_RE); + return match ? text.slice(match[0].length) : text; +} + +export interface RenderMarkdownOptions { + /** Skip a leading YAML frontmatter block (`---` delimited). */ + skipFrontmatter?: boolean; +} - // Plain paragraph line. - lines.push(` ${renderNoteLinks(line)}`); +/** + * Render markdown text as ANSI-styled terminal viewer lines. + * + * Inline `[NOTE : ...]` markers are always rendered as `↗` links + * (see `renderNoteLinks`). Renders the full GFM construct set used in + * `.podcast.md` episode files and work-item descriptions: heading + * hierarchy, ordered + nested bullet lists, blockquotes, tables, fenced + * and inline code, bold/italic/strikethrough, links, and horizontal rules. + * + * Fail-open: on a parse error the raw text is returned as plain lines so + * the viewer never crashes on unexpected input. + * + * @param mdText - Raw markdown document text. + * @param maxCols - Terminal width to wrap/truncate lines to. + * @param opts - Rendering options. + * @returns Viewer lines (no trailing padding; callers pad to viewport). + */ +export function renderMarkdown(mdText: string, maxCols: number, opts: RenderMarkdownOptions = {}): string[] { + if (!mdText) return []; + let text = renderNoteLinks(mdText); + if (opts.skipFrontmatter) text = stripYamlFrontmatter(text); + try { + const tokens = lexer(text); + const lines = renderBlocks(tokens, '', maxCols); + return lines.map(l => truncateAnsi(l, maxCols)); + } catch { + // Fail-open: never crash the viewer on malformed markdown. + return text.split('\n').map(l => truncateAnsi(l, maxCols)); } +} - // Wrap/truncate to maxCols. - return lines.map(l => (l.length > maxCols ? l.slice(0, maxCols) : l)); +/** + * Render a markdown document body as terminal viewer lines. + * + * YAML frontmatter (`---`-delimited, leading) is skipped; inline + * `[NOTE : ...]` markers are rendered as `↗` links. + * + * @param mdText - Raw markdown document text. + * @param maxCols - Terminal width to wrap/truncate lines to. + * @returns Viewer lines (no trailing padding; callers pad to viewport). + */ +export function renderMarkdownViewer(mdText: string, maxCols: number): string[] { + return renderMarkdown(mdText, maxCols, { skipFrontmatter: true }); } diff --git a/packages/herdr/src/notify-dispatch.test.ts b/packages/herdr/src/notify-dispatch.test.ts index 1f811718..06302e29 100644 --- a/packages/herdr/src/notify-dispatch.test.ts +++ b/packages/herdr/src/notify-dispatch.test.ts @@ -24,6 +24,7 @@ vi.mock('./auto-sync.js', () => ({ runSync: vi.fn().mockResolvedValue({ success: true }), createSyncTimer: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), clampSyncInterval: vi.fn((v: number) => v), + heartbeatTtlForInterval: vi.fn(() => 45_000), })); vi.mock('./notify.js', () => ({ @@ -94,7 +95,7 @@ function tick(): Promise { // --------------------------------------------------------------------------- describe('runWorklistTui toast dispatch', () => { - it('dispatches a Synced toast on manual sync (S) and writes no bottom line', async () => { + it('does not sync on S (manual sync removed) and writes no bottom line', async () => { const p = runWorklistTui(async () => [], [], undefined, { autoRefresh: false, autoSync: false, @@ -103,13 +104,14 @@ describe('runWorklistTui toast dispatch', () => { // Let initial render settle await tick(); - // Manual sync + // Manual sync was removed (WL-0MSGG5N5Z0074TLY): pressing S must NOT + // spawn `wl sync` (auto-sync on the timer is the only sync source). dataHandler?.(Buffer.from('S')); await tick(); await tick(); - expect(mockRunSync).toHaveBeenCalled(); - expect(mockShowToast).toHaveBeenCalledWith('Synced'); + expect(mockRunSync).not.toHaveBeenCalled(); + expect(mockShowToast).not.toHaveBeenCalled(); // Quit dataHandler?.(Buffer.from('q')); @@ -121,17 +123,18 @@ describe('runWorklistTui toast dispatch', () => { expect(rendered).not.toContain('[Refreshed'); }); - it('dispatches a Sync failed toast with the error body on sync failure', async () => { + it('dispatches a Sync failed toast with the error body on auto-sync failure', async () => { mockRunSync.mockResolvedValueOnce({ success: false, error: 'lock held' }); const p = runWorklistTui(async () => [], [], undefined, { autoRefresh: false, - autoSync: false, + autoSync: true, + syncIntervalMs: 60_000, showHelpText: false, }); await tick(); - - dataHandler?.(Buffer.from('S')); - await tick(); + // The auto-sync task fires immediately on scheduler start + // (fireImmediately), so the failing sync surfaces its toast without + // waiting for the full interval. await tick(); expect(mockShowToast).toHaveBeenCalledWith('Sync failed', { body: 'lock held' }); diff --git a/packages/herdr/src/ship-it-dialog-tui.test.ts b/packages/herdr/src/ship-it-dialog-tui.test.ts new file mode 100644 index 00000000..607f59ac --- /dev/null +++ b/packages/herdr/src/ship-it-dialog-tui.test.ts @@ -0,0 +1,373 @@ +/** + * packages/herdr/src/ship-it-dialog-tui.test.ts — Integration tests for the + * Ship It confirmation dialog in the worklist TUI (WL-0MSGG5N5Z0074TLY). + * + * Exercises the end-to-end TUI flow with the REAL shortcut registry + * (loadShortcutConfig, same as production index.ts) and a fake stdin/stdout + * harness (same pattern as code-freeze-dialog.test.ts): + * - pressing `S` opens the bottom-anchored dialog (list still visible); + * - typing `ship` (case-insensitive) + Enter dispatches + * `/skill:ship release` via the standard command-routing path — with NO + * `` substitution; + * - non-matching text + Enter does NOT dispatch (dialog stays open); + * - Esc cancels without dispatching; + * - `s` (lowercase Search) does NOT open the dialog (S/s distinct); + * - the Ship It shortcut stays available during a Code Freeze; + * - the S shortcut appears in the footer hints. + * + * Run: npx vitest run packages/herdr/src/ship-it-dialog-tui.test.ts + */ + +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'; + +// --------------------------------------------------------------------------- +// Module mocks (must be hoisted before worklist.js is imported) +// --------------------------------------------------------------------------- + +vi.mock('./fetcher.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchActionableCount: vi.fn().mockResolvedValue(0), + fetchChildrenForItem: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock('./auto-sync.js', () => ({ + runSync: vi.fn().mockResolvedValue({ success: true }), + createSyncTimer: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), + clampSyncInterval: vi.fn((v: number) => v), +})); + +vi.mock('./notify.js', () => ({ + showToast: vi.fn(), +})); + +import { runWorklistTui, SHIP_IT_COMMAND } from './worklist.js'; +import { setWorklogDir, resetWorklogDir, type WorkItem } from './fetcher.js'; +import { CODE_FREEZE_MARKER_FILENAME } from './code-freeze.js'; +import { setLogPath, resetLogPath } from './command-log.js'; +import { loadShortcutConfig } from './shortcut-config.js'; + +// --------------------------------------------------------------------------- +// Fake stdin/stdout harness (same pattern as code-freeze-dialog.test.ts) +// --------------------------------------------------------------------------- + +let dataHandler: ((chunk: Buffer) => void) | undefined; +let writes: string[]; + +/** Temp worklog dir holding the code-freeze marker for the current test. */ +let tmpDir: string; + +beforeEach(() => { + vi.clearAllMocks(); + dataHandler = undefined; + writes = []; + + // Isolate the command log so dispatched commands never touch the user's + // real ~/.config/herdr log (WL-0MSEPP104006PS7T). + setLogPath(join(tmpdir(), `herdr-ship-cmdlog-${process.pid}-${Date.now()}.json`)); + + tmpDir = mkdtempSync(join(tmpdir(), 'herdr-ship-')); + setWorklogDir(tmpDir); + + // 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(() => { + resetWorklogDir(); + resetLogPath(); + rmSync(tmpDir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +/** Small async tick helper so awaited promises settle. */ +function tick(): Promise { + return new Promise((r) => setTimeout(r, 5)); +} + +/** + * Build a minimal WorkItem with required fields. + */ +function makeItem(id: string, stage?: string): WorkItem { + return { id, title: `Item ${id}`, status: 'open', stage }; +} + +/** Write an active code-freeze marker into the temp worklog dir. */ +function writeActiveFreezeMarker(reason?: string): void { + writeFileSync( + join(tmpDir, CODE_FREEZE_MARKER_FILENAME), + JSON.stringify({ active: true, reason, startedAt: '2026-08-02T00:00:00Z', pid: 999 }), + 'utf8', + ); +} + +/** + * Start the TUI with auto-refresh/sync disabled and the REAL shortcut + * registry (same as production index.ts). + */ +function startTui( + onCommand?: (c: string) => void, + items: WorkItem[] = [], + opts: { showHelpText?: boolean } = {}, +): Promise { + return runWorklistTui(async () => items, items, loadShortcutConfig(), { + autoRefresh: false, + autoSync: false, + showHelpText: opts.showHelpText ?? false, + onCommand, + }); +} + +/** The last full render written to stdout. */ +function lastRender(): string { + return writes[writes.length - 1] ?? ''; +} + +const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); + +/** + * True when the last render shows the bottom-anchored Ship It dialog over + * the selection list: the list header AND the dialog copy are both present. + */ +function shipDialogShowing(): boolean { + const out = stripAnsi(lastRender()); + return out.includes('Work Items') && out.includes("Type 'ship' to confirm, Esc to cancel"); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Ship It shortcut (S) — typed confirmation dialog (WL-0MSGG5N5Z0074TLY)', () => { + it('opens the bottom-anchored dialog on S with the list still visible', async () => { + const onCommand = vi.fn(); + const p = startTui(onCommand, [makeItem('WL-TEST-1', 'plan_complete')]); + await tick(); + + dataHandler?.(Buffer.from('S')); + await tick(); + await tick(); + + expect(shipDialogShowing()).toBe(true); + // Nothing dispatched yet — typing alone must not fire the release. + expect(onCommand).not.toHaveBeenCalled(); + + dataHandler?.(Buffer.from('\x1b')); // cancel + await tick(); + await tick(); + expect(shipDialogShowing()).toBe(false); + + dataHandler?.(Buffer.from('q')); + await p; + }); + + it('does not open the dialog for lowercase s (Search chord, S/s distinct)', async () => { + const onCommand = vi.fn(); + const p = startTui(onCommand, [makeItem('WL-TEST-1', 'plan_complete')]); + await tick(); + + dataHandler?.(Buffer.from('s')); + await tick(); + await tick(); + + // Lowercase s is the Search shortcut (unknown → form page); + // it must NOT trigger the Ship It confirmation dialog. + expect(shipDialogShowing()).toBe(false); + expect(stripAnsi(lastRender())).toContain('Command Input'); + + dataHandler?.(Buffer.from('\x1b')); // cancel the form + await tick(); + await tick(); + dataHandler?.(Buffer.from('q')); + await p; + }); + + it('dispatches /skill:ship release (no ) when the user types ship + Enter', async () => { + const onCommand = vi.fn(); + const p = startTui(onCommand, [makeItem('WL-TEST-1', 'plan_complete')]); + await tick(); + + dataHandler?.(Buffer.from('S')); + await tick(); + await tick(); + expect(shipDialogShowing()).toBe(true); + + for (const ch of 'ship') dataHandler?.(Buffer.from(ch)); + await tick(); + await tick(); + // The typed buffer is reflected in the dialog render. + expect(stripAnsi(lastRender())).toContain('> ship'); + + dataHandler?.(Buffer.from('\r')); // Enter submits + await tick(); + await tick(); + + expect(onCommand).toHaveBeenCalledWith(SHIP_IT_COMMAND, 'plan'); // agent commands default to the plan model + expect(onCommand).toHaveBeenCalledTimes(1); + // The dialog closes after dispatch. + expect(shipDialogShowing()).toBe(false); + + dataHandler?.(Buffer.from('q')); + await p; + }); + + it('matches case-insensitively (SHIP dispatches)', async () => { + const onCommand = vi.fn(); + const p = startTui(onCommand, [makeItem('WL-TEST-1')]); + await tick(); + + dataHandler?.(Buffer.from('S')); + await tick(); + for (const ch of 'SHIP') dataHandler?.(Buffer.from(ch)); + await tick(); + dataHandler?.(Buffer.from('\r')); + await tick(); + await tick(); + + expect(onCommand).toHaveBeenCalledWith(SHIP_IT_COMMAND, 'plan'); // agent commands default to the plan model + expect(onCommand).toHaveBeenCalledTimes(1); + + dataHandler?.(Buffer.from('q')); + await p; + }); + + it('does not dispatch on Enter with non-matching text (dialog stays open)', async () => { + const onCommand = vi.fn(); + const p = startTui(onCommand, [makeItem('WL-TEST-1')]); + await tick(); + + dataHandler?.(Buffer.from('S')); + await tick(); + for (const ch of 'shipx') dataHandler?.(Buffer.from(ch)); + await tick(); + dataHandler?.(Buffer.from('\r')); + await tick(); + await tick(); + + expect(onCommand).not.toHaveBeenCalled(); + // Dialog stays open (AC3): the list + prompt are still rendered. + expect(shipDialogShowing()).toBe(true); + + dataHandler?.(Buffer.from('\x1b')); // cancel + await tick(); + dataHandler?.(Buffer.from('q')); + await p; + }); + + it('cancels with Esc without dispatching anything', async () => { + const onCommand = vi.fn(); + const p = startTui(onCommand, [makeItem('WL-TEST-1')]); + await tick(); + + dataHandler?.(Buffer.from('S')); + await tick(); + for (const ch of 'ship') dataHandler?.(Buffer.from(ch)); + await tick(); + expect(shipDialogShowing()).toBe(true); + + dataHandler?.(Buffer.from('\x1b')); // Esc cancels + await tick(); + await tick(); + + expect(shipDialogShowing()).toBe(false); + expect(onCommand).not.toHaveBeenCalled(); + + // The TUI is back in list mode: j navigates. + const writesBefore = writes.length; + dataHandler?.(Buffer.from('j')); + await tick(); + await tick(); + expect(writes.length).toBeGreaterThan(writesBefore); + + dataHandler?.(Buffer.from('q')); + await p; + }); + + it('consumes navigation keys while the dialog is open (modal input)', async () => { + const onCommand = vi.fn(); + const p = startTui(onCommand, [makeItem('WL-TEST-1')]); + await tick(); + + dataHandler?.(Buffer.from('S')); + await tick(); + expect(shipDialogShowing()).toBe(true); + + // Navigation keys are swallowed into the typed buffer, not navigation. + dataHandler?.(Buffer.from('j')); + await tick(); + expect(shipDialogShowing()).toBe(true); + expect(stripAnsi(lastRender())).toContain('> j'); + + dataHandler?.(Buffer.from('\x1b')); + await tick(); + dataHandler?.(Buffer.from('q')); + await p; + }); + + it('is NOT blocked during a Code Freeze (ship skill gates itself)', async () => { + writeActiveFreezeMarker('ship release in progress'); + const onCommand = vi.fn(); + const p = startTui(onCommand, [makeItem('WL-TEST-1', 'plan_complete')]); + await tick(); + + // During a freeze the S shortcut still resolves (no code_freeze: "block"). + dataHandler?.(Buffer.from('S')); + await tick(); + await tick(); + expect(shipDialogShowing()).toBe(true); + + for (const ch of 'ship') dataHandler?.(Buffer.from(ch)); + await tick(); + dataHandler?.(Buffer.from('\r')); + await tick(); + await tick(); + + // The release command is dispatched even while frozen — the ship skill + // itself decides whether a release may proceed. + expect(onCommand).toHaveBeenCalledWith(SHIP_IT_COMMAND, 'plan'); // agent commands default to the plan model + + dataHandler?.(Buffer.from('q')); + await p; + }); + + it('shows the S ship-it shortcut in the dynamic footer hints', async () => { + const p = startTui(() => {}, [makeItem('WL-TEST-1', 'plan_complete')], { showHelpText: true }); + await tick(); + + const out = stripAnsi(lastRender()); + expect(out).toContain('S:ship it'); + expect(out).toContain('s:Search'); + + dataHandler?.(Buffer.from('q')); + await p; + }); +}); diff --git a/packages/herdr/src/ship-it-dialog.test.ts b/packages/herdr/src/ship-it-dialog.test.ts new file mode 100644 index 00000000..3061e6a2 --- /dev/null +++ b/packages/herdr/src/ship-it-dialog.test.ts @@ -0,0 +1,210 @@ +/** + * packages/herdr/src/ship-it-dialog.test.ts — Unit tests for the Ship It + * typed-confirmation dialog (WL-0MSGG5N5Z0074TLY). + * + * Covers: + * - ShipItDialogState key handling: typed-input accumulation, Backspace, + * case-insensitive `ship` matching on Enter, non-matching Enter (buffer + * cleared, dialog stays open — AC3 option (a)), Esc cancellation. + * - formatShipItDialog rendering: prompt, typed buffer reflection, box + * borders, line widths within the pane width. + * - overlayShipItDialog: bottom-anchored overlay — the selection list + * stays visible above the dialog and the output stays within the pane + * height budget. + * + * Run: npx vitest run packages/herdr/src/ship-it-dialog.test.ts + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + ShipItDialogState, + formatShipItDialog, + overlayShipItDialog, +} from './ship-it-dialog.js'; + +const visible = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); + +function makeDialog(): { state: ShipItDialogState; onConfirm: ReturnType; onCancel: ReturnType } { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + const state = new ShipItDialogState(onConfirm, onCancel); + return { state, onConfirm, onCancel }; +} + +// ── ShipItDialogState — key handling ─────────────────────────────────── + +describe('ShipItDialogState — typed input', () => { + it('appends printable characters to the buffer', () => { + const { state } = makeDialog(); + expect(state.handleInput('s')).toBeNull(); + expect(state.handleInput('h')).toBeNull(); + expect(state.buffer).toBe('sh'); + }); + + it('deletes the last character with Backspace', () => { + const { state } = makeDialog(); + state.handleInput('s'); + state.handleInput('h'); + state.handleInput('\x7f'); + expect(state.buffer).toBe('s'); + }); + + it('ignores non-printable control keys', () => { + const { state } = makeDialog(); + expect(state.handleInput('\x1b[A')).toBeNull(); // arrow up + expect(state.handleInput('\t')).toBeNull(); + expect(state.buffer).toBe(''); + }); +}); + +describe('ShipItDialogState — confirmation matching (case-insensitive)', () => { + it('submits when the buffer equals ship (lowercase)', () => { + const { state, onConfirm } = makeDialog(); + for (const ch of 'ship') state.handleInput(ch); + const result = state.handleInput('\r'); + expect(result).toBe('submitted'); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('submits for mixed/upper case input (Ship, SHIP)', () => { + for (const typed of ['Ship', 'SHIP', 'sHiP']) { + const { state, onConfirm } = makeDialog(); + for (const ch of typed) state.handleInput(ch); + expect(state.handleInput('\n')).toBe('submitted'); + expect(onConfirm).toHaveBeenCalledTimes(1); + } + }); + + it('does not submit for a superstring of ship', () => { + const { state, onConfirm } = makeDialog(); + for (const ch of 'shipped') state.handleInput(ch); + const result = state.handleInput('\r'); + expect(result).toBeNull(); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('does not submit for a partial match', () => { + const { state, onConfirm } = makeDialog(); + state.handleInput('sh'); + expect(state.handleInput('\r')).toBeNull(); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('clears the buffer on non-matching Enter and keeps the dialog open (AC3 option (a))', () => { + const { state, onConfirm } = makeDialog(); + for (const ch of 'nope') state.handleInput(ch); + expect(state.buffer).toBe('nope'); + const result = state.handleInput('\r'); + expect(result).toBeNull(); // dialog stays open + expect(onConfirm).not.toHaveBeenCalled(); + expect(state.buffer).toBe(''); // buffer cleared so the user can retry + }); + + it('does not dispatch on Enter with an empty buffer', () => { + const { state, onConfirm } = makeDialog(); + expect(state.handleInput('\r')).toBeNull(); + expect(onConfirm).not.toHaveBeenCalled(); + }); +}); + +describe('ShipItDialogState — Esc cancellation', () => { + it('cancels with Esc without dispatching', () => { + const { state, onConfirm, onCancel } = makeDialog(); + for (const ch of 'ship') state.handleInput(ch); + const result = state.handleInput('\x1b'); + expect(result).toBe('cancelled'); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('cancels with Esc even with no typed input', () => { + const { state, onCancel } = makeDialog(); + expect(state.handleInput('\x1b')).toBe('cancelled'); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); + +// ── formatShipItDialog — rendering ───────────────────────────────────── + +describe('formatShipItDialog — rendering', () => { + it('renders the prompt, hint and typed buffer', () => { + const out = visible(formatShipItDialog(80, 'sh')); + expect(out).toContain('Ship It'); + expect(out).toContain("Type 'ship' to confirm, Esc to cancel"); + expect(out).toContain('> sh'); + expect(out).toContain('[Enter] confirm [Esc] cancel'); + }); + + it('reflects the current typed input', () => { + const empty = visible(formatShipItDialog(80, '')); + const typed = visible(formatShipItDialog(80, 'SHIP')); + expect(empty).toContain('> '); + expect(typed).toContain('> SHIP'); + }); + + it('draws a bordered box', () => { + const out = visible(formatShipItDialog(80, '')); + expect(out).toContain('┌'); + expect(out).toContain('┐'); + expect(out).toContain('└'); + expect(out).toContain('┘'); + expect(out).toContain('│'); + }); + + it('never emits a line wider than maxCols', () => { + for (const cols of [40, 60, 80, 120]) { + for (const buffer of ['', 's', 'shipped']) { + for (const line of visible(formatShipItDialog(cols, buffer)).split('\n')) { + expect(line.length).toBeLessThanOrEqual(cols); + } + } + } + }); +}); + +// ── overlayShipItDialog — bottom-anchored composition ────────────────── + +describe('overlayShipItDialog — bottom-anchored overlay', () => { + // A 24-row list renderer output (23 lines, the rows-1 budget). + const listLines = Array.from({ length: 23 }, (_, i) => i === 0 ? 'Work Items — 3 item(s)' : `row ${i}`); + const listOutput = listLines.join('\n'); + + it('keeps the selection list visible above the dialog', () => { + const out = overlayShipItDialog(listOutput, 80, 24, 'ship'); + expect(out).toContain('Work Items — 3 item(s)'); + expect(out).toContain('Ship It'); + }); + + it('anchors the dialog at the bottom rows', () => { + const out = overlayShipItDialog(listOutput, 80, 24, 'ship'); + const lines = out.split('\n'); + // The dialog box is the LAST 7 lines; the list rows above remain. + const dialogStart = lines.findIndex((l) => visible(l).includes('Ship It')); + expect(dialogStart).toBeGreaterThanOrEqual(0); + expect(visible(lines[lines.length - 1])).toContain('┘'); + // The header (first row) is always preserved. + expect(visible(lines[0])).toContain('Work Items'); + // The top list rows above the dialog are unchanged. + expect(visible(lines[1])).toBe('row 1'); + }); + + it('stays within the pane height budget', () => { + const out = overlayShipItDialog(listOutput, 80, 24, 'ship'); + expect(out.split('\n').length).toBeLessThanOrEqual(24); + }); + + it('replaces exactly the bottom dialog-height rows (23 → 16 list + 7 dialog)', () => { + const out = overlayShipItDialog(listOutput, 80, 24, 'ship'); + const lines = out.split('\n'); + expect(lines.length).toBe(23); // same as the list output (rows - 1) + expect(visible(lines[15])).toBe('row 15'); // last preserved list row (16 kept) + expect(visible(lines[16])).toContain('┌'); // dialog starts on the next row + }); + + it('never exceeds the pane height even for a short list', () => { + const shortList = ['Work Items — 1 item(s)', '']; + const out = overlayShipItDialog(shortList.join('\n'), 80, 24, ''); + expect(out.split('\n').length).toBeLessThanOrEqual(24); + expect(out).toContain('Ship It'); + }); +}); diff --git a/packages/herdr/src/ship-it-dialog.ts b/packages/herdr/src/ship-it-dialog.ts new file mode 100644 index 00000000..2f12d4a8 --- /dev/null +++ b/packages/herdr/src/ship-it-dialog.ts @@ -0,0 +1,180 @@ +/** + * packages/herdr/src/ship-it-dialog.ts — Ship It typed-confirmation dialog + * + * The Ship It shortcut (`S`, WL-0MSGG5N5Z0074TLY) triggers a dev→main + * release, so it must not fire on a stray keypress. Pressing `S` opens a + * bottom-anchored confirmation dialog overlaid on the lower rows of the + * selection list (the list stays visible above it — distinct from the + * full-screen FormState and the centered Code Freeze notice box). The user + * must type `ship` (case-insensitive) and press Enter to dispatch + * `/skill:ship release`; Esc cancels. + * + * Provides: + * - {@link ShipItDialogState} — mutable typed-input state + key handling + * - {@link formatShipItDialog} — renders the dialog box lines + * - {@link overlayShipItDialog} — composes list output + bottom-anchored + * dialog within the pane height budget + * + * Width/wrap math reuses the `visibleWidth` helper from form-dialog.ts + * (no new dependencies). + */ + +import { visibleWidth, truncateToWidth } from './form-dialog.js'; + +// ── ANSI helpers ────────────────────────────────────────────────────── + +const ANSI = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + reverse: '\x1b[7m', + fg: (code: number) => `\x1b[38;5;${code}m`, +}; + +// ── ShipItDialogState ───────────────────────────────────────────────── + +/** + * Mutable state for the Ship It confirmation dialog. + * + * Holds the typed confirmation buffer and processes keys: printable + * characters append to the buffer, Backspace deletes, Esc cancels, and + * Enter submits — dispatching only when the buffer lowercased equals + * `ship`. Enter with any other text clears the buffer and keeps the dialog + * open so the user can retry (never dispatches). + */ +export class ShipItDialogState { + /** Typed confirmation input buffer (matches `ship` case-insensitively). */ + buffer: string; + + /** Called when the user types `ship` and presses Enter. */ + private onConfirm: () => void; + + /** Called when the user presses Esc. */ + private onCancel: () => void; + + constructor(onConfirm: () => void, onCancel: () => void) { + this.buffer = ''; + this.onConfirm = onConfirm; + this.onCancel = onCancel; + } + + /** + * Process a single keypress while the dialog is open. + * + * @param key - The raw keypress string + * @returns 'submitted' if the dialog was confirmed (dispatch fired), + * 'cancelled' if dismissed with Esc, or null while the dialog + * stays open (typed input / non-matching Enter / ignored keys) + */ + handleInput(key: string): 'submitted' | 'cancelled' | null { + if (key === '\r' || key === '\n') { + if (this.buffer.toLowerCase() === 'ship') { + this.onConfirm(); + return 'submitted'; + } + // Non-matching Enter: clear the buffer, keep the dialog open so the + // user can retry (AC3 option (a)) — nothing is dispatched. + this.buffer = ''; + return null; + } + + if (key === '\x1b') { + // Esc cancels without dispatching. + this.onCancel(); + return 'cancelled'; + } + + if (key === '\x7f' || key === '\b') { + // Backspace: delete the last character from the buffer. + if (this.buffer.length > 0) { + this.buffer = this.buffer.slice(0, -1); + } + return null; + } + + // Regular character input (printable ASCII/UTF-8 single keypress). + if (key.length === 1 && key.charCodeAt(0) >= 0x20) { + this.buffer += key; + return null; + } + + // Ignore other control sequences. + return null; + } +} + +// ── Rendering ───────────────────────────────────────────────────────── + +/** + * Render the Ship It confirmation dialog as a bordered box. + * + * The box is a compact 7-line block (title, prompt, blank, typed input, + * hints) styled consistently with the Code Freeze notice dialog + * (formatCodeFreezeDialog). The dialog itself is bottom-anchored by + * {@link overlayShipItDialog}; this function renders just the box. + * + * @param maxCols - Terminal width (drives box width and centering) + * @param buffer - Current typed confirmation text + * @returns The rendered dialog box string (newline-separated lines) + */ +export function formatShipItDialog(maxCols: number, buffer: string): string { + const lines: string[] = []; + const dialogWidth = Math.min(maxCols - 4, 64); + const dialogMinWidth = 44; + // Clamp to maxCols so a very narrow pane never overflows (unlike the + // centered Code Freeze box, the bottom-anchored dialog stays within the + // pane width at every size). + const effectiveWidth = Math.max(1, Math.min(Math.max(dialogMinWidth, dialogWidth), maxCols)); + const leftPad = Math.max(0, Math.floor((maxCols - effectiveWidth) / 2)); + + const padLine = (content: string): string => { + // Truncate the content to the inner box width so a fixed prompt line + // can never overflow a very narrow pane (rightPad stays ≥ 0). + const innerWidth = Math.max(1, effectiveWidth - 4); + const truncated = truncateToWidth(content, innerWidth); + const visibleLen = visibleWidth(truncated); + const rightPad = Math.max(0, effectiveWidth - visibleLen - 4); + return ' '.repeat(leftPad) + `│ ${truncated}${' '.repeat(rightPad)} │`; + }; + + const borderLine = (left: string, right: string): string => + ' '.repeat(leftPad) + `${left}${'─'.repeat(effectiveWidth - 2)}${right}`; + + lines.push(borderLine('┌', '┐')); + lines.push(padLine(`${ANSI.bold}Ship It — run the dev→main release?${ANSI.reset}`)); + lines.push(padLine(`${ANSI.dim}Type 'ship' to confirm, Esc to cancel${ANSI.reset}`)); + lines.push(padLine('')); + lines.push(padLine(` ${ANSI.fg(33)}> ${buffer}${ANSI.reverse} ${ANSI.reset}`)); + lines.push(padLine(`${ANSI.dim}[Enter] confirm [Esc] cancel${ANSI.reset}`)); + lines.push(borderLine('└', '┘')); + return lines.join('\n'); +} + +/** + * Overlay the Ship It dialog on the bottom rows of the rendered selection + * list. + * + * Bottom-anchored: the top of the list output is preserved and only the + * lower rows are replaced by the dialog, so the list stays visible above + * it (no full-screen takeover). The total output never exceeds the list + * renderer's `rows - 1` line budget (the notification row stays free). + * + * @param listOutput - The full rendered list screen (newline-separated) + * @param maxCols - Terminal width + * @param maxRows - Terminal height + * @param buffer - Current typed confirmation text + * @returns The composed screen string (list + bottom dialog) + */ +export function overlayShipItDialog( + listOutput: string, + maxCols: number, + maxRows: number, + buffer: string, +): string { + const dialogLines = formatShipItDialog(maxCols, buffer).split('\n'); + const listLines = listOutput.split('\n'); + const keep = Math.max(0, listLines.length - dialogLines.length); + const result = [...listLines.slice(0, keep), ...dialogLines]; + // Safety clamp: never exceed the pane height budget. + return result.slice(0, Math.max(1, maxRows)).join('\n'); +} diff --git a/packages/herdr/src/shortcut-config.test.ts b/packages/herdr/src/shortcut-config.test.ts index cde3b48a..9d078951 100644 --- a/packages/herdr/src/shortcut-config.test.ts +++ b/packages/herdr/src/shortcut-config.test.ts @@ -118,9 +118,9 @@ describe('loadShortcutConfig — production shortcuts.json', () => { expect(entry?.command).toBe('/skill:implement '); }); - it('restricts the bundled code-workflow chords n/p/i to code issue types', () => { + it('restricts the bundled code-workflow chords n/p/i to code and docs issue types', () => { const registry = loadShortcutConfig(); - const codeTypes = ['bug', 'feature', 'task', 'chore', 'epic']; + const codeTypes = ['bug', 'docs', 'feature', 'task', 'chore', 'epic']; for (const chord of ['n', 'p', 'i']) { const entry = registry.lookupChordEntry([chord], 'list'); expect(entry?.workItemTypes).toEqual(codeTypes); @@ -138,6 +138,29 @@ describe('loadShortcutConfig — production shortcuts.json', () => { expect(entry?.command).toBe('/wl'); expect(entry?.label).toBe('sprint'); }); + + it('registers the S ship-it chord to /skill:ship release (WL-0MSGG5N5Z0074TLY)', () => { + const registry = loadShortcutConfig(); + const entry = registry.lookupChordEntry(['S'], 'list', undefined, false); + expect(entry).toBeDefined(); + expect(entry?.command).toBe('/skill:ship release'); + expect(entry?.label).toBe('ship it'); + expect(entry?.view).toBe('both'); + // NOT code_freeze-blocked: the ship skill gates itself, so the + // shortcut stays available during a freeze (confirmed decision). + expect(entry?.codeFreeze).toBeUndefined(); + // S is distinct from lowercase s (Search) — case-sensitive matching. + const search = registry.lookupChordEntry(['s'], 'list', undefined, false); + expect(search?.command).toBe('!!wl search '); + expect(registry.lookupChordEntry(['s'], 'list', undefined, false)?.command) + .not.toBe('/skill:ship release'); + }); + + it('keeps the S ship-it chord visible during a Code Freeze', () => { + const registry = loadShortcutConfig(); + expect(registry.lookupChordEntry(['S'], 'list', undefined, true)).toBeDefined(); + expect(registry.getEntriesForStage(undefined, true).some(e => e.chord[0] === 'S')).toBe(true); + }); }); // ── parseShortcutEntry ─────────────────────────────────────────────────── @@ -332,7 +355,7 @@ describe('ShortcutRegistry — issue-type filtering', () => { command: '/skill:implement ', view: 'both', label: 'implement', - workItemTypes: ['bug', 'feature', 'task', 'chore', 'epic'], + workItemTypes: ['bug', 'docs', 'feature', 'task', 'chore', 'epic'], }; const untypedEntry: ShortcutEntry = { @@ -354,12 +377,11 @@ describe('ShortcutRegistry — issue-type filtering', () => { 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']) { + it('resolves a code-gated chord only for code and docs item types', () => { + for (const t of ['bug', 'docs', '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', () => { @@ -513,3 +535,92 @@ describe('loadShortcutConfig — project-local shortcuts.json overrides', () => expect(JSON.stringify(a.getEntries())).toBe(JSON.stringify(b.getEntries())); }); }); + +// ── w chord split: per-sub-chord stage gating (OSL-0MSKVB5K6008XFOQ) ── +// The single-key `w` write-script chord becomes a chord leader with three +// sub-chords: w-r (write review), w-s (write script), w-b (write both). +// w-r/w-b require an existing script, so they are gated to script-bearing +// stages (plan_complete / in_review / done); w-s keeps today's four-stage +// gate. All three stay gated to podcast-typed items. + +describe('loadShortcutConfig — w chord split stage gating', () => { + let tempRoots: string[] = []; + + function makeLocalRoot(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), 'herdr-w-chord-')); + 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(); + }); + + const wEntries = [ + { chord: ['w', 'r'], command: '/skill:wiki-podcast-script --review ', view: 'both', label: 'write review', stages: ['plan_complete', 'in_review', 'done'], work_item_types: ['podcast'] }, + { chord: ['w', 's'], command: '/skill:wiki-podcast-script ', view: 'both', label: 'write script', stages: ['intake_complete', 'plan_complete', 'in_review', 'done'], work_item_types: ['podcast'] }, + { chord: ['w', 'b'], command: '/skill:wiki-podcast-script --review-rewrite ', view: 'both', label: 'write both', stages: ['plan_complete', 'in_review', 'done'], work_item_types: ['podcast'] }, + ]; + + it('loads all three w sub-chords and no single-key w', () => { + const root = makeLocalRoot({ 'shortcuts.json': JSON.stringify(wEntries) }); + const registry = loadShortcutConfig(root); + expect(registry.lookupChordEntry(['w'], 'list')).toBeUndefined(); + expect(registry.lookupChordEntry(['w', 'r'], 'list')).toBeDefined(); + expect(registry.lookupChordEntry(['w', 's'], 'list')).toBeDefined(); + expect(registry.lookupChordEntry(['w', 'b'], 'list')).toBeDefined(); + }); + + it('w-r and w-b are visible only at script-bearing stages (plan_complete, in_review, done)', () => { + const root = makeLocalRoot({ 'shortcuts.json': JSON.stringify(wEntries) }); + const registry = loadShortcutConfig(root); + for (const chord of [['w', 'r'], ['w', 'b']]) { + for (const stage of ['plan_complete', 'in_review', 'done']) { + expect(registry.lookupChordEntry(chord as ['w', 'r'], 'list', stage)).toBeDefined(); + } + for (const stage of ['idea', 'intake_complete', 'in_progress']) { + expect(registry.lookupChordEntry(chord as ['w', 'r'], 'list', stage)).toBeUndefined(); + } + } + }); + + it('w-s keeps the four-stage gate (intake_complete through done)', () => { + const root = makeLocalRoot({ 'shortcuts.json': JSON.stringify(wEntries) }); + const registry = loadShortcutConfig(root); + for (const stage of ['intake_complete', 'plan_complete', 'in_review', 'done']) { + expect(registry.lookupChordEntry(['w', 's'], 'list', stage)).toBeDefined(); + } + for (const stage of ['idea', 'in_progress']) { + expect(registry.lookupChordEntry(['w', 's'], 'list', stage)).toBeUndefined(); + } + }); + + it('all three sub-chords resolve at the script-bearing stages', () => { + const root = makeLocalRoot({ 'shortcuts.json': JSON.stringify(wEntries) }); + const registry = loadShortcutConfig(root); + expect(registry.lookupChordEntry(['w', 'r'], 'list', 'plan_complete')?.command) + .toBe('/skill:wiki-podcast-script --review '); + expect(registry.lookupChordEntry(['w', 's'], 'list', 'plan_complete')?.command) + .toBe('/skill:wiki-podcast-script '); + expect(registry.lookupChordEntry(['w', 'b'], 'list', 'plan_complete')?.command) + .toBe('/skill:wiki-podcast-script --review-rewrite '); + }); + + it('all three w sub-chords stay gated to podcast-typed items', () => { + const root = makeLocalRoot({ 'shortcuts.json': JSON.stringify(wEntries) }); + const registry = loadShortcutConfig(root); + for (const chord of [['w', 'r'], ['w', 's'], ['w', 'b']]) { + const entry = registry.lookupChordEntry(chord as ['w', 'r'], 'list'); + expect(entry?.workItemTypes).toEqual(['podcast']); + // Hidden on non-podcast types. + expect(registry.lookupChordEntry(chord as ['w', 'r'], 'list', undefined, false, 'bug')).toBeUndefined(); + } + }); +}); diff --git a/packages/herdr/src/shortcuts.json b/packages/herdr/src/shortcuts.json index bc726d00..c4edfa96 100644 --- a/packages/herdr/src/shortcuts.json +++ b/packages/herdr/src/shortcuts.json @@ -21,7 +21,7 @@ "label": "intake", "description": "Ensure that the selected item is reasonably well defined in terms of objectives.", "model": "plan", - "work_item_types": ["bug", "feature", "task", "chore", "epic"] + "work_item_types": ["bug", "docs", "feature", "task", "chore", "epic"] }, { "chord": [ @@ -35,7 +35,7 @@ "label": "plan", "description": "Run the plan workflow on the selected work item", "model": "plan", - "work_item_types": ["bug", "feature", "task", "chore", "epic"] + "work_item_types": ["bug", "docs", "feature", "task", "chore", "epic"] }, { "chord": [ @@ -52,7 +52,7 @@ "description": "Run the implement workflow on the selected work item", "model": "code", "code_freeze": "block", - "work_item_types": ["bug", "feature", "task", "chore", "epic"] + "work_item_types": ["bug", "docs", "feature", "task", "chore", "epic"] }, { "chord": [ @@ -72,6 +72,15 @@ "label": "Search", "description": "Search all workitems for keyword(s)." }, + { + "chord": [ + "S" + ], + "command": "/skill:ship release", + "view": "both", + "label": "ship it", + "description": "Run the dev-to-main release (ship skill)" + }, { "chord": [ "a", @@ -218,6 +227,17 @@ "description": "Open a new Pi agent pane asking for the audit gaps from the selected item's most recent audit.", "model": "plan" }, + { + "chord": [ + "P", + "n" + ], + "command": "/prompt:", + "view": "both", + "label": "new session", + "description": "Open a new Pi agent session with a blank prompt.", + "model": "plan" + }, { "chord": [ "f", diff --git a/packages/herdr/src/visibility.test.ts b/packages/herdr/src/visibility.test.ts index 4b57e4b5..85d0806b 100644 --- a/packages/herdr/src/visibility.test.ts +++ b/packages/herdr/src/visibility.test.ts @@ -2,6 +2,11 @@ * Unit tests for packages/herdr/src/visibility.ts — pane visibility * detection (isPaneVisible + PollGate). * + * Visibility signal: the pane is visible when its TAB is focused + * (`HERDR_TAB_ID` → `herdr tab get ` → `result.tab.focused`), + * regardless of which pane in the tab holds keyboard focus (multi-pane + * split bug, WL-0MSJNJPRM009RM35). No pane-focus fallback. + * * Run: npx vitest run packages/herdr/src/visibility.test.ts */ @@ -13,13 +18,13 @@ import { import { isPaneVisible, PollGate, DEFAULT_POLL_GATE_TTL_MS } from './visibility.js'; /** - * Build a herdr-style pane-get envelope. Hidden (non-focused) tabs report - * `result.pane.focused === false`. + * Build a herdr-style tab-get envelope. A hidden (non-focused) tab reports + * `result.tab.focused === false`. */ -function paneGetEnvelope(focused: boolean): string { +function tabGetEnvelope(focused: boolean): string { return JSON.stringify({ - id: 'cli:pane:get', - result: { pane: { focused } }, + id: 'cli:tab:get', + result: { tab: { focused } }, }); } @@ -28,28 +33,30 @@ describe('isPaneVisible', () => { beforeEach(() => { resetExecFileAsync(); + delete process.env.HERDR_TAB_ID; delete process.env.HERDR_PANE_ID; delete process.env.HERDR_BIN_PATH; }); afterEach(() => { resetExecFileAsync(); + process.env.HERDR_TAB_ID = originalEnv.HERDR_TAB_ID; process.env.HERDR_PANE_ID = originalEnv.HERDR_PANE_ID; process.env.HERDR_BIN_PATH = originalEnv.HERDR_BIN_PATH; }); - it('returns true when HERDR_PANE_ID is not set (fail-open, standalone mode)', async () => { - delete process.env.HERDR_PANE_ID; + it('returns true when HERDR_TAB_ID is not set (fail-open, standalone mode)', async () => { + delete process.env.HERDR_TAB_ID; const mockFn = vi.fn(); setExecFileAsync(mockFn as any); expect(await isPaneVisible()).toBe(true); - // No herdr exec at all when there is no pane context. + // No herdr exec at all when there is no tab context. expect(mockFn).not.toHaveBeenCalled(); }); it('returns true when the herdr CLI is missing (ENOENT)', async () => { - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; const mockFn = vi.fn().mockRejectedValue({ code: 'ENOENT' }); setExecFileAsync(mockFn as any); @@ -57,7 +64,7 @@ describe('isPaneVisible', () => { }); it('returns true when the herdr CLI exits non-zero', async () => { - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; const mockFn = vi.fn().mockRejectedValue(new Error('herdr: exit status 1')); setExecFileAsync(mockFn as any); @@ -65,42 +72,77 @@ describe('isPaneVisible', () => { }); it('returns true when the CLI returns unparseable output (fail-open)', async () => { - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; const mockFn = vi.fn().mockResolvedValue({ stdout: 'not json at all', stderr: '' }); setExecFileAsync(mockFn as any); expect(await isPaneVisible()).toBe(true); }); - it('returns false when herdr pane get reports result.pane.focused === false', async () => { - process.env.HERDR_PANE_ID = 'w1:pCM'; + it('returns false when herdr tab get reports result.tab.focused === false (tab not focused)', async () => { + process.env.HERDR_TAB_ID = 'w1:t11'; const mockFn = vi.fn().mockResolvedValue({ - stdout: paneGetEnvelope(false), + stdout: tabGetEnvelope(false), stderr: '', }); setExecFileAsync(mockFn as any); expect(await isPaneVisible()).toBe(false); - // The pane id is passed to the CLI. + // The tab id is passed to the CLI. + const callArgs = mockFn.mock.calls[0][1] as string[]; + expect(callArgs).toEqual(['tab', 'get', 'w1:t11']); + }); + + it('returns true when herdr tab get reports result.tab.focused === true (tab focused)', async () => { + process.env.HERDR_TAB_ID = 'w1:t11'; + const mockFn = vi.fn().mockResolvedValue({ + stdout: tabGetEnvelope(true), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + expect(await isPaneVisible()).toBe(true); + }); + + it('multi-pane bug case: tab focused + pane unfocused → visible', async () => { + // The bug (WL-0MSJNJPRM009RM35): in a split tab the worklist pane is + // NOT the keyboard-focused pane, so `herdr pane get` reports + // `focused: false` — yet the pane is visible on screen. The tab-focus + // signal (HERDR_TAB_ID + tab get) must decide visibility, so the pane + // keeps refreshing. Both env vars present mirrors the real environment; + // the pane id is set but must NOT be consulted (no pane-focus fallback). + process.env.HERDR_TAB_ID = 'w1:t11'; + process.env.HERDR_PANE_ID = 'w1:p41'; + const mockFn = vi.fn().mockResolvedValue({ + stdout: tabGetEnvelope(true), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + expect(await isPaneVisible()).toBe(true); + // Only the tab-get path is exercised — never a pane-get. const callArgs = mockFn.mock.calls[0][1] as string[]; - expect(callArgs).toEqual(['pane', 'get', 'w1:pCM']); + expect(callArgs).toEqual(['tab', 'get', 'w1:t11']); }); - it('returns true when herdr pane get reports result.pane.focused === true', async () => { - process.env.HERDR_PANE_ID = 'w1:pCM'; + it('multi-pane bug case: tab focused + pane focused → visible', async () => { + process.env.HERDR_TAB_ID = 'w1:t11'; + process.env.HERDR_PANE_ID = 'w1:p41'; const mockFn = vi.fn().mockResolvedValue({ - stdout: paneGetEnvelope(true), + stdout: tabGetEnvelope(true), stderr: '', }); setExecFileAsync(mockFn as any); expect(await isPaneVisible()).toBe(true); + const callArgs = mockFn.mock.calls[0][1] as string[]; + expect(callArgs).toEqual(['tab', 'get', 'w1:t11']); }); it('resolves the herdr binary from HERDR_BIN_PATH when set, else "herdr"', async () => { - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; const mockFn = vi.fn().mockResolvedValue({ - stdout: paneGetEnvelope(true), + stdout: tabGetEnvelope(true), stderr: '', }); setExecFileAsync(mockFn as any); @@ -120,11 +162,13 @@ describe('isPaneVisible', () => { describe('PollGate', () => { beforeEach(() => { resetExecFileAsync(); + delete process.env.HERDR_TAB_ID; delete process.env.HERDR_PANE_ID; }); afterEach(() => { resetExecFileAsync(); + delete process.env.HERDR_TAB_ID; delete process.env.HERDR_PANE_ID; }); @@ -171,10 +215,10 @@ describe('PollGate', () => { expect(check).toHaveBeenCalledTimes(1); }); - it('defaults to isPaneVisible and shares one herdr exec within the TTL', async () => { - process.env.HERDR_PANE_ID = 'w1:pCM'; + it('defaults to isPaneVisible and shares one tab-get exec within the TTL', async () => { + process.env.HERDR_TAB_ID = 'w1:t11'; const mockFn = vi.fn().mockResolvedValue({ - stdout: paneGetEnvelope(false), + stdout: tabGetEnvelope(false), stderr: '', }); setExecFileAsync(mockFn as any); @@ -182,7 +226,7 @@ describe('PollGate', () => { const gate = new PollGate(); expect(await gate.visible()).toBe(false); expect(await gate.visible()).toBe(false); - // refresh + sync ticks in one cycle share a single herdr pane get call. + // refresh + sync ticks in one cycle share a single herdr tab get call. expect(mockFn).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/herdr/src/visibility.ts b/packages/herdr/src/visibility.ts index 0473f392..8e2c5b82 100644 --- a/packages/herdr/src/visibility.ts +++ b/packages/herdr/src/visibility.ts @@ -1,18 +1,22 @@ /** * packages/herdr/src/visibility.ts — Pane visibility detection * - * Determines whether the current herdr pane is visible (focused) so the - * worklist TUI can pause auto-refresh/auto-sync when its tab is hidden. + * Determines whether the current herdr pane is visible so the worklist TUI + * can pause auto-refresh/auto-sync when its TAB is hidden. * - * Visibility signal: herdr sets `HERDR_PANE_ID` (plus `HERDR_TAB_ID` / + * Visibility signal: herdr sets `HERDR_TAB_ID` (plus `HERDR_PANE_ID` / * `HERDR_WORKSPACE_ID` / `HERDR_BIN_PATH`) for panes it spawns; a hidden - * (non-focused) tab reports `result.pane.focused === false` from - * `herdr pane get `. + * (non-focused) tab reports `result.tab.focused === false` from + * `herdr tab get `. Tab focus is the visibility signal — a pane is + * visible when its TAB is focused, regardless of which pane in the tab + * holds keyboard focus (multi-pane split fix, WL-0MSJNJPRM009RM35). There + * is NO pane-focus fallback (approved scope); a pane zoomed-over within a + * focused tab is therefore treated as visible (documented limitation). * - * Fail-open design: when visibility cannot be determined (no `HERDR_PANE_ID`, - * CLI missing/erroring, unparseable output) the pane is treated as visible so - * polling proceeds exactly as today. Standalone runs without herdr context - * are unaffected. + * Fail-open design: when visibility cannot be determined (no `HERDR_TAB_ID`, + * CLI missing/erroring, unparseable output) the pane is treated as visible + * so polling proceeds exactly as today. Standalone runs without herdr + * context are unaffected. */ import { getExecFileAsync } from './fetcher.js'; @@ -20,36 +24,36 @@ import { getExecFileAsync } from './fetcher.js'; // ── Constants ───────────────────────────────────────────────────────── /** Default TTL for the PollGate memoizer (ms). Refresh + sync ticks in one - * cycle share a single `herdr pane get` call. */ + * cycle share a single `herdr tab get` call. */ export const DEFAULT_POLL_GATE_TTL_MS = 2000; // ── isPaneVisible ───────────────────────────────────────────────────── /** - * Check whether the current herdr pane is visible (focused). + * Check whether the current herdr pane's tab is visible (focused). * * Returns true (fail-open) when: - * - `HERDR_PANE_ID` is not set (standalone mode — polling as today) + * - `HERDR_TAB_ID` is not set (standalone mode — polling as today) * - The herdr CLI is missing, exits non-zero, or returns unparseable output * - * Returns false only when `herdr pane get ` definitively reports - * `result.pane.focused === false`. + * Returns false only when `herdr tab get ` definitively reports + * `result.tab.focused === false`. * * Uses the injectable exec seam (setExecFileAsync) from fetcher.ts — no * real process spawns in tests. */ export async function isPaneVisible(): Promise { - const paneId = process.env.HERDR_PANE_ID; - if (!paneId) return true; + const tabId = process.env.HERDR_TAB_ID; + if (!tabId) return true; const bin = process.env.HERDR_BIN_PATH ?? 'herdr'; try { const execFileAsync = getExecFileAsync(); - const result = await execFileAsync(bin, ['pane', 'get', paneId], { + const result = await execFileAsync(bin, ['tab', 'get', tabId], { maxBuffer: 1024 * 1024, }); - const payload = extractPaneGetPayload(result.stdout); - const focused = payload?.result?.pane?.focused; + const payload = extractTabGetPayload(result.stdout); + const focused = payload?.result?.tab?.focused; if (typeof focused === 'boolean') return focused; return true; // unparseable / unexpected shape → fail open } catch { @@ -61,11 +65,11 @@ export async function isPaneVisible(): Promise { * Extract the JSON object from herdr CLI output. The CLI may prefix the * envelope with log lines, so scan for the first `{` like fetcher.ts. */ -function extractPaneGetPayload(raw: string): { result?: { pane?: { focused?: unknown } } } | null { +function extractTabGetPayload(raw: string): { result?: { tab?: { focused?: unknown } } } | null { const start = raw.indexOf('{'); if (start < 0) return null; try { - return JSON.parse(raw.slice(start)) as { result?: { pane?: { focused?: unknown } } }; + return JSON.parse(raw.slice(start)) as { result?: { tab?: { focused?: unknown } } }; } catch { return null; } @@ -77,7 +81,7 @@ function extractPaneGetPayload(raw: string): { result?: { pane?: { focused?: unk * A small TTL memoizer around a visibility check. * * Multiple auto-refresh/auto-sync ticks in one cycle share a single - * underlying check (e.g. one `herdr pane get` exec within the TTL). + * underlying check (e.g. one `herdr tab get` exec within the TTL). * Fails open: if the underlying check throws, `visible()` returns true * (polling continues) so transient herdr CLI errors never stall the TUI. */ diff --git a/packages/herdr/src/worklist-visibility.test.ts b/packages/herdr/src/worklist-visibility.test.ts index d566c9ca..76208e4c 100644 --- a/packages/herdr/src/worklist-visibility.test.ts +++ b/packages/herdr/src/worklist-visibility.test.ts @@ -3,10 +3,12 @@ * visibility-gated auto-refresh/auto-sync in the worklist TUI * (WL-0MSB446N4009FFT5). * - * Verifies that when the herdr pane is hidden (not focused), the + * Verifies that when the herdr pane's TAB is hidden (not focused), the * auto-refresh and auto-sync timers skip their ticks (zero fetcher / * `wl sync` invocations), while visible panes (and the fail-open path) * keep the existing cadence. Manual actions are never gated. + * Visibility signal: tab focus (HERDR_TAB_ID + `herdr tab get`), no + * pane-focus fallback (WL-0MSJNJPRM009RM35). * * Run: npx vitest run packages/herdr/src/worklist-visibility.test.ts */ @@ -36,6 +38,7 @@ vi.mock('./notify.js', () => ({ })); import { runWorklistTui } from './worklist.js'; +import { loadShortcutConfig } from './shortcut-config.js'; import { setExecFileAsync, resetExecFileAsync, getExecFileAsync } from './fetcher.js'; import { runSync } from './auto-sync.js'; import { showToast } from './notify.js'; @@ -85,6 +88,7 @@ beforeEach(() => { afterEach(() => { resetExecFileAsync(); + delete process.env.HERDR_TAB_ID; delete process.env.HERDR_PANE_ID; delete process.env.HERDR_BIN_PATH; vi.useRealTimers(); @@ -97,30 +101,30 @@ function tick(): Promise { } // --------------------------------------------------------------------------- -// Exec-mock helper: dispatch herdr pane-get vs wl CLI calls. +// Exec-mock helper: dispatch herdr tab-get vs wl CLI calls. // --------------------------------------------------------------------------- /** - * Build an execFileAsync mock that returns a herdr pane-get envelope for - * `herdr pane get` calls and a benign wl response for everything else. + * Build an execFileAsync mock that returns a herdr tab-get envelope for + * `herdr tab get` calls and a benign wl response for everything else. * - * @param paneFocused - The focused flag for `herdr pane get` responses. - * @param paneGetCalls - Optional array collecting pane-get call counts. + * @param tabFocused - The focused flag for `herdr tab get` responses. + * @param tabGetCalls - Optional array collecting tab-get call counts. */ function makeExecMock( - paneFocused: boolean | undefined, - paneGetCalls?: { count: number }, + tabFocused: boolean | undefined, + tabGetCalls?: { count: number }, ): Mock { return vi.fn(async (bin: string, args: string[]) => { - if (bin === 'herdr' && args[0] === 'pane' && args[1] === 'get') { - if (paneGetCalls) paneGetCalls.count += 1; - if (paneFocused === undefined) { - throw new Error('herdr: pane not found'); + if (bin === 'herdr' && args[0] === 'tab' && args[1] === 'get') { + if (tabGetCalls) tabGetCalls.count += 1; + if (tabFocused === undefined) { + throw new Error('herdr: tab not found'); } return { stdout: JSON.stringify({ - id: 'cli:pane:get', - result: { pane: { focused: paneFocused } }, + id: 'cli:tab:get', + result: { tab: { focused: tabFocused } }, }), stderr: '', }; @@ -133,10 +137,10 @@ function makeExecMock( }); } -/** Count how many pane-get execs were made through the mock. */ -function countPaneGetCalls(mockFn: Mock): number { +/** Count how many tab-get execs were made through the mock. */ +function countTabGetCalls(mockFn: Mock): number { return mockFn.mock.calls.filter( - (c) => c[0] === 'herdr' && c[1]?.[0] === 'pane' && c[1]?.[1] === 'get', + (c) => c[0] === 'herdr' && c[1]?.[0] === 'tab' && c[1]?.[1] === 'get', ).length; } @@ -153,7 +157,7 @@ async function quit(p: Promise): Promise { describe('worklist TUI visibility gating', () => { it('hidden pane: refreshTimer ticks do NOT invoke the fetcher (zero wl spawns)', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; setExecFileAsync(makeExecMock(false) as any); const fetcher = vi.fn().mockResolvedValue([]); @@ -175,7 +179,7 @@ describe('worklist TUI visibility gating', () => { it('hidden pane: syncTimer ticks do NOT invoke runSync or the fetcher', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; setExecFileAsync(makeExecMock(false) as any); const fetcher = vi.fn().mockResolvedValue([]); @@ -199,7 +203,7 @@ describe('worklist TUI visibility gating', () => { it('visible pane: refreshTimer and syncTimer ticks invoke the fetcher / runSync as today', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; setExecFileAsync(makeExecMock(true) as any); const fetcher = vi.fn().mockResolvedValue([]); @@ -226,9 +230,9 @@ describe('worklist TUI visibility gating', () => { await quit(p); }); - it('fail-open: no HERDR_PANE_ID env → ticks invoke the fetcher as today (standalone runs unaffected)', async () => { + it('fail-open: no HERDR_TAB_ID env → ticks invoke the fetcher as today (standalone runs unaffected)', async () => { vi.useFakeTimers(); - delete process.env.HERDR_PANE_ID; + delete process.env.HERDR_TAB_ID; setExecFileAsync(makeExecMock(undefined) as any); const fetcher = vi.fn().mockResolvedValue([]); @@ -249,7 +253,7 @@ describe('worklist TUI visibility gating', () => { it('fail-open: herdr CLI error → pane treated as visible, polling continues', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; setExecFileAsync(makeExecMock(undefined) as any); // CLI throws const fetcher = vi.fn().mockResolvedValue([]); @@ -268,13 +272,13 @@ describe('worklist TUI visibility gating', () => { await quit(p); }); - it('manual S (sync) is never gated — works even when the pane is hidden', async () => { + it('manual S (Ship It dialog) is never gated — works even when the pane is hidden', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; setExecFileAsync(makeExecMock(false) as any); const fetcher = vi.fn().mockResolvedValue([]); - const p = runWorklistTui(fetcher, [], undefined, { + const p = runWorklistTui(fetcher, [], loadShortcutConfig(), { autoRefresh: true, refreshIntervalMs: 30_000, autoSync: true, @@ -285,18 +289,22 @@ describe('worklist TUI visibility gating', () => { fetcher.mockClear(); mockRunSync.mockClear(); - // Hidden pane + manual S → sync still runs. + // Hidden pane + manual S → the Ship It confirmation dialog still opens: + // manual actions are never visibility-gated (WL-0MSGG5N5Z0074TLY). The + // manual-sync binding was removed, so NO `wl sync` is spawned. dataHandler?.(Buffer.from('S')); await vi.advanceTimersByTimeAsync(0); - expect(mockRunSync).toHaveBeenCalled(); - expect(mockShowToast).toHaveBeenCalledWith('Synced'); + const render = writes[writes.length - 1] ?? ''; + expect(render).toContain("Type 'ship' to confirm, Esc to cancel"); + expect(mockRunSync).not.toHaveBeenCalled(); + dataHandler?.(Buffer.from('\x1b')); // cancel the dialog await quit(p); }); it('manual actions are never gated — a fresh TUI load fetches items even while hidden', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; setExecFileAsync(makeExecMock(false) as any); // Initial data load (which happens regardless of visibility) still works @@ -313,11 +321,11 @@ describe('worklist TUI visibility gating', () => { await quit(p2); }); - it('PollGate TTL: refresh+sync ticks in one cycle share a single herdr pane get call', async () => { + it('PollGate TTL: refresh+sync ticks in one cycle share a single herdr tab get call', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; - const paneGetCalls = { count: 0 }; - setExecFileAsync(makeExecMock(true, paneGetCalls) as any); + process.env.HERDR_TAB_ID = 'w1:t11'; + const tabGetCalls = { count: 0 }; + setExecFileAsync(makeExecMock(true, tabGetCalls) as any); const fetcher = vi.fn().mockResolvedValue([]); const p = runWorklistTui(fetcher, [], undefined, { @@ -329,25 +337,25 @@ describe('worklist TUI visibility gating', () => { }); await vi.advanceTimersByTimeAsync(0); - const execsAfterStart = paneGetCalls.count; + const execsAfterStart = tabGetCalls.count; - // One refresh cycle (t=30s): exactly one pane-get for the refresh tick. + // One refresh cycle (t=30s): exactly one tab-get for the refresh tick. await vi.advanceTimersByTimeAsync(30_000); - const execsAfterRefresh = paneGetCalls.count; + const execsAfterRefresh = tabGetCalls.count; expect(execsAfterRefresh - execsAfterStart).toBe(1); // One sync cycle boundary (t=60s): refresh + sync fire together; the // sync tick reuses the refresh tick's cached result (TTL) → no extra exec. await vi.advanceTimersByTimeAsync(30_000); - expect(paneGetCalls.count - execsAfterRefresh).toBe(1); - expect(paneGetCalls.count - execsAfterStart).toBeLessThanOrEqual(3); + expect(tabGetCalls.count - execsAfterRefresh).toBe(1); + expect(tabGetCalls.count - execsAfterStart).toBeLessThanOrEqual(3); await quit(p); }); it('header shows the [paused — hidden] indicator when the pane is hidden', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; setExecFileAsync(makeExecMock(false) as any); const fetcher = vi.fn().mockResolvedValue([]); @@ -376,7 +384,7 @@ describe('worklist TUI visibility gating', () => { describe('worklist TUI visibility gating — getExecFileAsync seam', () => { it('visibility checks go through the same injectable exec seam as the fetcher', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; const mockFn = makeExecMock(false); setExecFileAsync(mockFn as any); @@ -388,12 +396,12 @@ describe('worklist TUI visibility gating — getExecFileAsync seam', () => { showHelpText: false, }); await vi.advanceTimersByTimeAsync(0); - // Fire the refresh tick so the gate runs its pane-get check. + // Fire the refresh tick so the gate runs its tab-get check. await vi.advanceTimersByTimeAsync(30_000); - // The gate's pane-get check must have gone through getExecFileAsync(). + // The gate's tab-get check must have gone through getExecFileAsync(). expect(getExecFileAsync).toBeDefined(); - expect(countPaneGetCalls(mockFn)).toBeGreaterThanOrEqual(1); + expect(countTabGetCalls(mockFn)).toBeGreaterThanOrEqual(1); await quit(p); }); @@ -401,19 +409,19 @@ describe('worklist TUI visibility gating — getExecFileAsync seam', () => { describe('worklist TUI visibility gating — hidden → visible transition (WL-0MSBVS4AS006ZQEZ)', () => { /** - * Exec mock with a dynamic focused flag (read per pane-get call), so a - * test can flip the pane from hidden to visible mid-run. + * Exec mock with a dynamic focused flag (read per tab-get call), so a + * test can flip the tab from hidden to visible mid-run. */ - function makeDynamicExecMock(getFocused: () => boolean | undefined, paneGetCalls?: { count: number }): Mock { + function makeDynamicExecMock(getFocused: () => boolean | undefined, tabGetCalls?: { count: number }): Mock { return vi.fn(async (bin: string, args: string[]) => { - if (bin === 'herdr' && args[0] === 'pane' && args[1] === 'get') { - if (paneGetCalls) paneGetCalls.count += 1; + if (bin === 'herdr' && args[0] === 'tab' && args[1] === 'get') { + if (tabGetCalls) tabGetCalls.count += 1; const focused = getFocused(); if (focused === undefined) { - throw new Error('herdr: pane not found'); + throw new Error('herdr: tab not found'); } return { - stdout: JSON.stringify({ id: 'cli:pane:get', result: { pane: { focused } } }), + stdout: JSON.stringify({ id: 'cli:tab:get', result: { tab: { focused } } }), stderr: '', }; } @@ -426,9 +434,9 @@ describe('worklist TUI visibility gating — hidden → visible transition (WL-0 it('hidden → visible transition triggers an immediate fetch outside the normal tick', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; - let paneFocused = false; - setExecFileAsync(makeDynamicExecMock(() => paneFocused) as any); + process.env.HERDR_TAB_ID = 'w1:t11'; + let tabFocused = false; + setExecFileAsync(makeDynamicExecMock(() => tabFocused) as any); const fetcher = vi.fn().mockResolvedValue([]); const p = runWorklistTui(fetcher, [], undefined, { @@ -440,24 +448,24 @@ describe('worklist TUI visibility gating — hidden → visible transition (WL-0 await vi.advanceTimersByTimeAsync(0); fetcher.mockClear(); - // First refresh tick while hidden: skipped, pane pauses. + // First refresh tick while hidden: skipped, tab pauses. await vi.advanceTimersByTimeAsync(30_000); expect(fetcher).not.toHaveBeenCalled(); - // Pane regains focus → the resume poll catches the transition and the + // Tab regains focus → the resume poll catches the transition and the // list re-fetches immediately (t=32s, outside the 30s tick at t=60s). - paneFocused = true; + tabFocused = true; await vi.advanceTimersByTimeAsync(2_000); expect(fetcher).toHaveBeenCalledTimes(1); await quit(p); }); - it('while hidden the resume poll spawns only herdr pane get — never the fetcher', async () => { + it('while hidden the resume poll spawns only herdr tab get — never the fetcher', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; - const paneGetCalls = { count: 0 }; - setExecFileAsync(makeDynamicExecMock(() => false, paneGetCalls) as any); + process.env.HERDR_TAB_ID = 'w1:t11'; + const tabGetCalls = { count: 0 }; + setExecFileAsync(makeDynamicExecMock(() => false, tabGetCalls) as any); const fetcher = vi.fn().mockResolvedValue([]); const p = runWorklistTui(fetcher, [], undefined, { @@ -471,20 +479,20 @@ describe('worklist TUI visibility gating — hidden → visible transition (WL-0 // Hidden tick starts the resume poll; run several polls while still hidden. await vi.advanceTimersByTimeAsync(30_000); - const execsAfterFirstTick = paneGetCalls.count; + const execsAfterFirstTick = tabGetCalls.count; await vi.advanceTimersByTimeAsync(10_000); expect(fetcher).not.toHaveBeenCalled(); - expect(paneGetCalls.count).toBeGreaterThan(execsAfterFirstTick); // pane-get polls only + expect(tabGetCalls.count).toBeGreaterThan(execsAfterFirstTick); // tab-get polls only await quit(p); }); it('after the immediate transition refresh, refreshes follow the normal refreshIntervalMs cadence', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; - let paneFocused = false; - setExecFileAsync(makeDynamicExecMock(() => paneFocused) as any); + process.env.HERDR_TAB_ID = 'w1:t11'; + let tabFocused = false; + setExecFileAsync(makeDynamicExecMock(() => tabFocused) as any); const fetcher = vi.fn().mockResolvedValue([]); const p = runWorklistTui(fetcher, [], undefined, { @@ -502,7 +510,7 @@ describe('worklist TUI visibility gating — hidden → visible transition (WL-0 expect(fetcher).not.toHaveBeenCalled(); // Become visible: immediate fetch via the resume poll (t=62s). - paneFocused = true; + tabFocused = true; await vi.advanceTimersByTimeAsync(2_000); expect(fetcher).toHaveBeenCalledTimes(1); @@ -519,9 +527,9 @@ describe('worklist TUI visibility gating — hidden → visible transition (WL-0 it('the [paused — hidden] header indicator clears once the transition refresh completes', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; - let paneFocused = false; - setExecFileAsync(makeDynamicExecMock(() => paneFocused) as any); + process.env.HERDR_TAB_ID = 'w1:t11'; + let tabFocused = false; + setExecFileAsync(makeDynamicExecMock(() => tabFocused) as any); const fetcher = vi.fn().mockResolvedValue([]); const p = runWorklistTui(fetcher, [], undefined, { @@ -532,7 +540,7 @@ describe('worklist TUI visibility gating — hidden → visible transition (WL-0 }); await vi.advanceTimersByTimeAsync(0); - // Hidden tick pauses the pane; a navigation render shows the indicator. + // Hidden tick pauses the tab; a navigation render shows the indicator. await vi.advanceTimersByTimeAsync(30_000); writes.length = 0; dataHandler?.(Buffer.from('j')); @@ -540,7 +548,7 @@ describe('worklist TUI visibility gating — hidden → visible transition (WL-0 expect(writes.join('')).toContain('[paused — hidden]'); // Regain focus: the immediate transition refresh re-renders without it. - paneFocused = true; + tabFocused = true; writes.length = 0; await vi.advanceTimersByTimeAsync(2_000); expect(fetcher).toHaveBeenCalledTimes(1); diff --git a/packages/herdr/src/worklist.test.ts b/packages/herdr/src/worklist.test.ts index 9d817c5d..50f81b93 100644 --- a/packages/herdr/src/worklist.test.ts +++ b/packages/herdr/src/worklist.test.ts @@ -34,9 +34,10 @@ import { formatChordHintsForHelp, resolvePodcastTarget, } from './worklist.js'; +import type { ChordState } 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 { loadShortcutConfig, ShortcutRegistry, type ShortcutEntry } from './shortcut-config.js'; import { regroupWorkItems, extractFilePaths } from './grouping.js'; import { setWorklogDir, resetWorklogDir, setExecFileAsync, resetExecFileAsync, type WorkItem } from './fetcher.js'; @@ -672,6 +673,44 @@ describe('dispatchChordCommand', () => { const result = dispatchChordCommand('unknown command', state); expect(result).toBe(false); }); + + it('routes /skill:ship release through onCommand with no substitution (WL-0MSGG5N5Z0074TLY)', () => { + const state = new WorkItemListState([makeItem('WL-TEST-1')], TERM_80x24); + state.selectedIndex = 0; + const onCommand = vi.fn(); + const result = dispatchChordCommand('/skill:ship release', state, onCommand); + expect(result).toBe(true); + // The release command is a global dev→main release — the command is + // routed verbatim, never rewritten with the selected item's id. + expect(onCommand).toHaveBeenCalledWith('/skill:ship release', undefined); + expect(onCommand).toHaveBeenCalledTimes(1); + }); + + it('routes /skill:ship release even with no item selected (global command)', () => { + const state = new WorkItemListState([], TERM_80x24); + const onCommand = vi.fn(); + const result = dispatchChordCommand('/skill:ship release', state, onCommand); + expect(result).toBe(true); + expect(onCommand).toHaveBeenCalledWith('/skill:ship release', undefined); + }); + + it('executeResolvedCommand dispatches /skill:ship release via the standard path', () => { + const state = new WorkItemListState([makeItem('WL-TEST-1')], TERM_80x24); + state.selectedIndex = 0; + const onCommand = vi.fn(); + const result = executeResolvedCommand('/skill:ship release', state, onCommand); + expect(result).toBe('dispatched'); + expect(onCommand).toHaveBeenCalledWith('/skill:ship release', undefined); + }); + + it('does not block /skill:ship release during a Code Freeze (ship skill gates itself)', () => { + const state = new WorkItemListState([makeItem('WL-TEST-1')], TERM_80x24); + state.selectedIndex = 0; + const onCommand = vi.fn(); + const result = executeResolvedCommand('/skill:ship release', state, onCommand, true); + expect(result).toBe('dispatched'); + expect(onCommand).toHaveBeenCalledWith('/skill:ship release', undefined); + }); }); describe('fetchItemsForView — stage-filtered fetch', () => { @@ -699,6 +738,35 @@ describe('fetchItemsForView — stage-filtered fetch', () => { expect(callArgs).toContain('--root-only'); }); + it('fetches completed/in-progress items for the in_review stage', async () => { + // in_review items carry status completed (submitted for review) or + // in-progress (being re-worked after review feedback) per the project + // workflow — restricting to status=open would empty the review queue + // (WL-0MSKCRX730052IIW). + const stageItems = [ + { ...makeItem('A', 'in_review'), status: 'completed' }, + { ...makeItem('B', 'in_review'), status: 'in-progress' }, + ]; + 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('in_review', 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'); + const statusArg = callArgs[callArgs.indexOf('--status') + 1]; + expect(statusArg).toContain('completed'); + expect(statusArg).toContain('in-progress'); + expect(callArgs[callArgs.indexOf('--stage') + 1]).toBe('in_review'); + 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); @@ -714,6 +782,20 @@ describe('fetchItemsForView — stage-filtered fetch', () => { const items = await fetchItemsForView('idea', defaultFetcher); expect(items.map((i) => i.id)).toEqual(['C']); }); + + it('applies the in_review stage filter client-side regardless of status', () => { + // Client-side filter (_applyFilters) matches on stage only — items are + // already status-filtered at fetch time, so completed/in-progress + // in_review items must survive the client-side pass (WL-0MSKCRX730052IIW). + const items = [ + { ...makeItem('A', 'in_review'), status: 'completed' }, + { ...makeItem('B', 'in_review'), status: 'in-progress' }, + makeItem('C', 'idea'), + ]; + const state = new WorkItemListState(items, TERM_80x24); + state.applyFilter('in_review'); + expect(state.getFlattenedItems().map((i) => i.id)).toEqual(['A', 'B']); + }); }); describe('Chord-complete error notification handling', () => { @@ -876,10 +958,10 @@ describe('issue-type shortcut filtering — worklist integration', () => { registry = loadShortcutConfig(); }); - it('treats i as a chord leader only for code item types', () => { + it('treats i as a chord leader only for code and docs item types', () => { expect(isChordLeader('i', registry, false, 'feature')).toBe(true); + expect(isChordLeader('i', registry, false, 'docs')).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', () => { @@ -1051,6 +1133,101 @@ describe('resolvePodcastTarget — podcast-progression dispatch', () => { const result = await resolvePodcastTarget('/skill:wiki-tts-generate --podcast-file ', synthItem); expect(result.error).toMatch(/no podcast script/i); }); + + it('resolves to the raw script path (w-r write-review chord)', async () => { + const result = await resolvePodcastTarget('/skill:wiki-podcast-script --review ', draftedItem); + expect(result).toEqual({ command: '/skill:wiki-podcast-script --review foo/foo.podcast.md' }); + }); + + it('belt-and-braces: errors on when no script is in Key Files', async () => { + const result = await resolvePodcastTarget('/skill:wiki-podcast-script --review ', sourcedItem); + expect(result.error).toMatch(/no podcast script/i); + expect(result.command).toBeUndefined(); + }); + + it('resolves to the raw script path (w-b write-both chord)', async () => { + const result = await resolvePodcastTarget('/skill:wiki-podcast-script --review-rewrite ', draftedItem); + expect(result).toEqual({ command: '/skill:wiki-podcast-script --review-rewrite foo/foo.podcast.md' }); + }); + + it('belt-and-braces: errors on when no script is in Key Files', async () => { + const result = await resolvePodcastTarget('/skill:wiki-podcast-script --review-rewrite ', sourcedItem); + expect(result.error).toMatch(/no podcast script/i); + expect(result.command).toBeUndefined(); + }); + + it('resolves review markers on wiki-dir-relative script paths as-is (raw form)', async () => { + const wikiItem: WorkItem = { ...draftedItem, description: '## Key Files:\n- wiki/podcast/foo/foo.podcast.md\n' }; + const result = await resolvePodcastTarget('/skill:wiki-podcast-script --review ', wikiItem); + expect(result).toEqual({ command: '/skill:wiki-podcast-script --review wiki/podcast/foo/foo.podcast.md' }); + }); + + it('returns the command unchanged for a command with no podcast markers (review marker absent)', async () => { + const result = await resolvePodcastTarget('/skill:wiki-podcast-script --review foo/foo.podcast.md', draftedItem); + expect(result).toEqual({ command: '/skill:wiki-podcast-script --review foo/foo.podcast.md' }); + }); +}); + +// ── w chord leader: sub-chord hints ────────────────────────────────────── +// The `w` single-key write-script chord is split into w-r/w-s/w-b sub-chords +// (OSL-0MSKVB5K6008XFOQ). Pressing `w` must collapse to `w:write...` and +// expand to the per-sub-chord hints via the existing formatChordHintsForHelp +// machinery, respecting per-stage visibility. + +describe('w chord leader — sub-chord hints and stage gating', () => { + const wChords: ShortcutEntry[] = [ + { + chord: ['w', 'r'], + command: '/skill:wiki-podcast-script --review ', + view: 'both', + label: 'write review', + stages: ['plan_complete', 'in_review', 'done'], + }, + { + chord: ['w', 's'], + command: '/skill:wiki-podcast-script ', + view: 'both', + label: 'write script', + stages: ['intake_complete', 'plan_complete', 'in_review', 'done'], + }, + { + chord: ['w', 'b'], + command: '/skill:wiki-podcast-script --review-rewrite ', + view: 'both', + label: 'write both', + stages: ['plan_complete', 'in_review', 'done'], + }, + ]; + + it('shows w as a chord leader hint in the footer (collapsed)', () => { + const registry = new ShortcutRegistry([...wChords]); + const hints = getChordHelpHints(registry); + expect(hints).toContain('[w] chords'); + }); + + it('expands to r/s/b sub-chord hints at a script-bearing stage', () => { + const registry = new ShortcutRegistry([...wChords]); + const nextChords = registry.getChordByPrefix(['w'], 'list', 'plan_complete'); + const hints = formatChordHintsForHelp(nextChords, ['w']); + expect(hints).toContain('r:review'); + expect(hints).toContain('s:script'); + expect(hints).toContain('b:both'); + }); + + it('shows only the s sub-chord at intake_complete (w-r/w-b hidden)', () => { + const registry = new ShortcutRegistry([...wChords]); + const nextChords = registry.getChordByPrefix(['w'], 'list', 'intake_complete'); + const hints = formatChordHintsForHelp(nextChords, ['w']); + expect(hints).toContain('s:script'); + expect(hints).not.toContain('r:review'); + expect(hints).not.toContain('b:both'); + }); + + it('hides all w sub-chords at idea (no stage gate matches)', () => { + const registry = new ShortcutRegistry([...wChords]); + const nextChords = registry.getChordByPrefix(['w'], 'list', 'idea'); + expect(nextChords).toHaveLength(0); + }); }); // ── Code Freeze: banner rendering ──────────────────────────────────────── @@ -1114,6 +1291,228 @@ describe('createListRenderer — code freeze banner', () => { ); expect(output.split('\n').length).toBeLessThanOrEqual(TERM_80x24.rows - 1); }); + + it('renders the Ambiguous Codefreeze marker banner when the marker is ambiguous', () => { + const output = renderer( + [makeItem('A')], + 0, + 0, + TERM_80x24, + null, + 'list', + null, + undefined, + null, + 0, + false, + undefined, + undefined, + 0, + false, + false, // codeFreezeActive (fail-open: browsing stays unblocked) + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + true, // showHelpText + true, // codeFreezeAmbiguous + ); + expect(output).toContain('Ambiguous Codefreeze marker'); + // The ambiguous banner is distinct from the active-freeze banner: an + // ambiguous marker must NOT show the red CODE FREEZE banner (browsing + // and shortcut blocking keep their fail-open semantics). + expect(output).not.toContain('CODE FREEZE'); + }); + + it('does not render the ambiguous banner when the marker is unambiguous (default)', () => { + const output = renderer([makeItem('A')], 0, 0, TERM_80x24, null, 'list', null); + expect(output).not.toContain('Ambiguous Codefreeze marker'); + }); + + it('keeps the rows - 1 line-count invariant with the ambiguous banner', () => { + const grouped: WorkItem[] = Array.from({ length: 30 }, (_, i) => ({ + ...makeItem(`G${i}`), + group: i, + groupLabel: `Group ${i}`, + })); + const output = renderer( + grouped, + 0, + 0, + TERM_80x24, + null, + 'list', + null, + undefined, + null, + 0, + false, + undefined, + undefined, + 0, + false, + false, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + true, + true, // codeFreezeAmbiguous + ); + expect(output.split('\n').length).toBeLessThanOrEqual(TERM_80x24.rows - 1); + }); +}); + +// ── Chord-mode footer gating (WL-0MSGJDSMJ004128E) ───────────────────── +// The chord-in-progress footer (`chord: _ `) must be gated by +// `showHelpText` like the normal shortcut hint line, so `showHelpText: false` +// hides ALL shortcut hint lines (consistent with the pi browse widget). The +// gating must only affect rendering — chord state accumulation is untouched. + +describe('createListRenderer — chord-mode footer gating', () => { + const renderer = createListRenderer(); + + function chordStateWithPending(keys: string[], hints = 'update ...'): ChordState { + const state = createChordState(); + state.pendingKeys = keys; + state.hints = hints; + return state; + } + + it('suppresses the chord footer when showHelpText is false', () => { + const output = renderer( + [makeItem('A')], + 0, + 0, + TERM_80x24, + null, + 'list', + null, + undefined, + chordStateWithPending(['u']), + 0, + false, + undefined, + undefined, + 0, + false, + false, + 0, + undefined, + undefined, + undefined, + 0, + true, + 0, + false, // showHelpText + ); + expect(output).not.toContain('chord:'); + expect(output).not.toContain('update ...'); + }); + + it('renders the chord footer when showHelpText is true', () => { + const output = renderer( + [makeItem('A')], + 0, + 0, + TERM_80x24, + null, + 'list', + null, + undefined, + chordStateWithPending(['u']), + 0, + false, + undefined, + undefined, + 0, + false, + false, + 0, + undefined, + undefined, + undefined, + 0, + true, + 0, + true, // showHelpText + ); + expect(output).toContain('chord:'); + expect(output).toContain('u _'); + expect(output).toContain('update ...'); + }); + + it('renders the chord footer when showHelpText is unset (default true)', () => { + // Backwards compatibility: existing positional callers that do not pass + // the trailing showHelpText argument keep the chord footer visible. + const output = renderer( + [makeItem('A')], + 0, + 0, + TERM_80x24, + null, + 'list', + null, + undefined, + chordStateWithPending(['u']), + 0, + false, + undefined, + undefined, + 0, + false, + false, + 0, + undefined, + undefined, + undefined, + 0, + true, + 0, + ); + expect(output).toContain('chord:'); + expect(output).toContain('u _'); + }); + + it('does not mutate chord state while rendering with showHelpText false', () => { + // The fix gates rendering only; the chord key handling state machine must + // keep accumulating even when the footer is hidden (WL-0MSGJDSMJ004128E). + const chordState = chordStateWithPending(['u', 'c']); + renderer( + [makeItem('A')], + 0, + 0, + TERM_80x24, + null, + 'list', + null, + undefined, + chordState, + 0, + false, + undefined, + undefined, + 0, + false, + false, + 0, + undefined, + undefined, + undefined, + 0, + true, + 0, + false, // showHelpText + ); + expect(chordState.pendingKeys).toEqual(['u', 'c']); + expect(chordState.hints).toBe('update ...'); + }); }); // ── Code Freeze: dialog rendering ──────────────────────────────────────── @@ -1245,6 +1644,71 @@ describe('formatMetadataPanel — field rendering and scrolling', () => { }); }); +describe('formatMetadataPanel — description preview (WL-0MSFZKQL700381P3)', () => { + it('renders a Description section for items with a description', () => { + const item = { ...makeRichItem(), description: '# Fix the bug\n\nMake it work better.' }; + const joined = formatMetadataPanel(item, 80, 30, 0).join('\n'); + expect(joined).toContain('Description'); + expect(joined).toContain('# Fix the bug'); + expect(joined).toContain('Make it work better.'); + }); + + it('omits the preview when the description is missing or empty', () => { + const missing = formatMetadataPanel(makeRichItem(), 80, 20, 0).join('\n'); + expect(missing).not.toContain('Description'); + const blank = formatMetadataPanel({ ...makeRichItem(), description: ' \n\n ' }, 80, 20, 0).join('\n'); + expect(blank).not.toContain('Description'); + }); + + it('shows at most the first 3 non-empty description lines', () => { + const item = { ...makeRichItem(), description: ['line 1', '', 'line 2', 'line 3', 'line 4'].join('\n') }; + const joined = formatMetadataPanel(item, 80, 30, 0).join('\n'); + expect(joined).toContain('line 1'); + expect(joined).toContain('line 2'); + expect(joined).toContain('line 3'); + expect(joined).not.toContain('line 4'); + }); + + it('truncates long description lines to the panel width', () => { + const item = { ...makeRichItem(), description: 'x'.repeat(200) }; + const lines = formatMetadataPanel(item, 40, 30, 0); + for (const line of lines) { + expect(line.replace(/\x1b\[[0-9;]*m/g, '').length).toBeLessThanOrEqual(40); + } + }); + + it('tolerates markdown fences and long lines without corrupting rendering', () => { + const description = '```\nconst x = "y";\n```\n\nnormal line'; + const item = { ...makeRichItem(), description }; + const lines = formatMetadataPanel(item, 40, 30, 0); + const joined = lines.join('\n'); + expect(joined).toContain('```'); + expect(joined).toContain('const x = "y";'); + for (const line of lines) { + expect(line.replace(/\x1b\[[0-9;]*m/g, '').length).toBeLessThanOrEqual(40); + } + }); + + it('places the preview after the metadata rows and before the last-command line', () => { + const item = { ...makeRichItem(), description: 'The description body' }; + const joined = formatMetadataPanel(item, 80, 30, 0, '/skill:audit WL-RICH1').join('\n'); + expect(joined.indexOf('Title')).toBeGreaterThanOrEqual(0); + expect(joined.indexOf('Title')).toBeLessThan(joined.indexOf('Description')); + expect(joined.indexOf('Description')).toBeLessThan(joined.indexOf('Last command:')); + }); + + it('scrolls with the rest of the panel content', () => { + const item = { ...makeRichItem(), description: ['p1', 'p2', 'p3'].join('\n') }; + const top = formatMetadataPanel(item, 80, 3, 0).join('\n'); + expect(top).not.toContain('p1'); + const previewView = formatMetadataPanel(item, 80, 3, 19).join('\n'); + expect(previewView).toContain('p1'); + expect(previewView).toContain('p3'); + // [m/M scroll] indicator still shown when content overflows + expect(previewView).toContain('[m/M scroll'); + }); +}); + describe('createListRenderer — metadata panel in list mode', () => { const renderer = createListRenderer(); @@ -1405,6 +1869,28 @@ describe('handleKeypress — metadata scroll keys', () => { }); }); +// ── Ship It / manual-sync removal (WL-0MSGG5N5Z0074TLY) ──────────────── +// The manual `wl sync` binding on `S` is removed; `S` now resolves through +// the ShortcutRegistry (Ship It dialog) like every other shortcut, and stays +// distinct from lowercase `s` (Search). + +describe('keyToAction — manual sync binding removed', () => { + it('does not map S to the sync action anymore', () => { + const state = new WorkItemListState([makeItem('A')], TERM_80x24); + // No registry passed: S is not a navigation key, so handleKeypress + // must return null (previously 'sync'). + expect(handleKeypress(state, 'S', TERM_80x24)).toBeNull(); + }); + + it('keeps s and other navigation keys unchanged', () => { + const state = new WorkItemListState([makeItem('A')], TERM_80x24); + // s is not a navigation key either (resolved via registry → Search form). + expect(handleKeypress(state, 's', TERM_80x24)).toBeNull(); + expect(handleKeypress(state, 'q', TERM_80x24)).toBe('quit'); + expect(handleKeypress(state, 'j', TERM_80x24)).toBe('down'); + }); +}); + // ── Last-command display (WL-0MSEPP1DE00285TQ-FT6) ─────────────────────── // The metadata panel shows the most recent recorded command when the selected // item's stage is in_progress, hidden otherwise, with a graceful fallback. diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index 3fe78df5..648aae84 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -15,7 +15,7 @@ import { dirname, join, resolve as resolvePath } from 'node:path'; 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 { readCodeFreezeState, readCodeFreezeStatus } from './code-freeze.js'; import type { ShortcutRegistry, ShortcutEntry } from './shortcut-config.js'; import { statusIcon, @@ -39,11 +39,20 @@ import { FormState, substituteIdentifiers, } from './form-dialog.js'; +import { ShipItDialogState, overlayShipItDialog } from './ship-it-dialog.js'; import { extractFilePaths } from './grouping.js'; -import { renderMarkdownViewer, renderNoteLinks } from './md-viewer.js'; +import { renderMarkdown, renderMarkdownViewer } from './md-viewer.js'; // ── Constants ───────────────────────────────────────────────────────── +/** + * The dev→main release command dispatched by the Ship It shortcut (`S`, + * WL-0MSGG5N5Z0074TLY). Global release — receives NO work item id. The + * command must be typed as `ship` + Enter in the confirmation dialog + * before it is dispatched via the standard command-routing path. + */ +export const SHIP_IT_COMMAND = '/skill:ship release'; + export const STAGES = [ 'idea', 'intake_complete', @@ -910,13 +919,65 @@ export function buildMetaRows(item: WorkItem, noIcons = false): Array<[string, s return metaRows; } +/** + * Maximum number of description preview lines shown in the metadata panel. + */ +const DESCRIPTION_PREVIEW_MAX_LINES = 3; + +/** + * Build the description preview lines for the metadata panel. + * + * Returns up to {@link DESCRIPTION_PREVIEW_MAX_LINES} non-empty lines from the + * item's description, each truncated to `maxCols`. The preview starts with a + * dimmed `Description` heading row. Returns an empty array when the description + * is missing or blank. + * + * @param description - The work item's description text. + * @param maxCols - Terminal width for truncation. + * @returns Preview lines ready to insert into the metadata panel. + */ +function buildDescriptionPreview( + description: string | undefined | null, + maxCols: number, +): string[] { + if (!description || description.trim() === '') { + return []; + } + + const preview: string[] = []; + + // Heading row + preview.push(` ${ANSI.dim}${ANSI.underline}Description${ANSI.reset}`); + + // First up-to-3 non-empty lines, so blank separator lines between markdown + // sections don't waste the limited preview space (WL-0MSFZKQL700381P3). + const lines = description.split('\n'); + let shown = 0; + for (const line of lines) { + if (shown >= DESCRIPTION_PREVIEW_MAX_LINES) break; + if (line.trim() === '') continue; + preview.push(` ${line}`); + shown += 1; + } + + // Truncate to fit the terminal width + for (let i = 0; i < preview.length; i++) { + if (preview[i].length > 0) { + preview[i] = truncateLine(preview[i], maxCols); + } + } + + return preview; +} + /** * Format the metadata panel shown below the selection list. * - * Renders the selected item's fields (via {@link buildMetaRows}) plus a last - * command line when the item's stage is `in_progress`. The panel scrolls - * independently with its own offset: when the content is taller than the - * panel, `metaScrollOffset` selects the visible window and a `[m/M scroll]` + * Renders the selected item's fields (via {@link buildMetaRows}) plus a + * description preview (up to 3 lines) and a last command line when the item's + * stage is `in_progress`. The panel scrolls independently with its own offset: + * when the content is taller than the panel, + * `metaScrollOffset` selects the visible window and a `[m/M scroll]` * indicator is appended to the last line. * * @param item - Selected work item (or null for an empty panel). @@ -956,6 +1017,13 @@ export function formatMetadataPanel( } } + // Description preview — first few lines of the item's description so the + // user can see what the item is about without opening the detail view + // (WL-0MSFZKQL700381P3). Shown as-is (markdown source lines), placed after + // the metadata rows and before the last-command line. + const preview = buildDescriptionPreview(item.description, maxCols); + lines.push(...preview); + // Last command — only meaningful while the item is being worked on if (item.stage === 'in_progress') { if (lastCommand) { @@ -1092,25 +1160,18 @@ export function formatDetailContent( lines.push(''); lines.push(` ${ANSI.underline}Description${ANSI.reset}`); lines.push(''); - const descLines = item.description.split('\n'); + // Render the description as GFM (tables, bold/italic, inline code, + // links, lists, headings) via the shared markdown renderer; NOTE + // markers render as `↗` links inside the rendered output. Wrap to + // the content width minus the 2-space indent. + const indent = 2; + const wrapWidth = contentWidth - indent - 2; + const descLines = renderMarkdown(item.description, Math.max(wrapWidth, 20)); for (const dl of descLines) { - // Wrap long lines to fit width; NOTE markers render as links. - const indent = 2; - const wrapWidth = contentWidth - indent - 2; - const linked = renderNoteLinks(dl); - if (linked.length > wrapWidth && wrapWidth > 10) { - let remaining = linked; - while (remaining.length > 0) { - const seg = remaining.slice(0, wrapWidth); - remaining = remaining.slice(wrapWidth); - lines.push(` ${seg}`); - } - } else { - lines.push(` ${linked}`); - } + lines.push(` ${dl}`); // Limit total lines if (lines.length > 500) { - lines.push(` ... (truncated, ${descLines.length} total description lines)`); + lines.push(` ... (truncated, ${item.description.split('\n').length} total description lines)`); break; } } @@ -1459,7 +1520,7 @@ export function getChordHelpHints(registry: ShortcutRegistry | undefined, codeFr // ── Keyboard handling ───────────────────────────────────────────────── export type KeyAction = 'up' | 'down' | 'pageup' | 'pagedown' | 'select' - | 'back' | 'filter' | 'refresh' | 'sync' | 'quit' | 'first' | 'last' + | 'back' | 'filter' | 'refresh' | 'quit' | 'first' | 'last' | 'meta-up' | 'meta-down' | 'chord-start' | 'chord-complete' | 'chord-cancel' | 'toggle-expand' | null; @@ -1504,14 +1565,15 @@ export function keyToAction(key: string): KeyAction { return 'back'; // '/' filter prompt removed — use f-* chords instead // 'r' is a single-key Producer Review shortcut — resolved via ShortcutRegistry + // 'S' is the Ship It shortcut — resolved via ShortcutRegistry (opens the + // typed-confirmation dialog, WL-0MSGG5N5Z0074TLY). The manual-sync 'S' + // binding was removed; auto-sync on the timer is unaffected. case 'q': return 'quit'; case 'g': return 'first'; case 'G': return 'last'; - case 'S': - return 'sync'; case 'm': return 'meta-down'; case 'M': @@ -1797,6 +1859,8 @@ export function createListRenderer(getShowIcons?: () => boolean): ( detailToCIndex?: number, detailToCFocus?: boolean, detailRenderedIndex?: number, + showHelpText?: boolean, + codeFreezeAmbiguous?: boolean, ) => string { // Default to icons enabled when no getter is supplied (backwards // compatible — callers/tests that render without options keep icons). @@ -1825,6 +1889,8 @@ export function createListRenderer(getShowIcons?: () => boolean): ( detailToCIndex?: number, detailToCFocus?: boolean, detailRenderedIndex?: number, + showHelpText?: boolean, + codeFreezeAmbiguous?: boolean, ): string => { const { rows, cols } = termSize; // Icons are gated by the getter for the whole frame (list lines, detail @@ -1886,14 +1952,26 @@ export function createListRenderer(getShowIcons?: () => boolean): ( } output.push(header); - // Code Freeze banner — a prominent warning that implementation is - // blocked while a ship release is in progress. The banner consumes one - // chrome row (chromeLines accounts for it) so the `rows - 1` pane + // Code Freeze banners — a prominent warning that implementation is + // blocked while a ship release is in progress (active marker), plus a + // DISTINCT warning when the marker is ambiguous (unreadable file, + // corrupt JSON, wrong shape): the downtime dispatcher treats ambiguity + // as frozen (fail-closed), so the operator sees why implement/audit + // dispatch is disabled (WL-0MSQ0RPQP00636JY). Each banner consumes one + // chrome row (chromeLines accounts for them) so the `rows - 1` pane // line-count invariant still holds (WL-0MSAAON63003N6LO). + let bannerCount = 0; if (codeFreezeActive) { const bannerText = `⛔ CODE FREEZE — ship release in progress; implement actions blocked`; const bannerLine = `${ANSI.bg(196)}${ANSI.fg(231)} ${bannerText} ${ANSI.reset}`; output.push(truncateLine(bannerLine, cols)); + bannerCount++; + } + if (codeFreezeAmbiguous) { + const bannerText = `⚠ Ambiguous Codefreeze marker — implement/audit dispatch disabled`; + const bannerLine = `${ANSI.bg(220)}${ANSI.fg(0)} ${bannerText} ${ANSI.reset}`; + output.push(truncateLine(bannerLine, cols)); + bannerCount++; } // Items are already flattened by the caller (render callback in runWorklistTui @@ -1907,8 +1985,7 @@ export function createListRenderer(getShowIcons?: () => boolean): ( // 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 ? 3 : 2; // header + banner + footer + const chromeLines = 2 + bannerCount; // header + banner(s) + 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 @@ -1968,9 +2045,15 @@ export function createListRenderer(getShowIcons?: () => boolean): ( output.push(''); } - // Footer with keyboard hints (dynamic — includes chord hints if available) + // Footer with keyboard hints (dynamic — includes chord hints if available). + // Both the normal hint line and the chord-in-progress line are gated by + // `showHelpText` (default true), so `showHelpText: false` hides ALL shortcut + // hints, consistent with the pi browse widget's showHelpText handling + // (WL-0MSGJDSMJ004128E). Note: gating only affects rendering — chord key + // handling/accumulation in chordState continues regardless. + const helpEnabled = showHelpText ?? true; const isChordActive = chordState && chordState.pendingKeys.length > 0; - if (isChordActive) { + if (isChordActive && helpEnabled) { const pendingStr = chordState!.pendingKeys.join(' '); const hintStr = chordState!.hints ? ` ${ANSI.dim}${chordState!.hints}${ANSI.reset}` @@ -2179,11 +2262,16 @@ export function formatCodeFreezeDialog(maxCols: number, maxRows: number, reason? /** * 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 + * When a stage filter is active, the worklist shows every root item in that + * stage matching the stage's status rule (`wl list --status --stage + * --root-only`; see STAGE_STATUS in fetcher.ts) — not just the + * `browseItemCount`-capped `wl next` subset — so stage-filtered views give + * a complete picture of the stage (WL-0MSDT8X1V003206G). Most stages show + * `open`-status items only; the in_review stage additionally includes + * `completed` and `in-progress` items, because per the project workflow + * advancing an item to in_review sets its status to `completed` (or leaves + * it `in-progress` while being re-worked after review feedback) + * (WL-0MSKCRX730052IIW). Items with status `blocked` are excluded; child * items stay hidden and remain reachable via expand exactly as in the * unfiltered view. Results follow the standard list order (sortIndex). * @@ -2258,6 +2346,13 @@ export function dispatchChordCommand( if (command.startsWith('/skill:audit')) { return resolveAndRouteCommand(command, state, onCommand, model); } + if (command.startsWith('/skill:ship')) { + // Dev→main release (Ship It shortcut, WL-0MSGG5N5Z0074TLY). Global + // release — no substitution; routed to the agent channel like + // other /skill:* commands. NOT blocked during a Code Freeze (the ship + // skill gates itself); only the confirmation dialog precedes dispatch. + return resolveAndRouteCommand(command, state, onCommand, model); + } // ── Agent workflow commands ───────────────────────────── if (command.startsWith('/intake')) { @@ -2294,10 +2389,11 @@ export interface PodcastTargetResolution { /** * Resolve podcast-progression command markers (``, - * ``) for the selected work item at dispatch time - * (OSL-0MSKFXM380098LFL, folding in OSL-0MSHFQ51L009IUOS). + * ``, ``, ``) for the selected + * work item at dispatch time (OSL-0MSKFXM380098LFL, folding in + * OSL-0MSHFQ51L009IUOS and the OSL-0MSKVB5K6008XFOQ w-chord split). * - * The `w` write-script chord command + * The `w s` write-script sub-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 @@ -2307,6 +2403,15 @@ export interface PodcastTargetResolution { * - otherwise: belt-and-braces guard — returns an error and does NOT * dispatch (never authors a duplicate). * + * The `w r` write-review sub-chord command + * (`/skill:wiki-podcast-script --review `) runs the 6 + * reviews on the existing script, and the `w b` write-both sub-chord + * command (`/skill:wiki-podcast-script --review-rewrite `) + * runs reviews + rewrite in one pass (7 LLM calls). Both resolve + * ``/`` to the first `.podcast.md` Key File + * in raw form (same as the existing `--rewrite` resolution) with a + * belt-and-braces error when no script exists. + * * The `t` TTS chord command * (`/skill:wiki-tts-generate --podcast-file `) resolves * `` to the first `.podcast.md` Key File, normalized to the @@ -2329,7 +2434,9 @@ export async function resolvePodcastTarget( ): Promise { const hasTarget = command.includes(''); const hasScript = command.includes(''); - if (!hasTarget && !hasScript) { + const hasReview = command.includes(''); + const hasBoth = command.includes(''); + if (!hasTarget && !hasScript && !hasReview && !hasBoth) { return { command }; } if (!item) { @@ -2383,6 +2490,23 @@ export async function resolvePodcastTarget( resolved = resolved.replace(//g, podcastFile); } + // `w r` write-review / `w b` write-both sub-chords: both require an + // existing script and resolve the marker to the raw first `.podcast.md` + // Key File (same raw form the `--rewrite` resolution uses). The chords + // are stage-gated to script-bearing stages, but a belt-and-braces guard + // still protects the unfiltered/edge case (OSL-0MSKVB5K6008XFOQ). + if (hasReview || hasBoth) { + if (!script) { + return { error: 'No podcast script found in Key Files: — author the script first (w)' }; + } + if (hasReview) { + resolved = resolved.replace(//g, script); + } + if (hasBoth) { + resolved = resolved.replace(//g, script); + } + } + return { command: resolved }; } @@ -2391,9 +2515,9 @@ export async function resolvePodcastTarget( * * Routing priority: * 1. {@link dispatchChordCommand} — handles `/wl ` (internal filter), - * `/skill:implement`, `/skill:audit`, `/intake`, `/plan`, `!!wl reviewed`, - * and compound `&& wl audit-set` commands (resolves `` and routes to - * `onCommand`). Returns 'dispatched'. + * `/skill:implement`, `/skill:audit`, `/skill:ship`, `/intake`, `/plan`, + * `!!wl reviewed`, and compound `&& wl audit-set` commands (resolves + * `` and routes to `onCommand`). Returns 'dispatched'. * 2. Code Freeze guard — when `codeFreezeActive` is true and the command is * an implement command, the command is NOT routed; returns 'blocked' so * the caller can show the Code Freeze dialog instead of spawning a pane. @@ -2521,6 +2645,12 @@ export async function runWorklistTui( let formState: FormState | null = null; /** Saved mode before entering form overlay (to restore on cancel) */ let preFormMode: ViewMode = 'list'; + /** + * Ship It confirmation dialog (WL-0MSGG5N5Z0074TLY). Non-null while the + * dialog is open; its buffer holds the typed confirmation input. The + * dialog is bottom-anchored — the selection list stays visible above it. + */ + let shipItDialog: ShipItDialogState | null = null; let totalActionableCount: number | undefined; @@ -2528,13 +2658,27 @@ export async function runWorklistTui( // the Code Freeze notice dialog is currently showing. The banner state is // refreshed on each data refresh; the command-dispatch path re-reads the // marker fresh so a freeze that starts between refreshes is still enforced - // at dispatch time (fail-safe client-side blocking). + // at dispatch time (fail-safe client-side blocking). `codeFreezeAmbiguous` + // drives the separate "Ambiguous Codefreeze marker" banner + // (WL-0MSQ0RPQP00636JY): a marker that cannot be parsed disables + // implement/audit downtime dispatch (fail-closed) and the operator must + // see why. Browsing/shortcut blocking keep their fail-open semantics — + // `codeFreezeActive` stays false for an ambiguous marker. let codeFreezeActive = false; + let codeFreezeAmbiguous = false; let codeFreezeNotice = false; - /** Re-read the code-freeze marker (fail-open: errors => not frozen). */ + /** + * Re-read the code-freeze marker tri-state. Fail-open for browsing: an + * ambiguous marker keeps `codeFreezeActive` false (browsing and shortcut + * blocking are unchanged); only the ambiguous-marker banner state and the + * downtime dispatcher's fail-closed gate are affected + * (WL-0MSQ0RPQP00636JY). + */ const refreshFreezeState = (): void => { - codeFreezeActive = readCodeFreezeState().active; + const status = readCodeFreezeStatus(); + codeFreezeActive = status === 'frozen'; + codeFreezeAmbiguous = status === 'ambiguous'; }; // Initial Code Freeze state read (fail-open: no marker => not frozen). @@ -2543,9 +2687,10 @@ export async function runWorklistTui( // Pane-visibility gating (pause-when-hidden). When the pane's tab is not // focused, auto-refresh/auto-sync timer ticks are skipped so hidden panes // stop spawning wl processes. Fail-open: when visibility can't be - // determined (no HERDR_PANE_ID / CLI error) the pane is treated as visible - // and polling proceeds as today. PollGate memoizes the pane-get exec within - // a TTL so refresh+sync ticks in one cycle share a single `herdr pane get`. + // determined (no HERDR_TAB_ID / CLI error) the pane is treated as visible + // and polling proceeds as today. Tab focus is the signal (herdr tab get + // -> result.tab.focused); PollGate memoizes the tab-get exec within a + // TTL so refresh+sync ticks in one cycle share a single `herdr tab get`. const paneGate = new PollGate(isPaneVisible, DEFAULT_POLL_GATE_TTL_MS); // Whether the pane is currently hidden (drives the header indicator). // Updated by the gate check on each timer tick; fail-open defaults to false. @@ -2688,9 +2833,12 @@ export async function runWorklistTui( * True when the resolved command is a `/wl` view command — a stage filter * (`/wl `, 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). + * filtered views show every root item in that stage matching the stage's + * status rule (`wl list --status --stage --root-only`; + * see STAGE_STATUS in fetcher.ts) — most stages show `open`-status items + * only, while the in_review stage additionally includes `completed` and + * `in-progress` items (WL-0MSKCRX730052IIW); clearing the filter restores + * the default view (WL-0MSGSE15000746F7). */ const isWlViewCommand = (cmd: string): boolean => { if (/^\/wl\s*$/.test(cmd)) return true; @@ -2707,9 +2855,9 @@ export async function runWorklistTui( // 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. + // skips are silent because the data is already fresh. The manual 'S' sync + // binding was removed (WL-0MSGG5N5Z0074TLY) — doSync is now reached only + // from the auto-sync timer path. const doSync = async (ifIdle = false, heartbeatTtlMs?: number): Promise => { const outcome = await runSync(getWorklogDir(), { ifIdle, @@ -2729,6 +2877,46 @@ export async function runWorklistTui( } }; + /** + * Open the Ship It confirmation dialog (WL-0MSGG5N5Z0074TLY). + * + * The dev→main release shortcut requires the user to type `ship` + * (case-insensitive) and press Enter before the command is dispatched. + * Dispatch (on confirm) goes through the SAME standard command-routing + * path as every other shortcut — executeResolvedCommand → + * dispatchChordCommand (which recognizes the `/skill:ship` family) → + * onCommand — with NO `` substitution (global release). Esc cancels + * without dispatching. + */ + const openShipItDialog = (model?: string): void => { + shipItDialog = new ShipItDialogState( + // onConfirm — typed 'ship' + Enter: dispatch via the standard path. + () => { + try { + // Fresh marker read at dispatch time (fail-safe client-side). + const frozen = readCodeFreezeState().active; + if (frozen) { + codeFreezeActive = true; + } + const result = executeResolvedCommand(SHIP_IT_COMMAND, state, opts.onCommand, frozen, model); + if (result === 'noop') { + showToast('Skipped', { body: `${SHIP_IT_COMMAND} (no item)` }); + } else { + showToast('Sent', { body: SHIP_IT_COMMAND }); + } + } catch (e) { + showToast('Error', { body: (e as Error).message }); + process.stderr.write(`[herdr] Command error: ${(e as Error).message}\n`); + } + }, + // onCancel — Esc: nothing is dispatched. + () => { + // no-op: dialog state is cleared by the onData handler. + }, + ); + render(); + }; + const onData = async (chunk: Buffer): Promise => { const key = chunk.toString(); @@ -2766,6 +2954,21 @@ export async function runWorklistTui( return; } + // ── Ship It confirmation dialog handling (WL-0MSGG5N5Z0074TLY) ──── + // While the dialog is open, all keys are consumed by the dialog: + // printable characters append to the typed buffer, Backspace deletes, + // Enter submits (dispatching only when the buffer matches `ship` + // case-insensitively), Esc cancels. The list stays visible beneath the + // bottom-anchored overlay. + if (shipItDialog !== null) { + const result = shipItDialog.handleInput(key); + if (result === 'submitted' || result === 'cancelled') { + shipItDialog = null; + } + render(); + return; + } + if (key === 'q' && state.mode !== 'filter') { cleanup(); resolve(undefined); @@ -2792,11 +2995,13 @@ export async function runWorklistTui( chordState.resolvedCommand = null; chordState.resolvedModel = null; if (command) { - // Podcast-progression markers (/) - // 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('') || command.includes('')) { + // Podcast-progression markers (// + // /) 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; w-chord split OSL-0MSKVB5K6008XFOQ). + if (command.includes('') || command.includes('') + || command.includes('') || command.includes('')) { const podcast = await resolvePodcastTarget(command, state.getSelectedItem()); if (podcast.error) { showToast('Error', { body: podcast.error }); @@ -2805,6 +3010,14 @@ export async function runWorklistTui( } command = podcast.command ?? command; } + // Ship It confirmation (WL-0MSGG5N5Z0074TLY): the dev→main + // release command, however it reaches this path, requires a typed + // 'ship' confirmation before dispatch. Esc cancels, the dialog + // stays bottom-anchored over the list. + if (command === SHIP_IT_COMMAND) { + openShipItDialog(model ?? undefined); + return; + } // Check for unknown identifiers that need form input if (hasUnknownIdentifiers(command)) { // Look up description from shortcut entry @@ -2879,8 +3092,9 @@ export async function runWorklistTui( showToast('Sent', { body: command.length > 60 ? command.substring(0, 57) + '...' : command }); } // Stage-filter dispatch (/wl 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). + // so the filtered view shows every root item in the stage matching + // the stage's status rule, not just the already-loaded subset + // (WL-0MSDT8X1V003206G). if (result === 'dispatched' && isWlViewCommand(command)) { await doRefresh(true); } @@ -2925,11 +3139,13 @@ export async function runWorklistTui( if (singleEntry) { let singleCmd = singleEntry.command; const singleModel = singleEntry.model ?? undefined; - // Podcast-progression markers (/) - // 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('') || singleCmd.includes('')) { + // Podcast-progression markers (// + // /) 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; w-chord split OSL-0MSKVB5K6008XFOQ). + if (singleCmd.includes('') || singleCmd.includes('') + || singleCmd.includes('') || singleCmd.includes('')) { const podcast = await resolvePodcastTarget(singleCmd, state.getSelectedItem()); if (podcast.error) { showToast('Error', { body: podcast.error }); @@ -2938,6 +3154,15 @@ export async function runWorklistTui( } singleCmd = podcast.command ?? singleCmd; } + // Ship It (S) — typed-confirmation dialog (WL-0MSGG5N5Z0074TLY): + // the dev→main release shortcut does NOT dispatch immediately. It + // opens a bottom-anchored confirmation dialog that keeps the + // selection list visible; the user must type `ship` + Enter to + // dispatch, Esc to cancel. + if (singleCmd === SHIP_IT_COMMAND) { + openShipItDialog(singleModel); + return; + } // Single-key shortcut — check for unknown identifiers first if (hasUnknownIdentifiers(singleCmd)) { let description = ''; @@ -2998,8 +3223,9 @@ export async function runWorklistTui( showToast('Sent', { body: singleCmd.length > 60 ? singleCmd.substring(0, 57) + '...' : singleCmd }); } // Stage-filter dispatch (/wl 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). + // so the filtered view shows every root item in the stage matching + // the stage's status rule, not just the already-loaded subset + // (WL-0MSDT8X1V003206G). if (result === 'dispatched' && isWlViewCommand(singleCmd)) { await doRefresh(true); } @@ -3035,11 +3261,6 @@ export async function runWorklistTui( return; } - if (action === 'sync') { - await doSync(); - return; - } - if (action === 'select' && prevMode === 'detail') { cleanup(); resolve(state.detailItem ?? undefined); @@ -3211,17 +3432,34 @@ export async function runWorklistTui( state.detailToCIndex, state.detailToCFocus, state.detailRenderedIndex, + // Gate the chord-in-progress footer behind showHelpText so `false` hides + // ALL shortcut hint lines (normal and chord-mode), matching the pi browse + // widget (WL-0MSGJDSMJ004128E). Chord key handling is unaffected. + opts.getShowHelpText(), + // Ambiguous code-freeze marker banner state (WL-0MSQ0RPQP00636JY): + // distinct from the active-freeze banner; both can render (defensive) + // but the tri-state read guarantees at most one is true. + codeFreezeAmbiguous, ); // Notifications are surfaced via Herdr toasts (showToast), never as a // bottom line — the pane output must stay within the terminal budget. const rendered = output; + // ── Ship It confirmation dialog overlay (WL-0MSGG5N5Z0074TLY) ───── + // Bottom-anchored: the selection list is rendered normally and the + // dialog replaces only the lower rows, so the list stays visible above + // it (contrast: FormState full-screen, Code Freeze notice centered + // full-pane box). The overlay never exceeds the `rows - 1` budget. + const finalOutput = shipItDialog !== null + ? overlayShipItDialog(rendered, termSize.cols, termSize.rows, shipItDialog.buffer) + : rendered; + // Clear from cursor to end of screen to remove leftover content // from previous renders of different heights process.stdout.write(ANSI.clear); process.stdout.write(ANSI.cursorHome); - process.stdout.write(rendered); + process.stdout.write(finalOutput); }; // Initial render @@ -3312,10 +3550,10 @@ export async function runWorklistTui( }); } - // Visibility resume-poll — runs only while the pane is hidden. Polls - // pane visibility on a short interval so the hidden → visible transition + // Visibility resume-poll — runs only while the pane's tab is hidden. Polls + // tab 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 + // refreshIntervalMs tick. The poll uses only herdr tab get (via the // PollGate memoizer, TTL aligned with the poll interval) — never wl — // preserving the zero-wl-when-hidden guarantee. scheduler.addTask({ diff --git a/packages/shared/README.md b/packages/shared/README.md index 34fd4ac6..87a511c1 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -99,3 +99,22 @@ db.close(); The TUI extension's `closeWorklogDb()` function (in `wl-integration.ts`) wraps this with cache cleanup. + +## updatedAt preservation (no-op guards) + +`updatedAt` is only bumped when a tracked field has **semantically** changed: + +- `import()` (full-set replace, used by `wl sync`) and `upsertItems()` + preserve the existing `updatedAt` when an incoming item is unchanged. +- `update()` returns early without writing (and without re-timestamping) + when the update is a no-op. + +Both paths share a single comparator, `compareTrackedFields()`, which is +**whitespace-insensitive for `title` and `description`**: leading/trailing +whitespace, trailing newlines and blank-line runs are stripped before +comparison, so whitespace-only differences (e.g. a second worklog store that +strips trailing newlines from descriptions) do NOT count as semantic changes +and never re-timestamp items (WL-0MSORD6HC005QVZX). The incoming normalized +content is still persisted — only `updatedAt` is preserved. All other fields +(tags, status, priority, …) use strict comparison; genuine content changes +still bump `updatedAt`. diff --git a/packages/shared/src/database.test.ts b/packages/shared/src/database.test.ts new file mode 100644 index 00000000..9a796da6 --- /dev/null +++ b/packages/shared/src/database.test.ts @@ -0,0 +1,203 @@ +/** + * Tests for whitespace-insensitive semantic-change detection in + * WorklogDatabase (WL-0MSORD6HC005QVZX). + * + * `hasWorkItemChanged()` (used by `import()` and `upsertItems()`) and the + * no-op guard in `update()` must NOT treat whitespace-only differences in + * `title`/`description` (trailing newlines, leading/trailing whitespace, + * blank-line runs) as semantic changes. Otherwise `wl sync` re-timestamps + * items whose meaningful content never changed, which silently invalidates + * audits and produces an infinite conflict-resolution loop. + * + * All assertions go through the public API (`create`/`import`/`update`/ + * `upsertItems`/`get`) because `hasWorkItemChanged` is private; the tests pin + * observable behaviour: `updatedAt` is preserved for whitespace-only diffs + * and bumped for genuine changes. + * + * Run: npx vitest run packages/shared/src/database.test.ts + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { WorklogDatabase } from './database.js'; +import { WorkItem } from './types.js'; + +const FIXED_TS = '2026-01-01T00:00:00.000Z'; + +/** Build a complete WorkItem with a fixed timestamp so bump assertions are unambiguous. */ +function makeItem(overrides: Partial = {}): WorkItem { + return { + id: 'WI-0001', + title: 'Fix the bug', + description: 'A description with some detail.', + status: 'open', + priority: 'medium', + sortIndex: 0, + parentId: null, + createdAt: FIXED_TS, + updatedAt: FIXED_TS, + tags: [], + assignee: '', + stage: 'idea', + issueType: 'bug', + createdBy: '', + deletedBy: '', + deleteReason: '', + risk: '', + effort: '', + needsProducerReview: false, + ...overrides, + }; +} + +let tempDir: string; +let db: WorklogDatabase; + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'wl-db-whitespace-')); + db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true); +}); + +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('import() updatedAt preservation for whitespace-only diffs (WL-0MSORD6HC005QVZX)', () => { + it('preserves updatedAt when only description differs by a trailing newline', () => { + const original = makeItem(); + db.import([original]); + expect(db.get(original.id)?.updatedAt).toBe(FIXED_TS); + + db.import([{ ...original, description: original.description + '\n' }]); + expect(db.get(original.id)?.updatedAt).toBe(FIXED_TS); + }); + + it('preserves updatedAt when only description differs by leading/trailing whitespace', () => { + const original = makeItem(); + db.import([original]); + db.import([{ ...original, description: ` ${original.description} \t` }]); + expect(db.get(original.id)?.updatedAt).toBe(FIXED_TS); + }); + + it('preserves updatedAt when only description differs by blank-line runs (multiple trailing newlines)', () => { + const original = makeItem(); + db.import([original]); + db.import([{ ...original, description: original.description + '\n\n\n' }]); + expect(db.get(original.id)?.updatedAt).toBe(FIXED_TS); + }); + + it('preserves updatedAt when only title differs by leading/trailing whitespace', () => { + const original = makeItem(); + db.import([original]); + db.import([{ ...original, title: ` ${original.title} ` }]); + expect(db.get(original.id)?.updatedAt).toBe(FIXED_TS); + }); + + it('preserves updatedAt when BOTH title and description differ by whitespace only', () => { + const original = makeItem(); + db.import([original]); + db.import([{ ...original, title: original.title + '\n', description: original.description + '\n\n' }]); + expect(db.get(original.id)?.updatedAt).toBe(FIXED_TS); + }); + + it('still persists the incoming (whitespace-differing) description content when the only diff is whitespace', () => { + const original = makeItem(); + db.import([original]); + db.import([{ ...original, description: original.description + '\n' }]); + const stored = db.get(original.id); + expect(stored?.updatedAt).toBe(FIXED_TS); + expect(stored?.description).toBe(original.description + '\n'); + }); + + it('bumps updatedAt on a genuine description content change', () => { + const original = makeItem(); + db.import([original]); + db.import([{ ...original, description: 'completely different content' }]); + const stored = db.get(original.id); + expect(stored?.description).toBe('completely different content'); + expect(stored?.updatedAt).not.toBe(FIXED_TS); + }); + + it('bumps updatedAt on a genuine title content change', () => { + const original = makeItem(); + db.import([original]); + db.import([{ ...original, title: 'A genuinely new title' }]); + const stored = db.get(original.id); + expect(stored?.title).toBe('A genuinely new title'); + expect(stored?.updatedAt).not.toBe(FIXED_TS); + }); + + it('detects non-whitespace changes in other fields (status, priority, tags, parentId)', () => { + const cases: Array<{ id: string; patch: Partial }> = [ + { id: 'WI-0001', patch: { status: 'completed' } }, + { id: 'WI-0002', patch: { priority: 'critical' } }, + { id: 'WI-0003', patch: { tags: ['sync', 'updatedAt'] } }, + { id: 'WI-0004', patch: { parentId: 'WI-0000' } }, + ]; + for (const { id, patch } of cases) { + const original = makeItem({ id }); + db.import([original]); + expect(db.get(id)?.updatedAt).toBe(FIXED_TS); + db.import([{ ...original, ...patch }]); + expect(db.get(id)?.updatedAt).not.toBe(FIXED_TS); + } + }); +}); + +describe('update() no-op guard (WL-0MSORD6HC005QVZX)', () => { + // Seed via import() with a FIXED_TS so a wrongly-bumped timestamp is + // unambiguous even when create()+update() land in the same millisecond. + function seed(): WorkItem { + const item = makeItem({ id: 'WI-UPD1', title: 'My item', description: 'the description' }); + db.import([item]); + return item; + } + + it('preserves updatedAt when only description differs by trailing whitespace', () => { + const seeded = seed(); + const updated = db.update(seeded.id, { description: 'the description\n' }); + expect(updated?.updatedAt).toBe(FIXED_TS); + expect(db.get(seeded.id)?.updatedAt).toBe(FIXED_TS); + }); + + it('preserves updatedAt when only title differs by leading/trailing whitespace', () => { + const seeded = seed(); + const updated = db.update(seeded.id, { title: ' My item ' }); + expect(updated?.updatedAt).toBe(FIXED_TS); + expect(db.get(seeded.id)?.updatedAt).toBe(FIXED_TS); + }); + + it('bumps updatedAt on a genuine description change', () => { + const seeded = seed(); + const updated = db.update(seeded.id, { description: 'brand new description' }); + expect(updated?.description).toBe('brand new description'); + expect(updated?.updatedAt).not.toBe(FIXED_TS); + }); + + it('bumps updatedAt on a genuine status change', () => { + const seeded = seed(); + const updated = db.update(seeded.id, { status: 'completed' }); + expect(updated?.status).toBe('completed'); + expect(updated?.updatedAt).not.toBe(FIXED_TS); + }); +}); + +describe('upsertItems() no-op guard (WL-0MSORD6HC005QVZX)', () => { + it('preserves updatedAt when only description differs by a trailing newline', () => { + const original = makeItem(); + db.import([original]); + db.upsertItems([{ ...original, description: original.description + '\n' }]); + expect(db.get(original.id)?.updatedAt).toBe(FIXED_TS); + }); + + it('bumps updatedAt on a genuine description change', () => { + const original = makeItem(); + db.import([original]); + db.upsertItems([{ ...original, description: 'new content' }]); + const stored = db.get(original.id); + expect(stored?.description).toBe('new content'); + expect(stored?.updatedAt).not.toBe(FIXED_TS); + }); +}); diff --git a/packages/shared/src/database.ts b/packages/shared/src/database.ts index a12a51db..53fd5992 100644 --- a/packages/shared/src/database.ts +++ b/packages/shared/src/database.ts @@ -5,7 +5,7 @@ import { randomBytes } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; -import { WorkItem, WorkItemPriority, CreateWorkItemInput, UpdateWorkItemInput, WorkItemQuery, Comment, CreateCommentInput, UpdateCommentInput, NextWorkItemResult, DependencyEdge, AuditResult } from './types.js'; +import { WorkItem, WorkItemPriority, CreateWorkItemInput, UpdateWorkItemInput, WorkItemQuery, Comment, CreateCommentInput, UpdateCommentInput, NextWorkItemResult, DependencyEdge, AuditResult, DemotedParent } from './types.js'; import { SqlitePersistentStore, FtsSearchResult, PersistentStoreServices, PersistentStoreCacheOptions } from './persistent-store.js'; import { normalizeStatusValue } from './status-stage-rules.js'; @@ -1030,14 +1030,10 @@ export class WorklogDatabase { 'tags', 'assignee', 'stage', 'issueType', 'risk', 'effort', 'needsProducerReview' ]; - const hasChanged = fieldsToCompare.some(f => { - const oldVal = item[f]; - const newVal = updated[f]; - if (Array.isArray(oldVal) && Array.isArray(newVal)) { - return JSON.stringify(oldVal) !== JSON.stringify(newVal); - } - return oldVal !== newVal; - }); + // Shared comparator: whitespace-only diffs in title/description are not + // semantic changes (WL-0MSORD6HC005QVZX). Same normalization as + // hasWorkItemChanged(). + const hasChanged = this.compareTrackedFields(item, updated, fieldsToCompare); if (!hasChanged) { // Nothing changed — preserve original updatedAt and return early @@ -1244,6 +1240,42 @@ export class WorklogDatabase { return downgraded; } + /** + * Demote a parent work item when a child is added to it. + * + * A parent cannot be `completed` (status) or `in_review` (stage) while its + * subtree is not finished. When a new child is attached to such a parent + * (via `wl create --parent` or `wl update --parent`), the parent is moved + * back to `open` / `plan_complete` so its lifecycle state always reflects + * that it has uncompleted children. + * + * Only the direct parent is demoted; ancestors are left untouched. + * + * @param parentId - id of the parent that received the new child + * @returns the demotion details (parent, from status/stage, to status/stage) + * or `null` when the parent is not in an eligible state (or does not exist) + */ + demoteParentOnChildAdded(parentId: string): DemotedParent | null { + const parent = this.get(parentId); + if (!parent) { + return null; + } + const eligible = parent.status === 'completed' || parent.stage === 'in_review'; + if (!eligible) { + return null; + } + const from = { status: parent.status, stage: parent.stage }; + const updated = this.update(parentId, { status: 'open', stage: 'plan_complete' }); + if (!updated) { + return null; + } + return { + parent: updated, + from, + to: { status: updated.status, stage: updated.stage }, + }; + } + /** * Get the number of direct children for each work item. * Returns a Map. @@ -1319,6 +1351,67 @@ export class WorklogDatabase { return depth; } + /** + * Ordinal rank of a risk level on the canonical scale + * (Low < Medium < High < Severe/Critical). Both the type-level spelling + * ('Severe') and the icon-scale spelling ('critical') map to the top + * rank. Unset/unknown values map to null (fail-closed: they are never + * matched by an at-most risk filter). + */ + private riskOrdinal(risk: string | undefined | null): number | null { + switch ((risk ?? '').trim().toLowerCase()) { + case 'low': return 1; + case 'medium': return 2; + case 'high': return 3; + case 'severe': + case 'critical': return 4; + default: return null; + } + } + + /** + * Ordinal rank of an effort level on the canonical scale + * (Extra Small < Small < Medium < Large < Extra Large). Accepts both the + * short CLI spellings (XS/S/M/L/XL) and the long-form effort-and-risk + * skill spellings (extra small/small/medium/large/extra large), + * normalized case-insensitively. Unset/unknown values map to null + * (fail-closed: they are never matched by an at-most effort filter). + */ + private effortOrdinal(effort: string | undefined | null): number | null { + switch ((effort ?? '').trim().toLowerCase().replace(/[\s-]+/g, '')) { + case 'xs': + case 'extrasmall': return 1; + case 's': + case 'small': return 2; + case 'm': + case 'medium': return 3; + case 'l': + case 'large': return 4; + case 'xl': + case 'extralarge': return 5; + default: return null; + } + } + + /** + * True when an item satisfies the optional at-most risk/effort filters. + * Fail-closed: an unset/unknown risk or effort on the item (or an invalid + * filter level) never matches. + */ + private matchesRiskEffort(item: WorkItem, risk?: string, effort?: string): boolean { + if (risk !== undefined && risk !== '') { + const level = this.riskOrdinal(risk); + const ord = this.riskOrdinal(item.risk); + if (level === null || ord === null || ord > level) return false; + } + if (effort !== undefined && effort !== '') { + const level = this.effortOrdinal(effort); + const ord = this.effortOrdinal(item.effort); + if (level === null || ord === null || ord > level) return false; + } + return true; + } + /** * Get numeric priority value for comparisons */ @@ -1466,6 +1559,8 @@ export class WorklogDatabase { options: { assignee?: string; searchTerm?: string; + risk?: string; + effort?: string; excluded?: Set; debugPrefix?: string; includeInProgress?: boolean; @@ -1476,6 +1571,8 @@ export class WorklogDatabase { const { assignee, searchTerm, + risk, + effort, excluded, debugPrefix = '[critical]', includeInProgress = false, @@ -1514,8 +1611,9 @@ export class WorklogDatabase { if (unblockedCriticals.length > 0) { // Apply assignee/search to unblocked criticals — only return items - // that match the caller's filters. - let selectable = this.applyFilters(unblockedCriticals, assignee, searchTerm); + // that match the caller's filters (including risk/effort). + let selectable = this.applyFilters(unblockedCriticals, assignee, searchTerm) + .filter(item => this.matchesRiskEffort(item, risk, effort)); if (excluded && excluded.size > 0) { selectable = selectable.filter(item => !excluded.has(item.id)); } @@ -1588,7 +1686,7 @@ export class WorklogDatabase { // Apply assignee/search filters to the blockers only const filteredBlockingPairs = blockingPairs.filter(pair => this.applyFilters([pair.blocking], assignee, searchTerm).length > 0 - ); + ).filter(pair => this.matchesRiskEffort(pair.blocking, risk, effort)); this.debug(`${debugPrefix} blocking candidates=${blockingPairs.length} after filters=${filteredBlockingPairs.length}`); // Strict root-only (WL-0MS964SIA0057ABR): never surface child blockers. @@ -1598,7 +1696,7 @@ export class WorklogDatabase { const rootBlockingPairs: { blocking: WorkItem; critical: WorkItem }[] = []; for (const pair of filteredBlockingPairs) { const resolved = this.resolveBlockerToRoot(pair.blocking, allItems, assignee, searchTerm, excluded); - if (resolved) { + if (resolved && this.matchesRiskEffort(resolved, risk, effort)) { rootBlockingPairs.push({ blocking: resolved, critical: pair.critical }); } } @@ -1616,7 +1714,8 @@ export class WorklogDatabase { // No actionable blocker found — return the blocked critical itself as a // last resort so the user is aware of the stuck critical item. - let selectableBlocked = this.applyFilters(blockedCriticals, assignee, searchTerm); + let selectableBlocked = this.applyFilters(blockedCriticals, assignee, searchTerm) + .filter(item => this.matchesRiskEffort(item, risk, effort)); if (excluded && excluded.size > 0) { selectableBlocked = selectableBlocked.filter(item => !excluded.has(item.id)); } @@ -1835,6 +1934,8 @@ export class WorklogDatabase { assignee?: string; searchTerm?: string; stage?: string; + risk?: string; + effort?: string; excluded?: Set; includeBlocked?: boolean; includeInProgress?: boolean; @@ -1846,6 +1947,8 @@ export class WorklogDatabase { assignee, searchTerm, stage, + risk, + effort, excluded, includeBlocked = false, includeInProgress = false, @@ -1900,6 +2003,38 @@ export class WorklogDatabase { pool = this.applyFilters(pool, assignee, searchTerm); this.debug(`${debugPrefix} filter: after assignee/search=${pool.length}`); + // 7b. Apply risk/effort at-most filters (ordinal, fail-closed on unset). + // An item is eligible only when its risk ≤ the filter level (Low < Medium + // < High < Severe) and its effort ≤ the filter level (Extra Small < Small + // < Medium < Large < Extra Large). Items with unset/empty risk or effort + // are NEVER matched — an absent estimate is not "≤ low/small" (AC2). + if (risk !== undefined && risk !== '') { + const riskLevel = this.riskOrdinal(risk); + if (riskLevel === null) { + // Invalid filter level → fail-closed: match nothing. + pool = []; + } else { + pool = pool.filter(item => { + const ord = this.riskOrdinal(item.risk); + return ord !== null && ord <= riskLevel; + }); + } + this.debug(`${debugPrefix} filter: after risk=${risk}=${pool.length}`); + } + if (effort !== undefined && effort !== '') { + const effortLevel = this.effortOrdinal(effort); + if (effortLevel === null) { + // Invalid filter level → fail-closed: match nothing. + pool = []; + } else { + pool = pool.filter(item => { + const ord = this.effortOrdinal(item.effort); + return ord !== null && ord <= effortLevel; + }); + } + this.debug(`${debugPrefix} filter: after effort=${effort}=${pool.length}`); + } + // Snapshot for critical-path escalation (before dep-blocker removal) const criticalPool = pool; @@ -2006,9 +2141,11 @@ export class WorklogDatabase { includeBlocked: boolean = false, stage?: string, includeInProgress: boolean = false, - edgeCache?: EdgeCache + edgeCache?: EdgeCache, + risk?: string, + effort?: string ): NextWorkItemResult { - this.debug(`${debugPrefix} assignee=${assignee || ''} search=${searchTerm || ''} stage=${stage || ''} excluded=${excluded?.size || 0}`); + this.debug(`${debugPrefix} assignee=${assignee || ''} search=${searchTerm || ''} stage=${stage || ''} excluded=${excluded?.size || 0} risk=${risk || ''} effort=${effort || ''}`); // Build the sort-order cache once from the pre-loaded items array. // This avoids an extra full-table scan of all work items from the database. @@ -2023,6 +2160,8 @@ export class WorklogDatabase { assignee, searchTerm, stage, + risk, + effort, excluded, includeBlocked, includeInProgress, @@ -2042,6 +2181,8 @@ export class WorklogDatabase { searchTerm, excluded, includeInProgress, + risk, + effort, debugPrefix: `${debugPrefix} [critical]`, edgeCache, sortOrderCache, @@ -2062,111 +2203,119 @@ export class WorklogDatabase { // be hidden from wl next results. The existing isInProgressSubtree() // filter in Stage 5 already ensures this for open items; Stage 3 must // apply the same filtering for blocker surfacing. - const nonCriticalBlocked = criticalPool.filter( - item => item.status === 'blocked' && item.priority !== 'critical' - ).filter(item => !this.isInProgressSubtree(item, items)); - this.debug(`${debugPrefix} non-critical blocked=${nonCriticalBlocked.length}`); - + // + // Skip non-critical blocker surfacing when a stage filter is specified — + // mirroring the Stage 2 guard above: the user is explicitly filtering by + // stage and blocker surfacing must not surface items at other stages + // (WL-0MSP1XJSO007LE3K). A blocker's own stage is not constrained by the + // stage-filtered candidate pool, so surfacing it would violate the filter. // Strict root-only (WL-0MS964SIA0057ABR): tracks whether any would-be // blocker was a hidden child whose parent is not selectable. If no blocker // can be surfaced and no root candidate remains in Stage 5, wl next // returns null with a clear reason rather than surfacing the child. let droppedHiddenChildBlocker = false; - if (nonCriticalBlocked.length > 0 && filteredItems.length > 0) { - // Find the highest priority value among open candidates - const bestCompetitorPriority = Math.max( - ...filteredItems.map(item => this.getPriorityValue(item.priority)) - ); - - // Sort blocked items by priority descending so we handle the most - // important blocked item first - const sortedBlocked = nonCriticalBlocked.slice().sort( - (a, b) => this.getPriorityValue(b.priority) - this.getPriorityValue(a.priority) - ); - - for (const blockedItem of sortedBlocked) { - const blockedPriority = this.getPriorityValue(blockedItem.priority); - if (blockedPriority < bestCompetitorPriority) { - // Blocked item is lower priority than best open candidate — skip - continue; - } - - // Blocked item priority >= best competitor: surface its blocker - const blockingPairs: { blocking: WorkItem; blocked: WorkItem }[] = []; - - // Check dependency blockers - const dependencyBlockers = this.getActiveDependencyBlockers(blockedItem.id, edgeCache); - for (const blocker of dependencyBlockers) { - if (excluded?.has(blocker.id)) continue; - blockingPairs.push({ blocking: blocker, blocked: blockedItem }); - } - - // Check child blockers - const blockingChildren = this.getNonClosedChildren(blockedItem.id, edgeCache); - for (const child of blockingChildren) { - if (excluded?.has(child.id)) continue; - blockingPairs.push({ blocking: child, blocked: blockedItem }); - } - - // Apply assignee/search filters to blockers - let filteredBlockers = blockingPairs.filter(pair => - this.applyFilters([pair.blocking], assignee, searchTerm).length > 0 + if (!stage) { + const nonCriticalBlocked = criticalPool.filter( + item => item.status === 'blocked' && item.priority !== 'critical' + ).filter(item => !this.isInProgressSubtree(item, items)); + this.debug(`${debugPrefix} non-critical blocked=${nonCriticalBlocked.length}`); + + if (nonCriticalBlocked.length > 0 && filteredItems.length > 0) { + // Find the highest priority value among open candidates + const bestCompetitorPriority = Math.max( + ...filteredItems.map(item => this.getPriorityValue(item.priority)) ); - - // Strict root-only (WL-0MS964SIA0057ABR): child blockers are never - // surfaced by wl next. - // - A child blocker whose parent is a selectable actionable root - // candidate is dropped — the parent competes in Stage 5 (open item - // selection) and is the unit of work surfaced there. - // - A child blocker whose parent is NOT selectable is hidden entirely - // (no orphan promotion); if no surfacable blocker remains and no - // root candidate exists, wl next returns null with a clear reason. - const rootOnlyBlockers: { blocking: WorkItem; blocked: WorkItem }[] = []; - for (const pair of filteredBlockers) { - if (!pair.blocking.parentId) { - // Root-level blocker — surfacing it is fine. - rootOnlyBlockers.push(pair); + + // Sort blocked items by priority descending so we handle the most + // important blocked item first + const sortedBlocked = nonCriticalBlocked.slice().sort( + (a, b) => this.getPriorityValue(b.priority) - this.getPriorityValue(a.priority) + ); + + for (const blockedItem of sortedBlocked) { + const blockedPriority = this.getPriorityValue(blockedItem.priority); + if (blockedPriority < bestCompetitorPriority) { + // Blocked item is lower priority than best open candidate — skip continue; } - // Child blocker: resolve to parent when selectable, else hidden. - const resolved = this.resolveBlockerToRoot(pair.blocking, items, assignee, searchTerm, excluded); - if (resolved) { - // Parent is selectable — it competes in Stage 5 (existing - // hierarchy awareness, WL-0MQF95NCC0024H61). - this.debug(`${debugPrefix} drop child blocker ${pair.blocking.id} (selectable parent ${resolved.id} competes in Stage 5)`); - } else { - droppedHiddenChildBlocker = true; + + // Blocked item priority >= best competitor: surface its blocker + const blockingPairs: { blocking: WorkItem; blocked: WorkItem }[] = []; + + // Check dependency blockers + const dependencyBlockers = this.getActiveDependencyBlockers(blockedItem.id, edgeCache); + for (const blocker of dependencyBlockers) { + if (excluded?.has(blocker.id)) continue; + blockingPairs.push({ blocking: blocker, blocked: blockedItem }); } - } - filteredBlockers = rootOnlyBlockers; - - // Filter out blockers that belong to an in-progress parent subtree — - // children of in-progress parents must not appear as independent - // wl next results from any stage, including blocker surfacing. - // This complements the in-progress subtree filter above on the - // blocked item itself and the existing isInProgressSubtree() filter - // in Stage 5 (open item selection). - filteredBlockers = filteredBlockers.filter(pair => - !this.isInProgressSubtree(pair.blocking, items) - ); - - this.debug(`${debugPrefix} blocker-surfacing: blockedItem=${blockedItem.id} pri=${blockedItem.priority} blockers=${filteredBlockers.length}`); - - if (filteredBlockers.length > 0) { - // Select the best blocker by sort index - const orderedBlockers = this.orderBySortIndex(filteredBlockers.map(p => p.blocking), sortOrderCache); - const selectedBlocker = orderedBlockers[0]; - if (selectedBlocker) { - const pair = filteredBlockers.find(p => p.blocking.id === selectedBlocker.id)!; - return { - workItem: selectedBlocker, - reason: `Blocking issue for ${pair.blocked.priority}-priority item ${pair.blocked.id} (${pair.blocked.title})` - }; + + // Check child blockers + const blockingChildren = this.getNonClosedChildren(blockedItem.id, edgeCache); + for (const child of blockingChildren) { + if (excluded?.has(child.id)) continue; + blockingPairs.push({ blocking: child, blocked: blockedItem }); + } + + // Apply assignee/search filters to blockers + let filteredBlockers = blockingPairs.filter(pair => + this.applyFilters([pair.blocking], assignee, searchTerm).length > 0 + ).filter(pair => this.matchesRiskEffort(pair.blocking, risk, effort)); + + // Strict root-only (WL-0MS964SIA0057ABR): child blockers are never + // surfaced by wl next. + // - A child blocker whose parent is a selectable actionable root + // candidate is dropped — the parent competes in Stage 5 (open item + // selection) and is the unit of work surfaced there. + // - A child blocker whose parent is NOT selectable is hidden entirely + // (no orphan promotion); if no surfacable blocker remains and no + // root candidate exists, wl next returns null with a clear reason. + const rootOnlyBlockers: { blocking: WorkItem; blocked: WorkItem }[] = []; + for (const pair of filteredBlockers) { + if (!pair.blocking.parentId) { + // Root-level blocker — surfacing it is fine. + rootOnlyBlockers.push(pair); + continue; + } + // Child blocker: resolve to parent when selectable, else hidden. + const resolved = this.resolveBlockerToRoot(pair.blocking, items, assignee, searchTerm, excluded); + if (resolved) { + // Parent is selectable — it competes in Stage 5 (existing + // hierarchy awareness, WL-0MQF95NCC0024H61). + this.debug(`${debugPrefix} drop child blocker ${pair.blocking.id} (selectable parent ${resolved.id} competes in Stage 5)`); + } else { + droppedHiddenChildBlocker = true; + } + } + filteredBlockers = rootOnlyBlockers; + + // Filter out blockers that belong to an in-progress parent subtree — + // children of in-progress parents must not appear as independent + // wl next results from any stage, including blocker surfacing. + // This complements the in-progress subtree filter above on the + // blocked item itself and the existing isInProgressSubtree() filter + // in Stage 5 (open item selection). + filteredBlockers = filteredBlockers.filter(pair => + !this.isInProgressSubtree(pair.blocking, items) + ); + + this.debug(`${debugPrefix} blocker-surfacing: blockedItem=${blockedItem.id} pri=${blockedItem.priority} blockers=${filteredBlockers.length}`); + + if (filteredBlockers.length > 0) { + // Select the best blocker by sort index + const orderedBlockers = this.orderBySortIndex(filteredBlockers.map(p => p.blocking), sortOrderCache); + const selectedBlocker = orderedBlockers[0]; + if (selectedBlocker) { + const pair = filteredBlockers.find(p => p.blocking.id === selectedBlocker.id)!; + return { + workItem: selectedBlocker, + reason: `Blocking issue for ${pair.blocked.priority}-priority item ${pair.blocked.id} (${pair.blocked.title})` + }; + } } } } - } + } // end if (!stage) — Stage 3 skipped under stage filter // ── Stage 5: Open item selection ── // Select among filtered candidates, returning the best root item @@ -2239,11 +2388,13 @@ export class WorklogDatabase { searchTerm?: string, includeBlocked: boolean = false, stage?: string, - includeInProgress: boolean = false + includeInProgress: boolean = false, + risk?: string, + effort?: string ): NextWorkItemResult { const items = this.store.getAllWorkItems(); const edgeCache = this.buildEdgeCache(items); - return this.findNextWorkItemFromItems(items, assignee, searchTerm, undefined, '[next]', includeBlocked, stage, includeInProgress, edgeCache); + return this.findNextWorkItemFromItems(items, assignee, searchTerm, undefined, '[next]', includeBlocked, stage, includeInProgress, edgeCache, risk, effort); } /** @@ -2256,7 +2407,9 @@ export class WorklogDatabase { searchTerm?: string, includeBlocked: boolean = false, stage?: string, - includeInProgress: boolean = false + includeInProgress: boolean = false, + risk?: string, + effort?: string ): NextWorkItemResult[] { const results: NextWorkItemResult[] = []; const excluded = new Set(); @@ -2276,7 +2429,9 @@ export class WorklogDatabase { includeBlocked, stage, includeInProgress, - edgeCache + edgeCache, + risk, + effort ); results.push(result); @@ -2365,11 +2520,44 @@ export class WorklogDatabase { return this.sortItemsByScore(items, recencyPolicy, cache); } + /** + * Compare a set of tracked fields between two work items and return true if + * any of them has semantically changed. + * + * `title` and `description` are whitespace-normalized before comparison + * (leading/trailing whitespace, trailing newlines and blank-line runs are + * stripped) so that whitespace-only differences do NOT count as semantic + * changes — e.g. a second worklog store that strips trailing newlines from + * descriptions would otherwise re-timestamp every item on every `wl sync` + * (WL-0MSORD6HC005QVZX). All other fields use strict equality; arrays + * (tags) are compared by value. + * + * Shared by {@link hasWorkItemChanged} (16-field tracked set, used by + * import()/upsertItems()) and the no-op guard in {@link update} (13-field + * set, excluding the GitHub metadata fields). + */ + private compareTrackedFields(oldItem: WorkItem, newItem: WorkItem, fields: (keyof WorkItem)[]): boolean { + return fields.some(f => { + const oldVal = oldItem[f]; + const newVal = newItem[f]; + if (f === 'title' || f === 'description') { + // Whitespace-only differences (trailing newline strip/normalization) + // are not semantic changes — compare normalized text only. + return String(oldVal ?? '').trim() !== String(newVal ?? '').trim(); + } + if (Array.isArray(oldVal) && Array.isArray(newVal)) { + return JSON.stringify(oldVal) !== JSON.stringify(newVal); + } + return oldVal !== newVal; + }); + } + /** * Compare an existing work item against a candidate and return true if any * tracked field has semantically changed. * - * Uses the same field set and comparison logic as the no-op guard in {@link update}. + * Uses the same field set and comparison logic as the no-op guard in {@link update} + * (via {@link compareTrackedFields}). */ private hasWorkItemChanged(oldItem: WorkItem, newItem: WorkItem): boolean { const fieldsToCompare: (keyof WorkItem)[] = [ @@ -2378,14 +2566,7 @@ export class WorklogDatabase { 'needsProducerReview', 'githubIssueNumber', 'githubIssueId', 'githubIssueUpdatedAt' ]; - return fieldsToCompare.some(f => { - const oldVal = oldItem[f]; - const newVal = newItem[f]; - if (Array.isArray(oldVal) && Array.isArray(newVal)) { - return JSON.stringify(oldVal) !== JSON.stringify(newVal); - } - return oldVal !== newVal; - }); + return this.compareTrackedFields(oldItem, newItem, fieldsToCompare); } /** @@ -2416,6 +2597,13 @@ export class WorklogDatabase { * re-timestamp unchanged items. Changed items get a new `updatedAt`; * entirely new items use the incoming value as-is. * + * Comparison is whitespace-insensitive for `title` and `description` + * (via {@link compareTrackedFields}): trailing-newline strips / leading or + * trailing whitespace / blank-line runs do NOT count as semantic changes, + * so `wl sync` does not re-timestamp items whose meaningful content never + * changed (WL-0MSORD6HC005QVZX). The incoming (normalized) content is still + * persisted — only `updatedAt` is preserved. + * * @param items - The full set of work items to store. * @param dependencyEdges - Optional full set of dependency edges. When * provided, existing edges are cleared and replaced with these. @@ -2473,10 +2661,10 @@ export class WorklogDatabase { * existing items not in the provided array are preserved. * * **No-op guard**: For each item that already exists in the store AND has - * identical tracked fields (same field set as {@link hasWorkItemChanged}), - * the save is entirely skipped — preserving the existing `updatedAt`. - * Items whose tracked fields differ, or that are new, get a fresh - * `updatedAt` timestamp. + * identical tracked fields (same field set as {@link hasWorkItemChanged}, + * whitespace-insensitive for `title`/`description`), the save is entirely + * skipped — preserving the existing `updatedAt`. Items whose tracked fields + * differ, or that are new, get a fresh `updatedAt` timestamp. * * When `dependencyEdges` is provided, only edges whose `fromId` or `toId` * belongs to the provided items are upserted; all other edges are untouched. diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 0468f27f..3f024880 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -295,3 +295,20 @@ export interface ShowJsonOutput { // Optional error message used when success is false error?: string; } + +/** + * Result of demoting a parent work item after a child was added to it. + * + * A parent cannot stay `completed` (status) or `in_review` (stage) while it + * has uncompleted children, so adding a child to such a parent reopens it. + * This shape captures the lifecycle transition so callers can report it + * (e.g. `completed`/`in_review` → `open`/`plan_complete`). + */ +export interface DemotedParent { + /** The parent work item after the demotion */ + parent: WorkItem; + /** The parent's status/stage before the demotion */ + from: { status: string; stage: string }; + /** The parent's status/stage after the demotion */ + to: { status: string; stage: string }; +} diff --git a/packages/tui/extensions/Worklog/session-health.test.ts b/packages/tui/extensions/Worklog/session-health.test.ts index 1502f774..41237ff7 100644 --- a/packages/tui/extensions/Worklog/session-health.test.ts +++ b/packages/tui/extensions/Worklog/session-health.test.ts @@ -1119,20 +1119,86 @@ describe('session-health', () => { ); expect(lines.length).toBe(2); - // Format: " → │ " (no quotes) - expect(lines[1]).toContain('code → openai/gpt-4'); + // Format: " │ → " — command preview on + // the left, model identifier on the right (no quotes). expect(lines[1]).toContain('Fix the bug by adding validation'); + expect(lines[1]).toContain('code → openai/gpt-4'); + expect(lines[1]).toContain('Fix the bug by adding validation │ code → openai/gpt-4'); expect(lines[1]).not.toContain('"'); expect(lines[1]).toContain('[dim'); }); + it('gives the command preview the majority of the line width', () => { + mocks.mockGetResolvedModel.mockReturnValue('openai/gpt-4'); + mocks.mockGetSelectedModel.mockReturnValue('code'); + + const longPrompt = + 'Implement the authentication flow with refresh token rotation and session persistence across restarts'; + const lines = fabricateFooterLines( + { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `[${c}${t}]`) } } }, + longPrompt, + 500, + ); + + expect(lines.length).toBe(2); + // Command budget = width(500) − model(17) − separator(5) = 478, so the + // prompt fits untruncated and the model sits after the separator at the + // end of the line. + expect(lines[1]).toContain(`${longPrompt} │ code → openai/gpt-4`); + + // Measure the two parts around the │ separator: the command part must + // be (a) the full untruncated prompt and (b) longer than the model part. + // (The mock theme wraps the label as `[dim` + label + `]`.) + const content = lines[1].replace(/^\[dim/, '').replace(/\]$/, ''); + const sepIndex = content.indexOf('│'); + const commandPart = content.slice(0, sepIndex).trim(); + const modelPart = content.slice(sepIndex + 1).trim(); + expect(commandPart).toBe(longPrompt); + expect(modelPart).toBe('code → openai/gpt-4'); + expect(commandPart.length).toBeGreaterThan(modelPart.length); + }); + + it('caps the model identifier width so the command preview gets the space', () => { + mocks.mockGetResolvedModel.mockReturnValue('openai/gpt-4-abcdefghijklmnopqrstuvwxyz'); + mocks.mockGetSelectedModel.mockReturnValue('code'); + + const lines = fabricateFooterLines( + { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `[${c}${t}]`) } } }, + 'Fix the bug', + 500, + ); + + // modelPart = "code → openai/gpt-4-abcdefghijklmnopqrstuvwxyz" (46 cols) + // → capped to 30 visible columns; the rest of the line goes to the prompt. + expect(lines.length).toBe(2); + expect(lines[1]).toContain('Fix the bug │ code → openai/gpt-4-abcdefghij'); + expect(lines[1]).not.toContain('qrstuvwxyz'); + }); + + it('never overflows the line at very narrow widths', () => { + mocks.mockGetResolvedModel.mockReturnValue('openai/gpt-4-abcdefghijklmnopqrstuvwxyz'); + mocks.mockGetSelectedModel.mockReturnValue('code'); + + // Width 20 < model cap (30) + separator (5): the command budget is 0, + // the label overflows, and the final clamp keeps the line at 20 cols. + const lines = fabricateFooterLines( + { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `[${c}${t}]`) } } }, + 'Fix the bug by adding validation', + 20, + ); + + expect(lines.length).toBe(2); + expect(lines[1].length).toBeLessThanOrEqual(20); + }); + it('truncates long initial prompt preview', () => { mocks.mockGetResolvedModel.mockReturnValue(null); mocks.mockGetSelectedModel.mockReturnValue('code'); const longPrompt = 'Write a comprehensive test suite for the authentication module including all edge cases like token expiry and refresh'; // Use a narrow terminal width (120) to trigger truncation - // With width=120, reserved=38, available=max(15, 120-38)=82, prompt length=151 > 82 + // With width=120, model="code → (resolving)" (19 cols), separator=5, + // available=max(15, 120-19-5)=96, prompt length=151 > 96 const lines = fabricateFooterLines( { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `[${c}${t}]`) } } }, longPrompt, @@ -1140,9 +1206,11 @@ describe('session-health', () => { ); expect(lines.length).toBe(2); - // Should show alias + truncated prompt with ... - expect(lines[1]).toContain('code'); - expect(lines[1]).toContain('...'); + // Truncated prompt with ... followed by the model identifier after the + // separator; the final clamp keeps the line at most `width` columns + // (may clip the model tail at very narrow widths — documented edge case). + expect(lines[1]).toContain('... │ code →'); + expect(lines[1].length).toBeLessThanOrEqual(120); expect(lines[1]).toContain('[dim'); }); diff --git a/packages/tui/extensions/Worklog/session-health.ts b/packages/tui/extensions/Worklog/session-health.ts index 133416b1..68bb178b 100644 --- a/packages/tui/extensions/Worklog/session-health.ts +++ b/packages/tui/extensions/Worklog/session-health.ts @@ -621,6 +621,16 @@ export function registerSessionHealth(pi: ExtensionAPI): void { modelPart = '—'; } + // Cap the model identifier at a fixed maximum so the command + // preview gets as much of the line as possible. truncateToTerminalWidth + // appends an ellipsis when the model part exceeds the cap. + const modelPartMaxWidth = 30; + const cappedModelPart = truncateToTerminalWidth(modelPart, modelPartMaxWidth); + + // Separator between the command preview (left) and the model + // identifier (right). + const separator = ' │ '; + // Build initial prompt portion — unquoted preview with generous // space allocation. The line is ultimately truncated to `width` by // truncateToTerminalWidth, so we use a dynamic limit. @@ -629,18 +639,21 @@ export function registerSessionHealth(pi: ExtensionAPI): void { // Truncate work item IDs to compact form (e.g., WL-0MQL0T5TR0060AEH // → WL...68HD) before applying length truncation. const compacted = truncateWorkItemId(initialPrompt); - // Reserve space for model part (up to ~30 chars), separator (4), - // and a small buffer. Then clip at the end. - const reserved = 38; - const maxLen = Math.max(15, width - reserved); + // Reserve only what the (capped) model part and separator need; + // everything else goes to the command preview. The final + // truncateToTerminalWidth(…, width) clamp below still guards + // against overflow at very narrow widths. + const separatorWidth = visibleWidth(separator); + const modelWidth = visibleWidth(cappedModelPart); + const maxLen = Math.max(0, width - modelWidth - separatorWidth); const preview = - compacted.length > maxLen + compacted.length > maxLen && maxLen > 3 ? `${compacted.slice(0, maxLen - 3)}...` : compacted; promptPart = preview; } - const label = promptPart ? `${modelPart} │ ${promptPart}` : modelPart; + const label = promptPart ? `${promptPart}${separator}${cappedModelPart}` : modelPart; lines.push(truncateToTerminalWidth(theme.fg('dim', label), width)); return lines; diff --git a/src/commands/create.ts b/src/commands/create.ts index 8e9e834c..a0d12fff 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -4,7 +4,7 @@ import type { PluginContext } from '../plugin-types.js'; import type { CreateOptions } from '../cli-types.js'; -import type { WorkItemStatus, WorkItemPriority, WorkItemRiskLevel, WorkItemEffortLevel } from '../types.js'; +import type { WorkItemStatus, WorkItemPriority, WorkItemRiskLevel, WorkItemEffortLevel, DemotedParent } from '../types.js'; import { humanFormatWorkItem, resolveFormat } from './helpers.js'; import { canValidateStatusStage, validateStatusStageCompatibility, validateStatusStageInput } from './status-stage-validation.js'; import { promises as fs } from 'fs'; @@ -151,12 +151,14 @@ export default function register(ctx: PluginContext): void { }; } + const parentId = utils.normalizeCliId(options.parent, options.prefix) || null; + const item = db.createWithNextSortIndex({ title: options.title, description: description, status: normalizedStatus as WorkItemStatus, priority: (options.priority || 'medium') as WorkItemPriority, - parentId: utils.normalizeCliId(options.parent, options.prefix) || null, + parentId, tags: options.tags ? options.tags.split(',').map((t: string) => t.trim()) : [], assignee: options.assignee || '', stage: normalizedStage, @@ -177,6 +179,19 @@ export default function register(ctx: PluginContext): void { db.saveAuditResult(auditResultData); } + // A parent cannot stay `completed`/`in_review` while it gains a new, + // uncompleted child: demote it to `open`/`plan_complete` so its + // lifecycle state reflects that its subtree is not finished. + let demotedParent: DemotedParent | null = null; + if (parentId) { + try { + demotedParent = db.demoteParentOnChildAdded(parentId); + } catch (err) { + // Best-effort: a demotion failure must not abort the create. + console.error(`Warning: failed to demote parent ${parentId}: ${err instanceof Error ? err.message : String(err)}`); + } + } + const refreshed = db.get(item.id) || item; // Include audit data in JSON output when audit was provided @@ -186,10 +201,17 @@ export default function register(ctx: PluginContext): void { } if (utils.isJsonMode()) { - output.json({ success: true, workItem: refreshed }); + output.json({ + success: true, + workItem: refreshed, + ...(demotedParent ? { demotedParent } : {}), + }); } else { const format = resolveFormat(program); console.log(humanFormatWorkItem(refreshed, db, format)); + if (demotedParent) { + console.log(`[Parent ${demotedParent.parent.id} demoted from ${demotedParent.from.status}/${demotedParent.from.stage} to ${demotedParent.to.status}/${demotedParent.to.stage}]`); + } } // Trigger re-sort after create only when the create modified one of the // impactful fields (status, priority, risk, effort, stage). Honor caller diff --git a/src/commands/next.ts b/src/commands/next.ts index f5c0c579..e90d12cf 100644 --- a/src/commands/next.ts +++ b/src/commands/next.ts @@ -21,6 +21,8 @@ export default function register(ctx: PluginContext): void { .description('Find the next work item to work on based on priority and status (excludes dependency-blocked items by default)') .option('-a, --assignee ', 'Filter by assignee') .option('--stage ', 'Filter by stage (idea, intake_complete, plan_complete, in_progress, in_review, done)') + .option('--risk ', 'Filter by risk level, at-most semantics (low, medium, high, severe). Items with unset risk never match.') + .option('--effort ', 'Filter by effort level, at-most semantics (xs/extra-small, s/small, m/medium, l/large, xl/extra-large). Items with unset effort never match.') .option('--search ', 'Search term for fuzzy matching against title, description, and comments') .option('-n, --number ', 'Number of items to return (default: 1)', '1') .option('--prefix ', 'Override the default prefix') @@ -32,7 +34,7 @@ export default function register(ctx: PluginContext): void { .option('-g, --groups ', 'Number of parallel-safe groups to identify (default: 3, only meaningful when -n > 1)', '3') .action(async (...rawArgs: any[]) => { // Normalize incoming args: commander may pass a Command instance - const normalized = normalizeActionArgs(rawArgs, ['assignee', 'stage', 'search', 'number', 'prefix', 'includeBlocked', 'includeInProgress', 'reSort', 'reSortSync', 'recencyPolicy', 'groups']); + const normalized = normalizeActionArgs(rawArgs, ['assignee', 'stage', 'search', 'number', 'prefix', 'includeBlocked', 'includeInProgress', 'reSort', 'reSortSync', 'recencyPolicy', 'groups', 'risk', 'effort']); let options: any = normalized.options || {}; utils.requireInitialized(); const db = utils.getDatabase(options.prefix); @@ -53,6 +55,26 @@ export default function register(ctx: PluginContext): void { options.stage = normalizedStage; } + // Validate risk/effort levels (at-most ordinal filters). Invalid levels + // fail closed at the CLI boundary — the shared filterCandidates pipeline + // also fails closed (matches nothing) as a belt-and-suspenders guard. + const VALID_RISK_LEVELS = new Set(['low', 'medium', 'high', 'severe', 'critical']); + const VALID_EFFORT_LEVELS = new Set(['xs', 'extra-small', 'extrasmall', 's', 'small', 'm', 'medium', 'l', 'large', 'xl', 'extra-large', 'extralarge']); + if (options.risk) { + const normalizedRisk = options.risk.toLowerCase().trim(); + if (!VALID_RISK_LEVELS.has(normalizedRisk)) { + output.error(`Invalid risk: "${options.risk}". Valid levels: low, medium, high, severe, critical.`, { success: false, error: `Invalid risk: "${options.risk}"` }); + process.exit(1); + } + } + if (options.effort) { + const normalizedEffort = options.effort.toLowerCase().trim().replace(/\s+/g, '-'); + if (!VALID_EFFORT_LEVELS.has(normalizedEffort)) { + output.error(`Invalid effort: "${options.effort}". Valid levels: xs/extra-small, s/small, m/medium, l/large, xl/extra-large.`, { success: false, error: `Invalid effort: "${options.effort}"` }); + process.exit(1); + } + } + // Auto re-sort unless --no-re-sort is passed. Commander exposes // the flag as `reSort: false` (for --no-re-sort) in some contexts // and some callers/tools may surface `noReSort` instead. Accept @@ -89,8 +111,8 @@ export default function register(ctx: PluginContext): void { } const results = (db as any).findNextWorkItems - ? (db as any).findNextWorkItems(count, options.assignee, options.search, includeBlocked, options.stage, includeInProgress) - : [db.findNextWorkItem(options.assignee, options.search, includeBlocked, options.stage, includeInProgress)]; + ? (db as any).findNextWorkItems(count, options.assignee, options.search, includeBlocked, options.stage, includeInProgress, options.risk, options.effort) + : [db.findNextWorkItem(options.assignee, options.search, includeBlocked, options.stage, includeInProgress, options.risk, options.effort)]; const availableResults = results.filter((result: any) => Boolean(result.workItem)); const missingCount = Math.max(0, count - availableResults.length); diff --git a/src/commands/update.ts b/src/commands/update.ts index e732fc7e..eacf8a53 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -4,7 +4,7 @@ import type { PluginContext } from '../plugin-types.js'; import type { UpdateOptions } from '../cli-types.js'; -import type { UpdateWorkItemInput, WorkItem, WorkItemStatus, WorkItemPriority, WorkItemRiskLevel, WorkItemEffortLevel } from '../types.js'; +import type { UpdateWorkItemInput, WorkItem, WorkItemStatus, WorkItemPriority, WorkItemRiskLevel, WorkItemEffortLevel, DemotedParent } from '../types.js'; import { promises as fs } from 'fs'; import { humanFormatWorkItem, resolveFormat, extractFilePaths } from './helpers.js'; import { canValidateStatusStage, validateStatusStageCompatibility, validateStatusStageInput } from './status-stage-validation.js'; @@ -403,6 +403,19 @@ export default function register(ctx: PluginContext): void { downgradedChildren = db.cascadePriorityDowngrade(normalizedId, priorityCandidate as WorkItemPriority); } + // Reparenting: a parent cannot stay `completed`/`in_review` while a + // new, uncompleted child is attached to it. Demote the target parent + // to `open`/`plan_complete` so its lifecycle state stays consistent. + let demotedParent: DemotedParent | null = null; + if (updates.parentId) { + try { + demotedParent = db.demoteParentOnChildAdded(updates.parentId); + } catch (err) { + // Best-effort: a demotion failure must not abort the update. + console.error(`Warning: failed to demote parent ${updates.parentId}: ${err instanceof Error ? err.message : String(err)}`); + } + } + // Mark that an impactful change was made if any qualifying fields were // included in this per-id update. if (updates.status || updates.priority || updates.risk || updates.effort || updates.stage) { @@ -432,6 +445,7 @@ export default function register(ctx: PluginContext): void { success: true, workItem: item, ...(downgradedChildren.length > 0 ? { downgradedChildren } : {}), + ...(demotedParent ? { demotedParent } : {}), }); } @@ -448,6 +462,9 @@ export default function register(ctx: PluginContext): void { if (r.downgradedChildren?.length) { jsonOut.downgradedChildren = r.downgradedChildren; } + if (r.demotedParent) { + jsonOut.demotedParent = r.demotedParent; + } output.json(jsonOut); } else { @@ -466,6 +483,9 @@ export default function register(ctx: PluginContext): void { if (r.downgradedChildren?.length) { console.log(`[Downgraded ${r.downgradedChildren.length} child(ren) from critical to high]`); } + if (r.demotedParent) { + console.log(`[Parent ${r.demotedParent.parent.id} demoted from ${r.demotedParent.from.status}/${r.demotedParent.from.stage} to ${r.demotedParent.to.status}/${r.demotedParent.to.stage}]`); + } } else { output.error(r.error, { success: false, error: r.error }); } diff --git a/src/sync.ts b/src/sync.ts index c10a2e9c..a3d3ab06 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -261,6 +261,21 @@ export function filterRemoteDataByPrefix( }; } +/** + * Terminal (closed) workflow stages. An item with `completed` status and a + * terminal stage is a protected close state: it must never be silently + * reverted by a stale remote `open`/`in_progress` copy during merge. + * + * `in_review` is the standard 'ready for review' state after a pi audit — a + * closed state until manually reopened (WL-0MSPZP7FE009YXPG). + * + * Whitelist per approved plan: {done, in_review}. Custom stages are NOT + * protected and fall back to normal timestamp/lexicographic resolution. + */ +export function isTerminalStage(stage: string | undefined): boolean { + return stage === 'done' || stage === 'in_review'; +} + function mergeSameTimestampItems( localItem: WorkItem, remoteItem: WorkItem, @@ -299,13 +314,13 @@ function mergeSameTimestampItems( const localIsDefault = isDefaultValue(localValue, field, options); const remoteIsDefault = isDefaultValue(remoteValue, field, options); - // Special handling for close state (status=completed + stage=done): + // Special handling for close state (status=completed + terminal stage): // When one version has a close and the other has a different non-close status/stage, // prefer the close values. This prevents an unrelated field change on a different // client from silently reverting a close operation. if (field === 'status') { - const localIsClose = localValue === 'completed' && (localItem.stage === 'done' || remoteItem.stage === 'done'); - const remoteIsClose = remoteValue === 'completed' && (remoteItem.stage === 'done' || localItem.stage === 'done'); + const localIsClose = localValue === 'completed' && (isTerminalStage(localItem.stage) || isTerminalStage(remoteItem.stage)); + const remoteIsClose = remoteValue === 'completed' && (isTerminalStage(remoteItem.stage) || isTerminalStage(localItem.stage)); if (localIsClose && !remoteIsClose) { (merged as any)[field] = localValue; mergedFields.push(`${field} (close preserved from local)`); @@ -334,8 +349,8 @@ function mergeSameTimestampItems( } } if (field === 'stage') { - const localIsCloseStage = localValue === 'done' && (localItem.status === 'completed' || remoteItem.status === 'completed'); - const remoteIsCloseStage = remoteValue === 'done' && (remoteItem.status === 'completed' || localItem.status === 'completed'); + const localIsCloseStage = isTerminalStage(localValue as string) && (localItem.status === 'completed' || remoteItem.status === 'completed'); + const remoteIsCloseStage = isTerminalStage(remoteValue as string) && (remoteItem.status === 'completed' || localItem.status === 'completed'); if (localIsCloseStage && !remoteIsCloseStage) { (merged as any)[field] = localValue; mergedFields.push(`${field} (close preserved from local)`); @@ -486,7 +501,7 @@ function mergeDifferentTimestampItems( continue; } - // Special handling for close state (status=completed + stage=done): + // Special handling for close state (status=completed + terminal stage): // When one version has a close and the other has a different non-close status/stage, // prefer the close values. This prevents an unrelated field change on a different // client from silently reverting a close operation. @@ -495,8 +510,8 @@ function mergeDifferentTimestampItems( // only applied when isRemoteNewer is true. If local is newer, the local intent // (e.g., reopening a closed item) is respected rather than the remote close. if (field === 'status') { - const localIsClose = localValue === 'completed' && (localItem.stage === 'done' || remoteItem.stage === 'done'); - const remoteIsClose = remoteValue === 'completed' && (remoteItem.stage === 'done' || localItem.stage === 'done'); + const localIsClose = localValue === 'completed' && (isTerminalStage(localItem.stage) || isTerminalStage(remoteItem.stage)); + const remoteIsClose = remoteValue === 'completed' && (isTerminalStage(remoteItem.stage) || isTerminalStage(localItem.stage)); if (localIsClose && !remoteIsClose) { (merged as any)[field] = localValue; mergedFields.push(`${field} (close preserved from local)`); @@ -531,8 +546,8 @@ function mergeDifferentTimestampItems( } } if (field === 'stage') { - const localIsCloseStage = localValue === 'done' && (localItem.status === 'completed' || remoteItem.status === 'completed'); - const remoteIsCloseStage = remoteValue === 'done' && (remoteItem.status === 'completed' || localItem.status === 'completed'); + const localIsCloseStage = isTerminalStage(localValue as string) && (localItem.status === 'completed' || remoteItem.status === 'completed'); + const remoteIsCloseStage = isTerminalStage(remoteValue as string) && (remoteItem.status === 'completed' || localItem.status === 'completed'); if (localIsCloseStage && !remoteIsCloseStage) { (merged as any)[field] = localValue; mergedFields.push(`${field} (close preserved from local)`); diff --git a/src/types.ts b/src/types.ts index 9830d420..4ed7dd5a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,4 +24,5 @@ export { type ConflictDetail, type NextWorkItemResult, type ShowJsonOutput, + type DemotedParent, } from '@worklog/shared/types'; diff --git a/src/version.ts b/src/version.ts index d0f04f93..831d85f9 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ // Auto-generated; do not edit. -export const WORKLOG_VERSION = '1.0.8'; +export const WORKLOG_VERSION = '1.0.9'; diff --git a/tests/cli/create-update-demote-parent.test.ts b/tests/cli/create-update-demote-parent.test.ts new file mode 100644 index 00000000..d965dc86 --- /dev/null +++ b/tests/cli/create-update-demote-parent.test.ts @@ -0,0 +1,176 @@ +/** + * Tests for parent demotion when a child is added to a completed/in_review parent. + * + * `wl create --parent ` and `wl update --parent ` demote the + * target parent from `completed`/`in_review` to `open`/`plan_complete`, so a + * completed parent never silently gains uncompleted children. The demotion is + * reported in JSON output (`demotedParent`) and as a human summary line. + * + * Work item: WL-0MSJL00P5004Y0L6 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + cliPath, + execAsync, + enterTempDir, + leaveTempDir, + writeConfig, + writeInitSemaphore, +} from './cli-helpers.js'; + +describe('parent demotion on child add', () => { + let tempState: { tempDir: string; originalCwd: string }; + + beforeEach(() => { + tempState = enterTempDir(); + writeConfig(tempState.tempDir, 'Test Project', 'TEST'); + writeInitSemaphore(tempState.tempDir); + }); + + afterEach(() => { + leaveTempDir(tempState); + }); + + async function createItem(flags = ''): Promise { + const { stdout } = await execAsync( + `tsx ${cliPath} --json create -t "Item" ${flags}` + ); + return JSON.parse(stdout).workItem.id; + } + + async function showItem(id: string): Promise { + const { stdout } = await execAsync(`tsx ${cliPath} --json show ${id}`); + return JSON.parse(stdout).workItem; + } + + // ======================================================================= + // wl create --parent + // ======================================================================= + describe('wl create --parent', () => { + it('should demote a completed/in_review parent and report it in JSON output', async () => { + const parentId = await createItem('--status completed --stage in_review'); + + const { stdout } = await execAsync( + `tsx ${cliPath} --json create -t "Child" --parent ${parentId}` + ); + const result = JSON.parse(stdout); + + expect(result.success).toBe(true); + expect(result.demotedParent).toBeDefined(); + expect(result.demotedParent.parent.id).toBe(parentId); + expect(result.demotedParent.from).toEqual({ status: 'completed', stage: 'in_review' }); + expect(result.demotedParent.to).toEqual({ status: 'open', stage: 'plan_complete' }); + + // Parent demoted in the DB + const parent = await showItem(parentId); + expect(parent.status).toBe('open'); + expect(parent.stage).toBe('plan_complete'); + }); + + it('should demote a completed/done parent', async () => { + const parentId = await createItem('--status completed --stage done'); + + const { stdout } = await execAsync( + `tsx ${cliPath} --json create -t "Child" --parent ${parentId}` + ); + const result = JSON.parse(stdout); + + expect(result.demotedParent).toBeDefined(); + expect(result.demotedParent.from).toEqual({ status: 'completed', stage: 'done' }); + expect(result.demotedParent.to).toEqual({ status: 'open', stage: 'plan_complete' }); + }); + + it('should not demote a parent that is not completed/in_review', async () => { + const parentId = await createItem('--status open --stage plan_complete'); + + const { stdout } = await execAsync( + `tsx ${cliPath} --json create -t "Child" --parent ${parentId}` + ); + const result = JSON.parse(stdout); + + expect(result.success).toBe(true); + expect(result.demotedParent).toBeUndefined(); + + const parent = await showItem(parentId); + expect(parent.status).toBe('open'); + expect(parent.stage).toBe('plan_complete'); + }); + + it('should print a human-readable summary line on demotion', async () => { + const parentId = await createItem('--status completed --stage in_review'); + + const { stdout } = await execAsync( + `tsx ${cliPath} create -t "Child" --parent ${parentId}` + ); + + expect(stdout).toContain( + `[Parent ${parentId} demoted from completed/in_review to open/plan_complete]` + ); + }); + + it('should not print a summary line when no demotion occurs', async () => { + const parentId = await createItem('--status open --stage idea'); + + const { stdout } = await execAsync( + `tsx ${cliPath} create -t "Child" --parent ${parentId}` + ); + + expect(stdout).not.toContain('demoted from'); + }); + }); + + // ======================================================================= + // wl update --parent (reparenting) + // ======================================================================= + describe('wl update --parent (reparenting)', () => { + it('should demote the target parent when reparenting under a completed parent', async () => { + const parentId = await createItem('--status completed --stage in_review'); + const childId = await createItem('--status open --stage idea'); + + const { stdout } = await execAsync( + `tsx ${cliPath} --json update ${childId} --parent ${parentId}` + ); + const result = JSON.parse(stdout); + + expect(result.success).toBe(true); + expect(result.demotedParent).toBeDefined(); + expect(result.demotedParent.parent.id).toBe(parentId); + expect(result.demotedParent.from).toEqual({ status: 'completed', stage: 'in_review' }); + expect(result.demotedParent.to).toEqual({ status: 'open', stage: 'plan_complete' }); + + // Parent demoted and child attached in the DB + const parent = await showItem(parentId); + expect(parent.status).toBe('open'); + expect(parent.stage).toBe('plan_complete'); + const child = await showItem(childId); + expect(child.parentId).toBe(parentId); + }); + + it('should not demote when reparenting under an open parent', async () => { + const parentId = await createItem('--status open --stage idea'); + const childId = await createItem('--status open --stage idea'); + + const { stdout } = await execAsync( + `tsx ${cliPath} --json update ${childId} --parent ${parentId}` + ); + const result = JSON.parse(stdout); + + expect(result.success).toBe(true); + expect(result.demotedParent).toBeUndefined(); + }); + + it('should print a human-readable summary line on reparenting demotion', async () => { + const parentId = await createItem('--status completed --stage done'); + const childId = await createItem('--status open --stage idea'); + + const { stdout } = await execAsync( + `tsx ${cliPath} update ${childId} --parent ${parentId}` + ); + + expect(stdout).toContain( + `[Parent ${parentId} demoted from completed/done to open/plan_complete]` + ); + }); + }); +}); diff --git a/tests/cli/next-risk-effort-cli.test.ts b/tests/cli/next-risk-effort-cli.test.ts new file mode 100644 index 00000000..cedaf79a --- /dev/null +++ b/tests/cli/next-risk-effort-cli.test.ts @@ -0,0 +1,118 @@ +/** + * CLI-level tests for `wl next --risk/--effort` at-most filters + * (WL-0MSMAIP5F003WAGG). Verifies the exact invocation the herdr + * downtime implement tier uses: + * `wl next --stage plan_complete --risk low --effort small -n N --json` + * and the fail-closed behavior for invalid filter levels. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { writeConfig, writeInitSemaphore, execAsync } from './cli-helpers.js'; +import { createTempDir, cleanupTempDir } from '../test-utils.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'); + +describe('wl next --risk/--effort CLI (WL-0MSMAIP5F003WAGG)', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + writeConfig(tempDir, 'Test Project', 'TEST'); + writeInitSemaphore(tempDir); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + async function createItem(args: string[]): Promise { + const { stderr } = await execAsync(`tsx ${cliPath} --json create ${args.join(' ')}`, { cwd: tempDir }); + if (stderr && stderr.includes('Error')) { + throw new Error(`create failed: ${stderr}`); + } + } + + async function createItemGetId(args: string[]): Promise { + const { stdout } = await execAsync(`tsx ${cliPath} --json create ${args.join(' ')}`, { cwd: tempDir }); + const parsed = JSON.parse(stdout); + return parsed.workItem.id; + } + + async function runNext(args: string[]): Promise<{ stdout: string; stderr: string; code: number }> { + try { + const res = await execAsync(`tsx ${cliPath} --json next ${args.join(' ')}`, { cwd: tempDir }); + return { stdout: res.stdout, stderr: res.stderr, code: 0 }; + } catch (e: any) { + return { stdout: e.stdout ?? '', stderr: e.stderr ?? '', code: e.exitCode ?? 1 }; + } + } + + it('--risk low --effort small selects only matching plan_complete items', async () => { + await createItem(['-t', '"Eligible"', '--stage', 'plan_complete', '--risk', 'Low', '--effort', 'Small', '--no-re-sort']); + await createItem(['-t', '"MediumRisk"', '--stage', 'plan_complete', '--risk', 'Medium', '--effort', 'Small', '--no-re-sort']); + await createItem(['-t', '"MediumEffort"', '--stage', 'plan_complete', '--risk', 'Low', '--effort', 'Medium', '--no-re-sort']); + await createItem(['-t', '"WrongStage"', '--stage', 'idea', '--risk', 'Low', '--effort', 'Small', '--no-re-sort']); + + const { stdout } = await runNext(['--stage', 'plan_complete', '--risk', 'low', '--effort', 'small', '-n', '10']); + const parsed = JSON.parse(stdout); + const ids = parsed.workItems?.map((r: any) => r.workItem?.id) ?? []; + expect(ids.length).toBe(1); + const item = parsed.workItems[0].workItem; + expect(item.title).toBe('Eligible'); + expect(item.risk).toBe('Low'); + expect(item.effort).toBe('Small'); + }); + + it('accepts XS/extra-small effort spellings', async () => { + await createItem(['-t', '"XS item"', '--stage', 'plan_complete', '--risk', 'Low', '--effort', 'XS', '--no-re-sort']); + await createItem(['-t', '"S item"', '--stage', 'plan_complete', '--risk', 'Low', '--effort', 'S', '--no-re-sort']); + + const { stdout } = await runNext(['--stage', 'plan_complete', '--risk', 'low', '--effort', 'xs', '-n', '10']); + const parsed = JSON.parse(stdout); + const ids = parsed.workItems?.map((r: any) => r.workItem?.id) ?? []; + expect(ids.length).toBe(1); + expect(parsed.workItems[0].workItem.title).toBe('XS item'); + }); + + it('invalid risk level fails closed with an error', async () => { + const { stderr, code } = await runNext(['--risk', 'bogus']); + expect(code).toBe(1); + expect(stderr).toContain('Invalid risk'); + }); + + it('invalid effort level fails closed with an error', async () => { + const { stderr, code } = await runNext(['--effort', 'bogus']); + expect(code).toBe(1); + expect(stderr).toContain('Invalid effort'); + }); + + it('no flags behavior is unchanged', async () => { + await createItem(['-t', '"A"', '-p', 'high', '--no-re-sort']); + await createItem(['-t', '"B"', '-p', 'medium', '--no-re-sort']); + const { stdout } = await runNext([]); + const parsed = JSON.parse(stdout); + expect(parsed.workItem?.title).toBe('A'); + }); + + it('dependency-blocked plan_complete Low/Small items are excluded by the implement-tier invocation', async () => { + // Fixture: an eligible blocker and an eligible item that depends on it. + // wl next excludes dependency-blocked items by default (includeBlocked=false), + // so the blocked item must never appear in the implement tier's candidate list. + const blockerId = await createItemGetId(['-t', '"Blocker"', '--stage', 'plan_complete', '--risk', 'Low', '--effort', 'Small', '--no-re-sort']); + const blockedId = await createItemGetId(['-t', '"Blocked"', '--stage', 'plan_complete', '--risk', 'Low', '--effort', 'Small', '--no-re-sort']); + await execAsync(`tsx ${cliPath} --json dep add ${blockedId} ${blockerId}`, { cwd: tempDir }); + + // Exact implement-tier invocation (packages/herdr/src/index.ts getNextImplementCandidate). + const { stdout } = await runNext(['--stage', 'plan_complete', '--risk', 'low', '--effort', 'small', '-n', '10']); + const parsed = JSON.parse(stdout); + const ids = parsed.workItems?.map((r: any) => r.workItem?.id) ?? []; + expect(ids).toContain(blockerId); // the blocker is itself eligible + expect(ids).not.toContain(blockedId); // blocked candidates never reach the dispatch layer + }); +}); diff --git a/tests/database.test.ts b/tests/database.test.ts index 74e65e93..21244193 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -530,6 +530,78 @@ describe('WorklogDatabase', () => { }); }); + describe('demoteParentOnChildAdded', () => { + it('should demote a completed/in_review parent to open/plan_complete', () => { + const parent = db.create({ title: 'Parent', status: 'completed', stage: 'in_review' }); + + const demoted = db.demoteParentOnChildAdded(parent.id); + + expect(demoted).not.toBeNull(); + expect(demoted!.parent.id).toBe(parent.id); + expect(demoted!.from).toEqual({ status: 'completed', stage: 'in_review' }); + expect(demoted!.to).toEqual({ status: 'open', stage: 'plan_complete' }); + // Persisted in the DB + const refreshed = db.get(parent.id); + expect(refreshed?.status).toBe('open'); + expect(refreshed?.stage).toBe('plan_complete'); + }); + + it('should demote a completed/done parent to open/plan_complete', () => { + const parent = db.create({ title: 'Parent', status: 'completed', stage: 'done' }); + + const demoted = db.demoteParentOnChildAdded(parent.id); + + expect(demoted).not.toBeNull(); + expect(demoted!.from).toEqual({ status: 'completed', stage: 'done' }); + expect(demoted!.to).toEqual({ status: 'open', stage: 'plan_complete' }); + expect(db.get(parent.id)?.status).toBe('open'); + expect(db.get(parent.id)?.stage).toBe('plan_complete'); + }); + + it('should demote a parent in the in_review stage regardless of status', () => { + // in_review normally pairs with completed, but the rule keys on the + // stage axis too, so a parent stuck at stage in_review is demoted. + const parent = db.create({ title: 'Parent', status: 'completed', stage: 'in_review' }); + + const demoted = db.demoteParentOnChildAdded(parent.id); + + expect(demoted).not.toBeNull(); + expect(demoted!.to).toEqual({ status: 'open', stage: 'plan_complete' }); + }); + + it('should be a no-op for parents that are not completed/in_review', () => { + const openParent = db.create({ title: 'Open parent', status: 'open', stage: 'plan_complete' }); + const inProgressParent = db.create({ title: 'In-progress parent', status: 'in-progress', stage: 'in_progress' }); + const ideaParent = db.create({ title: 'Idea parent', status: 'open', stage: 'idea' }); + + expect(db.demoteParentOnChildAdded(openParent.id)).toBeNull(); + expect(db.demoteParentOnChildAdded(inProgressParent.id)).toBeNull(); + expect(db.demoteParentOnChildAdded(ideaParent.id)).toBeNull(); + // Untouched + expect(db.get(openParent.id)?.status).toBe('open'); + expect(db.get(openParent.id)?.stage).toBe('plan_complete'); + expect(db.get(inProgressParent.id)?.status).toBe('in-progress'); + expect(db.get(ideaParent.id)?.stage).toBe('idea'); + }); + + it('should return null for a missing parent', () => { + expect(db.demoteParentOnChildAdded('TEST-NONEXISTENT')).toBeNull(); + }); + + it('should only demote the direct parent, leaving ancestors untouched', () => { + const grandparent = db.create({ title: 'Grandparent', status: 'completed', stage: 'done' }); + const parent = db.create({ title: 'Parent', status: 'completed', stage: 'done', parentId: grandparent.id }); + + const demoted = db.demoteParentOnChildAdded(parent.id); + + expect(demoted?.parent.id).toBe(parent.id); + expect(db.get(parent.id)?.status).toBe('open'); + // Ancestor propagation is out of scope — grandparent stays completed + expect(db.get(grandparent.id)?.status).toBe('completed'); + expect(db.get(grandparent.id)?.stage).toBe('done'); + }); + }); + describe('comments', () => { let workItemId: string; diff --git a/tests/herdr/errors.test.ts b/tests/herdr/errors.test.ts index 1acc1202..ff01dcd5 100644 --- a/tests/herdr/errors.test.ts +++ b/tests/herdr/errors.test.ts @@ -171,13 +171,14 @@ describe('handleKeypress edge cases', () => { expect(state.detailItem?.id).toBe('WL-001'); }); - it('maps uppercase S to the sync action', () => { + it('does not map uppercase S to the sync action anymore (manual sync removed, WL-0MSGG5N5Z0074TLY)', () => { const items = [makeItem('WL-001')]; const state = new WorkItemListState(items, defaultTermSize); - // 'S' (uppercase) triggers a manual sync; must not be treated as quit or - // an unrecognized key. + // The manual-sync 'S' binding was removed; S now resolves via the + // ShortcutRegistry (Ship It confirmation dialog). Without a registry it + // is an unrecognized key (null), never 'sync' and never quit. const action = handleKeypress(state, 'S', defaultTermSize); - expect(action).toBe('sync'); + expect(action).toBeNull(); }); }); diff --git a/tests/next-regression.test.ts b/tests/next-regression.test.ts index c4ae03f8..12953a13 100644 --- a/tests/next-regression.test.ts +++ b/tests/next-regression.test.ts @@ -2004,4 +2004,109 @@ describe('wl next regression tests (WL-0MM2FKKOW1H0C0G4)', () => { } }); }); + + // ───────────────────────────────────────────────────────────────────── + // Regression: Stage filter skips blocker surfacing (WL-0MSP1XJSO007LE3K) + // RCA of RW-0MSAD6FX7001AAV9: the herdr plan tier dispatches /skill:plan + // on whatever `wl next --stage intake_complete --json` returns. Stage 3 + // (non-critical blocker surfacing) ran even with a stage filter and + // surfaced a plan_complete epic as the dependency blocker of an + // intake_complete/blocked child, so /skill:plan was dispatched on the + // epic every downtime cycle. Stage 2 (critical escalation) already has a + // `!stage` guard; Stage 3 must mirror it. + // ───────────────────────────────────────────────────────────────────── + describe('stage filter skips blocker surfacing (WL-0MSP1XJSO007LE3K)', () => { + it('should NOT surface a plan_complete epic blocker under --stage intake_complete', () => { + // 6abcb44e-like RCA state (RW-0MSAD6FX7001AAV9): a plan_complete/open + // epic (sortIndex 200) is the dependency blocker of an + // intake_complete/blocked high-priority child that also depends on an + // open intake_complete item. The open intake_complete item keeps Stage + // 3's `filteredItems.length > 0` gate satisfied, so previously the epic + // was surfaced with 'Blocking issue for high-priority item ...' — the + // epic must never be returned by a stage-filtered next. + const epic = db.create({ + title: 'Plan complete epic', + status: 'open', + stage: 'plan_complete', + issueType: 'epic', + sortIndex: 200, + }); + const blockedChild = db.create({ + title: 'Intake child blocked', + status: 'blocked', + stage: 'intake_complete', + priority: 'high', + parentId: epic.id, + }); + const otherDep = db.create({ + title: 'Other dependency (intake open)', + status: 'open', + stage: 'intake_complete', + priority: 'medium', + sortIndex: 400, + }); + db.addDependencyEdge(blockedChild.id, epic.id); + db.addDependencyEdge(blockedChild.id, otherDep.id); + + const result = db.findNextWorkItem(undefined, undefined, false, 'intake_complete'); + // The plan_complete epic must never be surfaced by a stage-filtered next; + // the only intake_complete open candidate is otherDep. + expect(result.workItem).not.toBeNull(); + expect(result.workItem!.id).toBe(otherDep.id); + expect(result.workItem!.stage).toBe('intake_complete'); + }); + + it('should return an intake_complete item, not the out-of-stage blocker', () => { + // Same epic/blocked-child shape as the RCA, plus a real intake_complete + // open item. The stage filter must select the intake_complete item + // rather than surfacing the plan_complete epic as a blocker. + const epic = db.create({ + title: 'Plan complete epic', + status: 'open', + stage: 'plan_complete', + issueType: 'epic', + sortIndex: 200, + }); + const blockedChild = db.create({ + title: 'Intake child blocked', + status: 'blocked', + stage: 'intake_complete', + priority: 'high', + parentId: epic.id, + }); + db.addDependencyEdge(blockedChild.id, epic.id); + const intakeItem = db.create({ + title: 'Intake open item', + status: 'open', + stage: 'intake_complete', + priority: 'low', + }); + + const result = db.findNextWorkItem(undefined, undefined, false, 'intake_complete'); + expect(result.workItem).not.toBeNull(); + expect(result.workItem!.id).toBe(intakeItem.id); + expect(result.workItem!.stage).toBe('intake_complete'); + }); + + it('should skip critical escalation when stage filter is active (existing behavior)', () => { + // AC: critical escalation is already skipped with --stage (Stage 2 + // guard). Lock it in: a critical intake_complete item whose blocker is + // at another stage must not surface that blocker under a stage filter. + const blocker = db.create({ + title: 'Critical blocker', + status: 'open', + stage: 'plan_complete', + }); + const criticalBlocked = db.create({ + title: 'Critical blocked intake', + status: 'blocked', + stage: 'intake_complete', + priority: 'critical', + }); + db.addDependencyEdge(criticalBlocked.id, blocker.id); + + const result = db.findNextWorkItem(undefined, undefined, false, 'intake_complete'); + expect(result.workItem).toBeNull(); + }); + }); }); diff --git a/tests/next-risk-effort-filter.test.ts b/tests/next-risk-effort-filter.test.ts new file mode 100644 index 00000000..f5c4eb11 --- /dev/null +++ b/tests/next-risk-effort-filter.test.ts @@ -0,0 +1,249 @@ +/** + * Tests for `wl next` risk/effort at-most filters (WL-0MSMAIP5F003WAGG). + * + * Semantics under test (AC1/AC2): + * - risk ≤ level (low < medium < high < severe); effort ≤ level + * (extra small < small < medium < large < extra large). + * - Items with unset/empty risk or effort NEVER match (fail-closed). + * - Effort matching recognizes both "Small"/"Extra Small" and the short + * CLI spellings (XS/S/M/L/XL), normalized case-insensitively. + * - Dependency-blocked items remain excluded under the new filters. + * - Batch mode (`-n`) returns multiple eligible candidates. + * - Callers that do not use the new flags see unchanged behavior. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import { WorklogDatabase } from '../src/database.js'; +import { + createTempDir, + cleanupTempDir, + createTempJsonlPath, + createTempDbPath, +} from './test-utils.js'; +import type { WorkItemRiskLevel, WorkItemEffortLevel } from '../src/types.js'; + +/** Cast a raw DB value (e.g. lowercase or long-form) to the strict type. */ +const risk = (v: string) => v as unknown as WorkItemRiskLevel; +const effort = (v: string) => v as unknown as WorkItemEffortLevel; + +describe('wl next risk/effort filters (WL-0MSMAIP5F003WAGG)', () => { + let tempDir: string; + let dbPath: string; + let jsonlPath: string; + let db: WorklogDatabase; + + beforeEach(() => { + tempDir = createTempDir(); + dbPath = createTempDbPath(tempDir); + jsonlPath = createTempJsonlPath(tempDir); + if (fs.existsSync(jsonlPath)) { + fs.unlinkSync(jsonlPath); + } + db = new WorklogDatabase('TEST', dbPath, jsonlPath, true, true); + }); + + afterEach(() => { + db.close(); + cleanupTempDir(tempDir); + }); + + // ───────────────────────────────────────────────────────────────────── + // Risk filter boundaries (AC1: at-most ordinal semantics) + // ───────────────────────────────────────────────────────────────────── + describe('risk filter (at-most ordinal)', () => { + it('risk=low matches only Low-risk items', () => { + const low = db.create({ title: 'Low risk', risk: 'Low', status: 'open' }); + db.create({ title: 'Medium risk', risk: 'Medium', status: 'open' }); + db.create({ title: 'High risk', risk: 'High', status: 'open' }); + db.create({ title: 'Severe risk', risk: 'Severe', status: 'open' }); + + const result = db.findNextWorkItem(undefined, undefined, false, undefined, false, 'low'); + expect(result.workItem?.id).toBe(low.id); + }); + + it('risk=medium matches Low and Medium, excludes High/Severe', () => { + const low = db.create({ title: 'Low risk', risk: 'Low', status: 'open' }); + const medium = db.create({ title: 'Medium risk', risk: 'Medium', status: 'open' }); + db.create({ title: 'High risk', risk: 'High', status: 'open' }); + db.create({ title: 'Severe risk', risk: 'Severe', status: 'open' }); + + const results = db.findNextWorkItems(5, undefined, undefined, false, undefined, false, 'medium'); + const ids = results.map(r => r.workItem?.id).filter(Boolean); + expect(ids).toContain(low.id); + expect(ids).toContain(medium.id); + expect(ids).not.toContain( + results.find(r => r.workItem?.title === 'High risk')?.workItem?.id + ); + expect(ids.length).toBe(2); + }); + + it('risk=severe matches all canonical levels', () => { + const low = db.create({ title: 'Low risk', risk: 'Low', status: 'open' }); + const severe = db.create({ title: 'Severe risk', risk: 'Severe', status: 'open' }); + db.create({ title: 'Unset risk', status: 'open' }); + + const results = db.findNextWorkItems(5, undefined, undefined, false, undefined, false, 'severe'); + const ids = results.map(r => r.workItem?.id).filter(Boolean); + expect(ids).toContain(low.id); + expect(ids).toContain(severe.id); + // Unset risk never matches (fail-closed) + expect(ids.length).toBe(2); + }); + + it('risk matching is case-insensitive (data may store "low")', () => { + const lower = db.create({ title: 'Lowercase risk', risk: risk('low'), status: 'open' }); + db.create({ title: 'High risk', risk: 'High', status: 'open' }); + + const result = db.findNextWorkItem(undefined, undefined, false, undefined, false, 'low'); + expect(result.workItem?.id).toBe(lower.id); + }); + + it('filter value itself is case-insensitive ("Low" matches Low-risk items)', () => { + const low = db.create({ title: 'Low risk', risk: 'Low', status: 'open' }); + db.create({ title: 'High risk', risk: 'High', status: 'open' }); + + const result = db.findNextWorkItem(undefined, undefined, false, undefined, false, 'Low'); + expect(result.workItem?.id).toBe(low.id); + }); + + it('invalid risk level fails closed at the DB layer (matches nothing)', () => { + db.create({ title: 'Low risk', risk: 'Low', status: 'open' }); + + const result = db.findNextWorkItem(undefined, undefined, false, undefined, false, 'bogus'); + expect(result.workItem).toBeNull(); + }); + }); + + // ───────────────────────────────────────────────────────────────────── + // Effort filter boundaries (AC1: XS/S matching, fail-closed on unset) + // ───────────────────────────────────────────────────────────────────── + describe('effort filter (at-most ordinal)', () => { + it('effort=small matches Small and Extra Small, excludes Medium+', () => { + const xs = db.create({ title: 'XS effort', effort: 'XS', status: 'open' }); + const small = db.create({ title: 'Small effort', effort: 'Small', status: 'open' }); + db.create({ title: 'Medium effort', effort: 'Medium', status: 'open' }); + db.create({ title: 'Large effort', effort: 'Large', status: 'open' }); + + const results = db.findNextWorkItems(5, undefined, undefined, false, undefined, false, undefined, 'small'); + const ids = results.map(r => r.workItem?.id).filter(Boolean); + expect(ids).toContain(xs.id); + expect(ids).toContain(small.id); + expect(ids.length).toBe(2); + }); + + it('effort=xs matches only Extra Small (XS)', () => { + const xs = db.create({ title: 'XS effort', effort: 'XS', status: 'open' }); + db.create({ title: 'Small effort', effort: 'Small', status: 'open' }); + + const results = db.findNextWorkItems(5, undefined, undefined, false, undefined, false, undefined, 'xs'); + const ids = results.map(r => r.workItem?.id).filter(Boolean); + expect(ids).toEqual([xs.id]); + }); + + it('effort recognizes long-form spellings ("Extra Small", "extra small")', () => { + const longXs = db.create({ title: 'Long XS', effort: effort('Extra Small'), status: 'open' }); + const lowerLongXs = db.create({ title: 'Lower long XS', effort: effort('extra small'), status: 'open' }); + db.create({ title: 'Medium effort', effort: 'Medium', status: 'open' }); + + const results = db.findNextWorkItems(5, undefined, undefined, false, undefined, false, undefined, 'xs'); + const ids = results.map(r => r.workItem?.id).filter(Boolean); + expect(ids).toContain(longXs.id); + expect(ids).toContain(lowerLongXs.id); + expect(ids.length).toBe(2); + }); + + it('effort=medium matches XS/S/M', () => { + const xs = db.create({ title: 'XS', effort: 'XS', status: 'open' }); + const s = db.create({ title: 'S', effort: 'S', status: 'open' }); + const m = db.create({ title: 'M', effort: 'M', status: 'open' }); + db.create({ title: 'L', effort: 'L', status: 'open' }); + db.create({ title: 'XL', effort: 'XL', status: 'open' }); + + const results = db.findNextWorkItems(5, undefined, undefined, false, undefined, false, undefined, 'medium'); + const ids = results.map(r => r.workItem?.id).filter(Boolean); + expect(ids).toContain(xs.id); + expect(ids).toContain(s.id); + expect(ids).toContain(m.id); + expect(ids.length).toBe(3); + }); + + it('unset/empty effort never matches (fail-closed)', () => { + db.create({ title: 'Unset effort', status: 'open' }); + db.create({ title: 'Empty effort', effort: '' as const, status: 'open' }); + + const result = db.findNextWorkItem(undefined, undefined, false, undefined, false, undefined, 'small'); + expect(result.workItem).toBeNull(); + }); + + it('filter value "extra small" (with space) matches XS items', () => { + const xs = db.create({ title: 'XS', effort: 'XS', status: 'open' }); + db.create({ title: 'Small', effort: 'Small', status: 'open' }); + + const result = db.findNextWorkItem(undefined, undefined, false, undefined, false, undefined, 'extra small'); + expect(result.workItem?.id).toBe(xs.id); + }); + }); + + // ───────────────────────────────────────────────────────────────────── + // Combined risk + effort + stage (AC2: implement-tier selection shape) + // ───────────────────────────────────────────────────────────────────── + describe('combined risk/effort/stage selection', () => { + it('stage=plan_complete + risk=low + effort=small returns only matching open items', () => { + const target = db.create({ + title: 'Eligible plan_complete Low/Small', + stage: 'plan_complete', + risk: 'Low', + effort: 'Small', + status: 'open', + }); + db.create({ title: 'Medium risk', stage: 'plan_complete', risk: 'Medium', effort: 'Small', status: 'open' }); + db.create({ title: 'Medium effort', stage: 'plan_complete', risk: 'Low', effort: 'Medium', status: 'open' }); + db.create({ title: 'Unset risk', stage: 'plan_complete', effort: 'Small', status: 'open' }); + db.create({ title: 'Wrong stage', stage: 'idea', risk: 'Low', effort: 'Small', status: 'open' }); + db.create({ title: 'Completed item kept by stage filter', stage: 'plan_complete', risk: 'Low', effort: 'Small', status: 'completed' }); + + const results = db.findNextWorkItems(10, undefined, undefined, false, 'plan_complete', false, 'low', 'small'); + const ids = results.map(r => r.workItem?.id).filter(Boolean); + expect(ids).toContain(target.id); + // Non-matching candidates are never returned + for (const r of results) { + const wi = r.workItem; + if (!wi) continue; + expect(wi.stage).toBe('plan_complete'); + expect(['Low', 'low']).toContain(wi.risk); + expect(['Small', 'XS', 'S', 'small', 'xs', 'Extra Small']).toContain(wi.effort); + } + }); + + it('dependency-blocked items stay excluded under the new filters', () => { + const blocker = db.create({ title: 'Blocker', status: 'open', stage: 'plan_complete', risk: 'Low', effort: 'Small' }); + const blocked = db.create({ title: 'Blocked', status: 'open', stage: 'plan_complete', risk: 'Low', effort: 'Small' }); + db.addDependencyEdge(blocked.id, blocker.id); // blocked depends on blocker → excluded + + const result = db.findNextWorkItem(undefined, undefined, false, 'plan_complete', false, 'low', 'small'); + expect(result.workItem?.id).toBe(blocker.id); + expect(result.workItem?.id).not.toBe(blocked.id); + }); + + it('no-flag callers see unchanged behavior', () => { + const a = db.create({ title: 'Open A', priority: 'high', status: 'open' }); + db.create({ title: 'Open B', priority: 'medium', status: 'open' }); + + const result = db.findNextWorkItem(); + expect(result.workItem?.id).toBe(a.id); + const batch = db.findNextWorkItems(5); + expect(batch.filter(r => r.workItem).length).toBe(2); + }); + + it('batch -n returns multiple eligible candidates and excludes unset estimates', () => { + db.create({ title: 'Low1', risk: 'Low', status: 'open' }); + db.create({ title: 'Low2', risk: 'Low', status: 'open' }); + db.create({ title: 'Unset', status: 'open' }); + + const results = db.findNextWorkItems(2, undefined, undefined, false, undefined, false, 'low'); + const ids = results.map(r => r.workItem?.id).filter(Boolean); + expect(ids.length).toBe(2); + }); + }); +}); diff --git a/tests/sync.test.ts b/tests/sync.test.ts index c0e10bc9..09077a5d 100644 --- a/tests/sync.test.ts +++ b/tests/sync.test.ts @@ -1090,6 +1090,158 @@ describe('Sync Operations', () => { expect(merged.stage).toBe('done'); expect(merged.updatedAt).toBe('2024-06-02T12:00:00.000Z'); }); + + describe('in_review close preservation (WL-0MSPZP7FE009YXPG)', () => { + // completed + stage=in_review is a terminal 'ready for review' state + // (standard after pi audit). It must be protected from stale remote + // clobber just like completed + stage=done. + + const baseItem = (overrides: Partial): WorkItem => ({ + id: 'WI-IRV-001', + title: 'In review item', + description: '', + status: 'open', + priority: 'medium', + sortIndex: 0, + parentId: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-06-01T12:00:00.000Z', + tags: [], + assignee: '', + stage: 'idea', + issueType: '', + createdBy: '', + deletedBy: '', + deleteReason: '', + risk: '' as const, + effort: '' as const, + ...overrides, + }); + + describe('different-timestamp merge', () => { + it('(a) preserves local completed/in_review when remote open/idea has an older timestamp', () => { + const localClosed = baseItem({ + status: 'completed', + stage: 'in_review', + updatedAt: '2024-06-01T12:00:00.000Z', // close is the newest change + }); + const remoteStale = baseItem({ + status: 'open', + stage: 'idea', + updatedAt: '2024-01-01T00:00:00.000Z', // stale pre-close copy + }); + + const result = mergeWorkItems([localClosed], [remoteStale]); + + expect(result.merged).toHaveLength(1); + expect(result.merged[0].status).toBe('completed'); + expect(result.merged[0].stage).toBe('in_review'); + }); + + it('(b) preserves remote completed/in_review when local open/idea has an older timestamp', () => { + const localStale = baseItem({ + status: 'open', + stage: 'idea', + updatedAt: '2024-01-01T00:00:00.000Z', // stale pre-close copy + }); + const remoteClosed = baseItem({ + status: 'completed', + stage: 'in_review', + updatedAt: '2024-06-01T12:00:00.000Z', // close is the newest change + }); + + const result = mergeWorkItems([localStale], [remoteClosed]); + + expect(result.merged).toHaveLength(1); + expect(result.merged[0].status).toBe('completed'); + expect(result.merged[0].stage).toBe('in_review'); + }); + + it('(c) preserves local completed/in_review even when a stale remote open/idea has a NEWER timestamp (the clobber bug)', () => { + // Regression for WL-0MSKFFJWD002BQJ5: a stale remote copy of an + // item whose updatedAt advanced (e.g. an unrelated edit on another + // client) must not silently revert the local in_review close. + const localClosed = baseItem({ + status: 'completed', + stage: 'in_review', + updatedAt: '2024-06-01T12:00:00.000Z', // older than the stale remote edit + }); + const remoteStaleNewer = baseItem({ + status: 'open', + stage: 'idea', + updatedAt: '2024-06-02T09:00:00.000Z', // newer timestamp but never saw the close + }); + + const result = mergeWorkItems([localClosed], [remoteStaleNewer]); + + expect(result.merged).toHaveLength(1); + expect(result.merged[0].status).toBe('completed'); + expect(result.merged[0].stage).toBe('in_review'); + }); + + it('still respects an intentional reopen: newer local open/idea beats older remote completed/in_review', () => { + const localReopened = baseItem({ + status: 'open', + stage: 'idea', + updatedAt: '2024-06-02T12:00:00.000Z', // intentional reopen is the newest change + }); + const remoteClosed = baseItem({ + status: 'completed', + stage: 'in_review', + updatedAt: '2024-06-01T12:00:00.000Z', + }); + + const result = mergeWorkItems([localReopened], [remoteClosed]); + + expect(result.merged).toHaveLength(1); + expect(result.merged[0].status).toBe('open'); + expect(result.merged[0].stage).toBe('idea'); + expect(result.merged[0].updatedAt).toBe('2024-06-02T12:00:00.000Z'); + }); + }); + + describe('same-timestamp merge', () => { + it('(a) preserves local completed/in_review over remote open/idea at the same timestamp', () => { + const sameTimestamp = '2024-06-01T12:00:00.000Z'; + const localClosed = baseItem({ + status: 'completed', + stage: 'in_review', + updatedAt: sameTimestamp, + }); + const remoteOpen = baseItem({ + status: 'open', + stage: 'idea', + updatedAt: sameTimestamp, + }); + + const result = mergeWorkItems([localClosed], [remoteOpen]); + + expect(result.merged).toHaveLength(1); + expect(result.merged[0].status).toBe('completed'); + expect(result.merged[0].stage).toBe('in_review'); + }); + + it('(b) preserves remote completed/in_review over local open/idea at the same timestamp', () => { + const sameTimestamp = '2024-06-01T12:00:00.000Z'; + const localOpen = baseItem({ + status: 'open', + stage: 'idea', + updatedAt: sameTimestamp, + }); + const remoteClosed = baseItem({ + status: 'completed', + stage: 'in_review', + updatedAt: sameTimestamp, + }); + + const result = mergeWorkItems([localOpen], [remoteClosed]); + + expect(result.merged).toHaveLength(1); + expect(result.merged[0].status).toBe('completed'); + expect(result.merged[0].stage).toBe('in_review'); + }); + }); + }); }); describe('merge utils', () => {