diff --git a/.pi/skills/heartbeat/scripts/heartbeat.py b/.pi/skills/heartbeat/scripts/heartbeat.py index c12c09bc6..8de72c64c 100755 --- a/.pi/skills/heartbeat/scripts/heartbeat.py +++ b/.pi/skills/heartbeat/scripts/heartbeat.py @@ -113,19 +113,21 @@ def check_queue(): return "No items in completed/in_review queue." if count < 10: - # Sparse queue — flag next item for producer review + # Sparse queue — hold the next item for the producer (assign to the + # producer-hold identity; the assignee hold replaced the removed + # producer-review flag). next_data = run_wl(['next', '--json']) next_item = next_data.get('workItem') if next_item: item_id = next_item['id'] item_title = next_item.get('title', item_id) - run_wl(['update', item_id, '--needs-producer-review', 'true', '--json']) + run_wl(['update', item_id, '--assignee', 'Map', '--json']) return ( - f"Flagged {item_title} ({item_id}) for producer review.\n" + f"Held {item_title} ({item_id}) for the producer.\n" f"Queue has {count} completed/in_review items (below threshold of 10)." ) else: - return "No next item found to flag for review." + return "No next item found to hold for the producer." else: # Full queue — find first non-ready item by sortIndex items_sorted = sorted(items, key=lambda x: x.get('sortIndex', 0) or 0) diff --git a/AGENTS.md b/AGENTS.md index 23034524c..21552f4ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,19 @@ Read the global agent instructions at `~/.pi/agent/AGENTS.md` — they define th ## Project-specific guidance +### Claim pattern & scheduling hold (WL-0MV82CHGQ000HAZZ) + +The assignee field doubles as the scheduling hold. Implement/plan/intake tiers +claim with a status flip (`wl update --status in_progress --assignee +`); the herdr **audit tier** claims **status-neutrally** — assignee CAS +only (`wl update --assignee --if-status completed --if-stage +in_review --if-assignee ""`), so the item stays `completed`/`in_review`. A +non-empty assignee is an intentional hold from auto-dispatch (producer sentinel +`wl update --assignee Map`; clear `--assignee ""` to release); claims +clear the assignee on session end. See +[docs/dev/downtime-dispatcher.md](./docs/dev/downtime-dispatcher.md) and the +global instructions (`~/.pi/agent/AGENTS.md`). + ### Architecture Notes for Agents Worklog uses **SQLite as the runtime source of truth** with an **ephemeral JSONL pattern** for Git sync: diff --git a/CHANGELOG.md b/CHANGELOG.md index fdd5157b3..2bc078305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## v1.0.22 (2026-10-17) +> **Release focus:** This release focuses on making the worklist clearer and more responsive, with better visibility into blocked items, priorities, review status, and in-progress markers, plus instant refresh and a new help screen for shortcuts. It also improves reliability around audits, quick-create, rejections, and finish-gate so work isn't stranded or interrupted. +### Features +- Blocked items now show exactly what's holding them up, right in the list. (WL-0MV8EZRDJ001TDA8) +- Medium-priority items now show a yellow square, so you can tell priority apart from stage at a glance. (WL-0MV7WQTJ9003SAV1) +- Press Ctrl+R to refresh the worklist instantly, and enjoy a cleaner header. (WL-0MV85T0LT008UT9H) +- Failed audits no longer strand work off the review queue; items stay in review and can be re-dispatched. (WL-0MV82CHGQ000HAZZ) +- The producer-review status icon is back in the selection list, so you can see at a glance which items await review. (WL-0MVAMS4RO000H3B0) +- The worklist now refreshes instantly when you focus its pane, so new items appear without waiting. (WL-0MVAS988R008PNH6) +- Blocked items now show each blocker's status in both the preview and detail views. (WL-0MVBMLY2Z0032CYR) +- Quick-create now runs in the background without interrupting your work. (WL-0MV6J5JXL002D8C4) +- Epic items now show a plain "epic" label instead of a redundant icon, making lists cleaner and easier to scan. (WL-0MVAQ873D007LNWD) +- The in-progress marker now reliably shows which work items are actively being worked on. (WL-0MV1BWETT005GSUJ) +- Press ? to open a help screen listing all shortcuts and icon meanings. (WL-0MTV97GV20088CS7) +### Bug Fixes +- Blocked items now show their "Blocked By" info steadily instead of flickering on and off. (WL-0MV8Q5X8U004KKAP) +- The Worklog selection pane no longer crashes on startup after updates. (WL-0MVAY631M004RZ3Q) +- Failed audits no longer silently remove items from the review queue. (WL-0MV7W77AY005MGBL) +- Rejecting an item with a reason now runs quietly in the background without opening a command pane. (WL-0MV6J4HQ0009OJPQ) +- Finish-gate no longer fails when other agents update shared branches mid-run. (WL-0MV5MYBKJ0033P8U) +- Tests now pass reliably regardless of where the project is checked out. (WL-0MTT7XTN6006MHHV) +- Separate-process audits now correctly read child results, so parent audits no longer wrongly show "not ready to close. (WL-0MU0FHSEP003PCDY) +### Other +- Downtime worker tests are now more reliable, so future updates are less likely to break them. (WL-0MV5RYKQD000L5LM) +- Updated the shared tools guide so players can find the new agent and lease-release scripts. (WL-0MSRWFPRT009S0SO) + ## v1.0.21 (2026-10-13) > **Release focus:** This release focuses on making the worklist more reliable and complete, ensuring blocked, in-progress, and reworked items always appear and that data refreshes instantly. It also fixes several crashes and data-loss issues that could occur when saving changes during conflicts or busy periods, and improves recovery behaviour when AI providers or long outages interrupt queued jobs. ### Features diff --git a/CLI.md b/CLI.md index b76b73582..d332bb8bb 100644 --- a/CLI.md +++ b/CLI.md @@ -161,7 +161,9 @@ Automatic re-sort: ### `update` [options] -Update fields on one or more existing work items. Accepts multiple IDs. Options mirror `create` for updatable fields, plus `--description-file ` (read description from a file), `--audit-text ` and `--audit-file ` (read audit text from a file; writes to the `audit_results` table), `--audit-fingerprint ` (content fingerprint for the freshness gate; an explicit value wins over an embedded `Audit content fingerprint: ` line), `--needs-producer-review ` (set needsProducerReview flag), and `--do-not-delegate ` (set or clear the do-not-delegate tag). +Update fields on one or more existing work items. Accepts multiple IDs. Options mirror `create` for updatable fields, plus `--description-file ` (read description from a file), `--if-status `/`--if-stage `/`--if-assignee ` (atomic compare-and-swap guards; see *Conditional updates* below), `--audit-text ` and `--audit-file ` (read audit text from a file; writes to the `audit_results` table), `--audit-fingerprint ` (content fingerprint for the freshness gate; an explicit value wins over an embedded `Audit content fingerprint: ` line), `--needs-producer-review ` (set needsProducerReview flag), and `--do-not-delegate ` (set or clear the do-not-delegate tag). + +> **Soft-delete guard (SA-0MUJBLZC60085DI9):** a soft-deleted item (non-empty `deletedBy`) cannot be updated — `wl update` rejects it per-id with error `deleted-item` and a message naming the item, and exits non-zero. This prevents a stale status/stage update from resurrecting a deleted item for the dispatcher to re-select. An explicit change to the delete-attribution fields themselves (`--deleted-by` / `--delete-reason`) is still permitted. > **Auto-revert:** when `--audit-text`/`--audit-file` carries a `Ready to close: No` verdict for an item in `in_review` (status `completed`), the item is automatically reverted to `open`/`plan_complete` (priority preserved) and the output reports the transition (`reverted` field in JSON, `[ID reverted from completed/in_review to open/plan_complete]` in human mode). See docs/AUDIT_STATUS.md. @@ -201,6 +203,19 @@ wl update WL-ABC123 --do-not-delegate true # add tag wl update WL-ABC123 --do-not-delegate false # remove tag ``` +Conditional updates (compare-and-swap guards): + +- `--if-status `, `--if-stage ` and `--if-assignee ` make the update conditional on the item's current value for that field. When any guard is supplied, the check and the write run inside a single `BEGIN IMMEDIATE` SQLite transaction, so exactly one of several concurrent claimers wins; every loser receives a per-id `stale` result (`success: false`, `error: "stale"`) and **no field is written** (no partial write). +- The guards compose: supplying more than one requires **all** of them to match. This is how the herdr audit tier claims a `completed`/`in_review` item **status-neutrally** — it changes only the assignee, leaving status and stage untouched: + + ```sh + wl update WL-ABC123 --assignee audit-tier \ + --if-status completed --if-stage in_review --if-assignee "" + ``` + +- `--if-assignee ""` is a meaningful expected value: it matches an item that is currently **unassigned** (empty assignee). It is distinguishable from omitting the flag (which applies no assignee guard), so an empty-string guard is a valid exclusive-claim token. +- The guarded status values are normalized (e.g. `in_progress` matches a stored `in-progress`). `--if-assignee` matches the stored assignee exactly. + ### `reviewed` [value] Toggle or set the `needsProducerReview` flag on a work item. If `value` is omitted, it toggles the current value. diff --git a/bench/tui-expand.js b/bench/tui-expand.js index 6fad918c0..78174c4d9 100644 --- a/bench/tui-expand.js +++ b/bench/tui-expand.js @@ -122,7 +122,6 @@ async function run() { deleteReason: '', risk: '', effort: '', - needsProducerReview: false, }, overrides); items.set(id, item); return id; diff --git a/docs/dev/context-budget.thresholds.json b/docs/dev/context-budget.thresholds.json index 276032c4c..b05937d7e 100644 --- a/docs/dev/context-budget.thresholds.json +++ b/docs/dev/context-budget.thresholds.json @@ -1,6 +1,6 @@ { "global_agents": 0, - "project_agents": 1655, + "project_agents": 2416, "skills_prose": 473, - "total": 2128 + "total": 2889 } diff --git a/docs/dev/cross-repo-producer-review-restore.md b/docs/dev/cross-repo-producer-review-restore.md new file mode 100644 index 000000000..1842fa68f --- /dev/null +++ b/docs/dev/cross-repo-producer-review-restore.md @@ -0,0 +1,60 @@ +# Cross-repo restore — producer-review flag in the ship gate & audit runner + +> Work item: WL-0MVAPF1QM005FYM1 (child of epic WL-0MVAMS4RO000H3B0). +> Repo boundary: the ship gate and audit runner live in the **SorraAgents** +> skill tree; the restore was delivered as SorraAgents work items (not edited +> from ContextHub). + +## Why + +The `needsProducerReview` flag was removed (WL-0MV85WFYU0071YQ9 / +WL-0MV85WGIT004PHRC) and its two roles — the audit verdict's producer signal +and the ship release gate — were re-homed onto an assignee sentinel +(`assignee === 'Map'`). That removal was a wrongful deletion +(RCA WL-0MVAOYBZM0065AEN). ContextHub restored the flag (F2/F3/F4/F5); the +SorraAgents side is restored here. + +## Delivered + +| SA item | Scope | Commit | +|---|---|---| +| SA-0MVB1QFXI004T4G5 | Audit runner: `'yes'` verdict sets `--needs-producer-review yes` (top-level items); merge-gate blocker flags it; the audit claim is always cleared with `--assignee ""` (status-neutral CAS kept; `_PRODUCER_HOLD_ASSIGNEE` removed). | `4fd33cb4` | +| SA-0MVB1Q6DR007I49R | Ship gate: `check-final-validation.js`, `check-audit-gate.js`, `run-release.js` block release on `needsProducerReview === true` again; the producer-hold assignee sentinel is removed. | `05ec727b` | + +Both SA items are `completed`/`in_review`; SA full suite green +(audit-runner tests 213 + 49, ship-gate tests 200). + +## Kept (Q2=C — not reverted) + +- `wl update --if-assignee` atomic CAS and the **status-neutral audit-tier + claim** (WL-0MV85WBDB003J35W / WL-0MV85WCJJ0087VMP) — the + WL-0MV7W77AY005MGBL fix is preserved. `isAssigneeHeld` (any non-empty + assignee is a scheduling hold) remains. +- The audit runner's legacy-hybrid recovery (`in-progress`/`in_review` → + `completed`/`in_review`) is preserved. + +## Retired + +- The producer-hold assignee sentinel (`PRODUCER_HOLD_ASSIGNEE` / + `isProducerHold` / `markProducerHoldWorkItem` / `producer-hold.ts`): no + producer-review semantics remain on the assignee. The producer-review signal + is the flag again, toggled by `wl reviewed `. + +## Verification + +- ContextHub: `wl reviewed`, `--needs-producer-review` on create/update and the + list/search/next filters, the ❌/✅ selection-list icon, and 6-bucket + review-queue grouping are restored (F2–F5). +- SorraAgents: flag-based ship gate + audit-runner verdict marking restored and + tested. +- End-to-end: a top-level `'Ready to close: Yes'` audit sets + `needsProducerReview=true`; `check-final-validation.js` blocks release until + the producer clears it (`wl reviewed false`) — the pre-removal release + guarantee. + +## Related + +- ContextHub WL-0MVAMS4RO000H3B0 (epic), WL-0MVAPF1QM005FYM1 (this item) +- SA-0MVB1QFXI004T4G5, SA-0MVB1Q6DR007I49R (delivery) +- Reversed: SA-0MV9DUE6F006SK1H (ship-gate re-homing), SA-0MV98PAT7005VXXS + (audit-runner producer marking; assignee-clear retained) diff --git a/docs/dev/downtime-dispatcher.md b/docs/dev/downtime-dispatcher.md index 1127c74a4..1da1d12f1 100644 --- a/docs/dev/downtime-dispatcher.md +++ b/docs/dev/downtime-dispatcher.md @@ -311,12 +311,16 @@ are now recovered automatically: - **`marker-write-failed`** (the marker write failed AFTER the successful claim): `dispatchClaimedTier` calls `rollbackClaim` (→ - `rollbackClaimWorkItem`) to reverse the CAS transition — + `rollbackClaimWorkItem`) to reverse the CAS transition. The plan/intake/ + implement/risk-effort tiers keep the legacy status restore — `--status --if-status in_progress [--if-stage - --stage ]`. Plan/intake/implement/ - risk-effort roll back to `open` at the original stage; the audit tier - rolls back to `completed`/`in_review` so the item stays in the audit - queue. A successful rollback reports the neutral outcome + --stage ]`, rolling back to `open` at + the original stage. The AUDIT tier claim is STATUS-NEUTRAL + (WL-0MV85WCJJ0087VMP): it never flips status/stage, so its rollback + clears only the claiming agent's assignee, guarded by + `--assignee "" --if-assignee ` — the item stays at + `completed`/`in_review` (never a hybrid write) and only THIS claim's + state is reverted. A successful rollback reports the neutral outcome `claim-rolled-back`; a stale/failed rollback (a concurrent human/agent already moved the item) reports `marker-write-failed` and leaves the item untouched (fail-closed). @@ -332,6 +336,67 @@ are now recovered automatically: A STANDING success marker (no `outcome`) excludes the item for its tier **for a bounded lifetime**, so a dispatched item is never double-dispatched while its pane may still be running — and never stranded forever if it is not (see below). +### Status-neutral audit-tier claim (WL-0MV85WCJJ0087VMP) + +The AUDIT tier claim is deliberately STATUS-NEUTRAL: an audited +`completed`/`in_review` item must never enter the `in_progress`/`in_review` +hybrid that stranding autocracies were built on. `claimItem` (→ +`claimWorkItem`) writes ONLY the assignee for the audit tier: + +``` +wl update --assignee --if-status completed --if-stage in_review --if-assignee "" +``` + +- No `--status`/`--stage` — the item reads back `completed`/`in_review` + throughout the claim (AC: no hybrid state). +- `--if-assignee ""` is the exclusive claim token: only an UNASSIGNED item + may be claimed, so assigning an item (e.g. to the producer) makes the + claim fail stale at every re-claim. +- A failed audit Phase 2 restores via the status-neutral rollback + (`--assignee "" --if-assignee `), so the item can never be stranded + off the review queue. +- Assignee-based scheduling hold: `classifyItemForDispatch` and every tier's + `select*` helper exclude items with a non-empty assignee, and the parse + helpers (`parseShowItemOutput`, `parseAuditCandidatesOutput`, + `parseNextCandidatesOutput`, `parseImplementCandidatesOutput`, + `parseCriticalCandidatesOutput`) carry the field so the exclusion holds at + every selection path. + +Every OTHER tier keeps the legacy status-flip claim +(`--status in_progress` + the tier's status/stage CAS), and its rollback +keeps the legacy status restore guarded by `--if-status in_progress`. + +### Producer-hold sentinel (WL-0MV85WDOM007BKF8) + +The **scheduling-hold convention**: a non-empty `assignee` means the item is +intentionally held from auto-dispatch — `classifyItemForDispatch` and every +tier's `select*` helper exclude it, and the audit claim's `--if-assignee ""` +CAS fails stale on it. Assigning an item therefore *pauses its automatic +scheduling*, with no magic DB flag involved. + +The per-item **producer pause switch** is assigning the item to the +producer's wl identity. The reserved sentinel is +`PRODUCER_HOLD_ASSIGNEE` (`'Map'`, the producer's wl identity), recognised +by `isProducerHold()`. Because the value is self-describing, the ship +release gate can key on it: an item assigned to the producer means "the +producer owns it" and is not shippable until it has a passing audit +(epic WL-0MV82CHGQ000HAZZ Q2; re-homed ship gate — SA-tracked). + +Rules: + +- Assigning to the producer (`wl update --assignee Map`) holds the item + at every tier — claims fail stale and selectors skip it. +- Clearing the assignee (`wl update --assignee ""`) **releases** the + hold and makes the item dispatchable again. +- The sentinel never collides with an automated agent identity + (`pi`, `implement`, `@github-copilot`, …); comparison is exact + (whitespace-tolerant), not case-insensitive. +- **Clearing discipline:** every assignee writer clears the assignee on + session end so a finished/aborted claim never silently suppresses future + auto-dispatch. `status_lifecycle`'s failure-exit already clears; the + dispatch claim/rollback paths clear it on every terminal outcome + (WL-0MV85WD400091OZ6). + ### Success-marker lifetime (WL-0MU6UL0RJ008IHGT) A marker is written **before** the pane spawns to prevent duplicate dispatch, and its readers exclude the item **while the item remains at the marker's dispatched-at stage**. That is correct for a healthy run (the agent advances the item and the stage change releases the marker), but a pane that spawns successfully and never advances the item — a crash, a manual close, a silent failure, or an agent that exits without progressing — left the marker standing **forever**. The item became permanently invisible to the tier that would retry it and could only leave the stage by completing the very step that would never be scheduled: a deadlock. @@ -1045,6 +1110,22 @@ falls through/deferred; a per-worklog active audit still reports `audit-in-flight`; the observability line carries the concurrent-audit count and slot state). +### Soft-delete exclusion (SA-0MUJBLZC60085DI9) + +Every dispatch path excludes soft-deleted items — those with a non-empty +`deletedBy` (set by `wl delete`, which may leave the item's `status` +untouched). `isSoftDeleted()` (trim-aware) is applied in +`classifyItemForDispatch` (the Herdr-head and coordination paths) and in every +`select*` helper (audit, implement, plan/intake, critical). The parse helpers +(`parseNextCandidatesOutput`, `parseImplementCandidatesOutput`, +`parseCriticalCandidatesOutput`, `parseAuditCandidatesOutput`, +`parseShowItemOutput`) carry `deletedBy` to those filters, and the fetcher's +`normalizeItem` preserves it so the live Herdr head carries the field. Before +this guard a deleted item whose status was not `deleted` could be re-selected +and worked a second time. The guard is a neutral sequential filter (never a +strike, never `no-candidate` metadata) and is a single-field check, so it has +negligible cost. + ### Producer-review gate (WL-0MTIAL65N004T22F) Every dispatch tier — audit, critical (including its dependency-frontier walk), implement, @@ -1353,6 +1434,27 @@ dispatch call — no per-worklog duplication (F5 WL-0MTII48OV008P2QU; WL-0MT50LKAK001EF5Q single cap source). v1 scope is single-machine; a multi-machine (real flock/NFS) extension is future work. +#### Cross-root `assignee` merge rule (WL-0MV85WBZ2004VDUQ) + +`assignee` is a scheduling hold, not a protected data field: assigning an item +holds it from auto-dispatch and clearing the assignee releases it. Cross-root +sync therefore treats a cleared `assignee` (`''`) as **meaningful intent** +rather than an absent/default value: + +- `assignee` is **not** a regression-guarded merge field (it is absent from + `mergeParticipatingFields` in `src/sync.ts`). A newer remote `''` clears a + local non-empty assignee and propagates **without** `--accept-regressions`. +- Empty-vs-non-empty assignee merges resolve **last-writer-wins** by + `updatedAt` (deterministic lexicographic tie-break at equal timestamps) via + `isEmptyMeaningfulField()` — so a stale local claim can never resurrect a + released hold, and a newer local hold is not cleared by a stale remote `''`. +- `status`, `stage` and `priority` remain regression-guarded unchanged: a + newer remote default is still refused and the local value kept. + +Rationale and the accepted answer to the epic's Q1 live in the parent epic +appendix (Option A — status-neutral assignee CAS claim). Cross-root coverage: +`tests/cli/sync-assignee-clear.test.ts` (two worklog roots, real `wl sync`). + ### Owner lease, pane liveness & contention feedback (WL-0MTYZXSLN008HZOW; pane-count cap removed by WL-0MU2EP6JL006A1U3) diff --git a/docs/dev/e2e-needsproducerreview-restore-verification.md b/docs/dev/e2e-needsproducerreview-restore-verification.md new file mode 100644 index 000000000..72b40c820 --- /dev/null +++ b/docs/dev/e2e-needsproducerreview-restore-verification.md @@ -0,0 +1,78 @@ +# End-to-end verification — Restored `needsProducerReview` flag, icon and release gate + +> Work item: WL-0MVAPF2Q3009088O (child of epic WL-0MVAMS4RO000H3B0). +> Commits: F2 `e950d006` · F3 `c51c1c13` · F4 `abb367ee` · F5 `b779d91f` · +> F6 `70708f62` (SA `4fd33cb4`, `05ec727b`) · F7 `f7f826e6`. +> This item records the end-to-end verification evidence for the restore. + +## 1. Selection-list icon and metadata row + +- An item with `needsProducerReview === true` renders ❌ in the herdr list + prefix; `false` renders ✅; unset renders nothing. Verified against the + shared `getIconPrefixParts` (prefix contains ❌; `[REVIEW]`/`[OK]` text + fallbacks in noIcons mode) — PASS. +- The metadata `Producer Review` row (`... ❌ needs review` / `✅ reviewed`) is + rendered by `buildMetaRows` and covered by the restored + `packages/herdr/src/worklist.test.ts` and `tests/herdr/detail-view.test.ts` + suites (green in the F4 run). +- `poetry` of the runtime pane: `npx tsx packages/herdr/src/index.ts` starts + and renders the list without crashing after the shared-artifacts staleness + fix (see WL-0MVAY631M004RZ3Q). + +## 2. Dispatch exclusion and review-queue ordering + +- Flag-true items are excluded from downtime auto-dispatch every tier + (`classifyItemForDispatch` returns null; `select*Candidate` skip them) — + restored F4, covered by `packages/herdr/src/downtime-worker*.test.ts` and + `tests/unit/downtime-worker-*.test.ts` (green). +- `inReviewBucket` returns 6 buckets with bucket 1 = `needsProducerReview === + true`; review-queue ordering ranks flagged items first — restored F3/F4, + covered by `tests/grouping-utility.test.ts` and + `packages/herdr/src/grouping.test.ts` (green). Runner check: `PASS`. + +## 3. `wl reviewed`, filters, JSONL and cross-root sync + +- `wl reviewed ` toggles; ` true|false` sets; JSON output returns the + updated item — restored F3, covered by `tests/cli/reviewed.test.ts` (green). +- `--needs-producer-review` filters work on `wl list`/`wl search`/`wl next` + and the REST API (`/items?needsProducerReview=true|false`; invalid → 400). + Direct E2E: DB `list` filter PASS, API filter + 400 PASS. +- JSONL transport round-trips the flag (restored `packages/shared` registry + tests, `packages/shared/src/database.test.ts` green). +- Cross-root sync: the flag participates as a normal tracked field (F2 + restored the schema/transport; sync regression-guard behaviour for the + assignee kept per Q2=C). + +## 4. Audit-safety regression + +- Flipping the flag does **not** bump `updatedAt`, so a fresh audit stays fresh + (WL-0MSN6ZCTN0027U2R). Direct E2E: `update({needsProducerReview:false})` + preserves `updatedAt` — PASS; `isAuditFresh(auditedAt==updatedAt)` true and + stale-after-2min false — PASS. Covered by the restored shared database + "flag-only flip" tests. + +## 5. Ship gate blocks release on a flagged item + +- F6 delivered the SA items: SA-0MVB1QFXI004T4G5 (audit runner sets + `--needs-producer-review yes` on 'yes' verdicts) and SA-0MVB1Q6DR007I49R + (ship gate blocks release on `needsProducerReview === true`). SA full suite + green (ship 200 tests; audit-runner 262 tests). See + `docs/dev/cross-repo-producer-review-restore.md`. + +## 6. Full project suite passes (`/skill:test` gate) + +- Canonical gate (`run_tests.py` full scope → `tests/cli` + `tests/unit`) + green after F8: suite success true. herdr suite (108 files, 3518 tests) + green; shared restored corpus green. + +## Notes + +- Range of restored surfaces in one sentence: `needsProducerReview` now + round-trips DB → CLI (`wl reviewed`, create/update, list/search/next + filters, api) → herdr (icon ❌/✅, metadata row, 6-bucket review queue, + dispatch exclusion) → release gate (SA); the verify pane renders the + selection list normally. +- The worktree `@worklog/shared` resolution gap (worktrees point at the main + checkout's shared install) required a worktree-local node_modules mirror + during implementation; tracked in WL-0MVAY631M004RZ3Q alongside the + vendored-artifact crash. diff --git a/docs/dev/herdr-events-subscribe-reliability.md b/docs/dev/herdr-events-subscribe-reliability.md new file mode 100644 index 000000000..dddade1dc --- /dev/null +++ b/docs/dev/herdr-events-subscribe-reliability.md @@ -0,0 +1,112 @@ +# Herdr `events.subscribe` stream reliability (WL-0MVAS988R008PNH6) + +Root cause and fix for the repeated `events.subscribe … outcome="stream_closed"` +completions (and the resulting ~10 s reconnect loop) seen in +`~/.config/herdr/herdr-server.log`, and the pane-level "refresh on focus" +behaviour it underpinned (`Refresh selection list on focus`, +`WL-0MVAS988R008PNH6`). + +## Symptom + +`herdr-server.log` showed `events.subscribe` requests completing with +`outcome="stream_closed"` every ~10 s (`sub-100` … `sub-115` at +2026-10-16T07:15–07:17, matching `MAX_RECONNECT_DELAY_MS = 10_000` in +`packages/herdr/src/events.ts`). Each close triggered a reconnect, so the +client's `pane_focused` events arrived only in the shrinking windows between +reconnects — pane-level focus refresh was unreliable and the list could stay +stale until the next 30 s tick. + +## Diagnosis (log + live-probe evidence) + +Probes against the live herdr 0.9.5 Unix socket (`~/.config/herdr/herdr.sock`, +read-only subscribe) established the server's actual contract: + +1. **An idle subscription stays open indefinitely.** A single + `events.subscribe` for `pane.focused` was held open for 40 s+ and delivered + real `pane_focused` events with no close. There is therefore **no server + idle-timeout** and **no keepalive requirement** — a client keepalive would + not fix anything. +2. **The server handles exactly ONE request per connection.** Sending a second + request frame on an established subscription socket — a second + `events.subscribe`, a `ping`, or even `pane.list` — causes the server to + **reset** the connection (`ConnectionResetError`, `stream_closed` in the + log) within ~100 ms. A fresh connection is required for every request. +3. **`ping` is a fresh-connection method.** `{"method":"ping","params":{}}` + returns `{"result":{"type":"pong","version":"0.9.5","protocol":22, + …}}`; a second ping on the same connection is rejected/closed. It is not a + stream-keepalive primitive. + +The previous subscriber design resent `events.subscribe` **on the live +subscription socket** whenever the tracked pane set changed +(`resendSubscribeRequest`, called by `addPaneSubscription` / +`removePaneSubscription`). This directly triggered (2). Worse, the worklist's +startup `syncPaneSubscriptions` re-added panes after each reconnect (and pane +seen/closed events re-triggered resubscribes), so each reconnect was killed +again — the ratcheting ~10 s loop. + +A local reproduction (`HerdrEventSubscriber` against the live socket) confirmed +the loop: after `addPaneSubscription('w1:p1')` the server reset the +connection; the client reconnected (`isConnecting=false` by then), and each +reconnect's `syncPaneSubscriptions` re-added the pane, ratcheting +`reconnectAttempts` to the 10 s cap — exactly the logged cadence. + +## Fix (client-side only — no herdr app/framework changes) + +`packages/herdr/src/events.ts` now keeps every subscription connection +**write-once**: + +- **One request per connection.** The socket is dedicated to the stream. The + only frame written is the single initial `events.subscribe` carrying the + full (global + per-pane) subscription set. +- **Pane-set changes re-establish the connection.** `addPaneSubscription(s)` / + `removePaneSubscription(s)` update `trackedPaneIds` and then run + `restartSubscription()`, which tears down the current socket (without the + error/reconnect path) and connects afresh with the complete set in one + request. `subscriptionVersion` + a single-flight `restartPromise` coalesce + batched pane changes and changes that land during an in-flight reconnect + into one rebuild — never one connection per pane. +- **Backoff reset on success.** `markSubscribed()` resets `reconnectAttempts` + on every successful subscribe, so a genuine stream close reconnects promptly + at the base delay rather than ratcheting to the cap. +- **Liveness signal.** A new `onReconnected` callback fires when a stream is + re-established after a prior loss (never for the first subscribe). + `worklist.ts` uses `onError` to flip `eventsActive` off and re-enable the + tab-level `resume-poll` fallback while the stream is down, and `onReconnected` + to flip it back on and top up per-pane subscriptions. + +`worklist.ts` also batches `syncPaneSubscriptions` (`addPaneSubscriptions`, +one restart) instead of one `addPaneSubscription` per pane. + +## Verification + +- `packages/herdr/src/events.test.ts` — `keepalive & reconnect resilience` + suite (mock records per-client `requests`): + - a pane-set change never sends a second request on the live socket (the + original socket keeps exactly one frame) and is applied on a new + connection carrying the full set; + - a closed stream reconnects, re-subscribes with the full pane set, and + fires `onReconnected`; + - batched pane adds coalesce into a single reconnect; + - a steady-state idle subscription sends no further frames (no keepalive on + the stream). +- `packages/herdr/src/worklist-visibility.test.ts` — after a server-side + stream close + reconnect, a `pane_focused` event for the current pane + refreshes the list again. +- Live reproduction against the real herdr socket: with the fix, + `addPaneSubscription` returns `subscribed` and health stays `active` + (`paneCount` 1 → 2) with no reconnect. + +## Residual limitations / notes + +- Subscriptions are still scoped to the herdr **session** the client attaches + to (unchanged). +- A subscription request naming a **nonexistent pane** is rejected by the + server with `pane … not found` and the connection is closed; the client + reconnects with backoff. In normal operation the tracked set comes from the + shared `.worklog/agent-panes.json` pane set, so this is confined to a stale + pane entry between its close and the next sync. Not a regression (the previous + in-place resubscribe had the same behaviour); a follow-up could pre-validate + panes before subscribing. +- If a future herdr release changes the one-request-per-connection contract, + the probe methodology above is the way to re-verify before adjusting the + client. diff --git a/docs/dev/herdr-hydrator.md b/docs/dev/herdr-hydrator.md index 1bc1b6a90..bc1604a5e 100644 --- a/docs/dev/herdr-hydrator.md +++ b/docs/dev/herdr-hydrator.md @@ -2,16 +2,17 @@ Work item: WL-0MSOJLZD9004P8PI Folded-in scope: WL-0MTTSWE0G008M1VA (no-activity / claim-age timeout, 2026-09-13) +Active-detection rework: WL-0MV1BWETT005GSUJ (pane-title detection, 2026-10-16) ## Problem Work items claimed as `in_progress` are correctly excluded from downtime dispatch (single-flight — an active claim owns them). But there is no automatic mechanism to detect claims that are **stuck**: status `in-progress` -with no matching agent tab (tab closed, session died, claim abandoned). Such -items linger at the top of the queue and are never re-selected — they block -themselves and top the Herdr selection list as non-dispatchable wall items -(RCA `WL-0MTTS9FY00055QJV`, root cause E; evidence +with no matching agent pane (pane closed, session died, claim abandoned). +Such items linger at the top of the queue and are never re-selected — they +block themselves and top the Herdr selection list as non-dispatchable wall +items (RCA `WL-0MTTS9FY00055QJV`, root cause E; evidence `AH-0MTP19DG100472CV`). ## Design @@ -21,31 +22,47 @@ like the downtime worker: 1. **Every 30 s** — `wl list --status in-progress --json` (all in-progress items for the pane's worklog root). -2. **Fetch live tabs** — `herdr tab list --workspace `; keep tabs in the - **current workspace** (`HERDR_WORKSPACE_ID`; tabs without a workspace id - are always included — fail-open toward "active"). -3. **Match** — a tab whose label **exactly equals** the item's work-item ID - (e.g. `WL-…`) counts as active; a label that merely *contains* the ID does - not. Tabs are labelled with the exact work-item ID by the shared - dispatcher-anchor anchors (`getItemTabAnchor`/`findTabByLabel`). -4. **Demote (release)** any item with no matching tab: +2. **Fetch live panes** — `herdr pane list --workspace `; keep panes in + the **current workspace** (`HERDR_WORKSPACE_ID`; panes without a workspace + id are always included — fail-open toward "active"). +3. **Match** — a pane whose **title contains** the item's work-item ID + (e.g. `Downtime triggered implement Foo - WL-…`) counts as active. + Every pane spawned for an item is titled by the shared `pane-title.ts` + builders (manual, shell, downtime), and + `truncatePaneTitlePreservingSuffix` keeps the ID as a trailing suffix + under truncation, so the ID reliably appears in the title. +4. **Demote (release)** any item with no matching pane: - stage `in_review` → `status=completed`, stage `in_review` - an active outbound dependency blocker → `status=blocked` (stage repaired to a compatible value, e.g. `plan_complete` when the claim sat at `in_progress`) - otherwise → `status=open` at the claimed stage (the folded-in - no-activity/claim-age timeout release: a no-tab claim is released + no-activity/claim-age timeout release: a no-pane claim is released regardless of activity age, reusing the existing 2 h dispatch stale window `DOWNTIME_AUDIT_STALE_WINDOW_MS` as the activity bound) 5. **Focus-resume** — the hidden→visible transition runs the check immediately (same runner, no interval wait). +### Why pane titles, not tabs + +An earlier revision matched the exact **label** of a workspace **tab** +(`herdr tab list`). That proved unreliable in practice +(WL-0MV1BWETT005GSUJ): tabs are created by the dispatcher *anchor* as a +placement side effect, so an item being dispatched can be evaluated before +its tab exists (a false-demotion race), and tabs can be re-parented or +renamed by the operator. Pane **titles** are set at spawn time, always carry +the item ID as a preserved suffix, and survive re-parenting — matching them +is the simple, reliable rule the producer directed: + +> "It should be simple. Look for a pane in this workspace with the work item +> id as the title. If it exists then work item is busy." + ### Safety (fail-open, never crash, never false-demote) -- An unavailable / unparseable `wl list` or `herdr tab list` **aborts the +- An unavailable / unparseable `wl list` or `herdr pane list` **aborts the cycle without demoting** (no demotion on ambiguous evidence). -- An item with a live tab is never touched (the dep query is skipped entirely - for it). +- An item with a matching pane is never touched (the dep query is skipped + entirely for it). - A dependency lookup failure treats the item as not-blocked and still releases it to `open`. - A failed `wl update` is logged and left unchanged (counted as skipped). @@ -57,73 +74,92 @@ like the downtime worker: ## Selection-list in-progress marker (WL-0MV1BWETT005GSUJ) Demotion alone is not enough for the selection list: an item whose worklog -status is still `open` (e.g. a claim released before its tab was re-opened) -would show no marker even though a work-item tab is open. To make the marker -reflect tab existence directly, the same exact-label tab lookup also drives a +status is still `open` (e.g. a claim released before its pane was re-opened) +would show no marker even though a work-item pane is open. To make the marker +reflect pane existence directly, the same pane-title lookup also drives a display overlay: -- `markItemsActiveByTab(items, activeIds)` recursively stamps - `status = 'in-progress'` on every item whose ID is in the active tab set, so - the row renders the in-progress icon and the item is placed in Group N. It - is **idempotent**: each item's pre-marker status is captured on first sight - (a non-enumerable `Symbol` property) and restored on every later pass, so a - **closed** tab clears the marker back to the true worklog status without a - `wl` re-fetch. -- `applyActiveTabMarkers(items, listTabs, workspaceId)` fetches the workspace - tabs (fail-open) and applies the marker; the production wiring injects it - into `runWorklistTui`'s `applyActiveTabMarkers` seam from `index.ts` (the - same "inject from the caller" pattern as `mergeAgentStates`). -- **Update cadence.** Opening or closing a work-item tab does **not** touch the - worklog DB, so the DB-change-gated refresh tick can never update this marker - for it. The worklist therefore re-applies it on the initial load, on every - full refresh, on the `active-tab-marker` scheduler task (the hydrator - cadence, `HYDRATOR_INTERVAL_MS`), and on focus-resume. The marker task - spawns only `herdr tab list` (never `wl`), so the zero-`wl`-when-idle - guarantee holds; it is visibility- and typing-gated like the hydrator. -- Terminal `in_review`/`completed` items are deliberately left untouched: - they already carry the review/audit marker and belong in the In Review - group. +- `markItemsActiveByPane(items, activeIds)` recursively stamps + `status = 'in-progress'` on every item whose ID is in the active pane set, + so the row renders the in-progress icon and the item is placed in Group N. + It is **idempotent**: each item's pre-marker status is captured on first + sight (a non-enumerable `Symbol` property) and restored on every later + pass, so a **closed** pane clears the marker back to the true worklog + status without a `wl` re-fetch. +- The same pass also drives the row's **green-circle icon** (the agent slot + of the icon prefix): an item with a matching pane gets + `agentState = 'working'` (the green circle) and an item without one gets it + cleared. This is the producer's rule — the green circle appears exactly + when a work-item pane is open in this workspace and never otherwise. The + icon no longer depends on the event-driven agent-status tracker + (WL-0MSBQUJQX005RAT9), which could not honour that invariant (a + freshly-spawned/`unknown` pane is absent from `herdr agent list` and was + pruned — losing its icon permanently; an agent `done` state pruned the + icon even while the pane stayed open). +- `applyActivePaneMarkers(items, listActivePanes, workspaceId)` fetches the + workspace panes (fail-open) and applies the marker; the production wiring + injects it into `runWorklistTui`'s `applyActivePaneMarkers` seam from + `index.ts`. +- **Update cadence.** Opening or closing a work-item pane does **not** touch + the worklog DB, so the DB-change-gated refresh tick can never update this + marker for it. The worklist therefore re-applies it on the initial load, on + every full refresh, on the `active-pane-marker` scheduler task (the + hydrator cadence, `HYDRATOR_INTERVAL_MS`), on focus-resume, and after pane + lifecycle events (`pane_agent_status_changed` / `pane_agent_detected` / + `pane_closed` / `pane_exited`) so the green circle tracks open/closed + panes without the 30 s wait. The marker task spawns only `herdr pane list` + (never `wl`), so the zero-`wl`-when-idle guarantee holds; it is + visibility- and typing-gated like the hydrator. +- Terminal `in_review`/`completed` items are deliberately left untouched by + the STATUS marker: they already carry the review/audit marker and belong + in the In Review group. The green circle STILL reflects pane existence for + them — an open pane means the item is being worked regardless of stage. ## Files -- `packages/herdr/src/hydrator.ts` — module: ID extraction, active-tab - matching, demotion decision, tab-based selection-list marker - (`markItemsActiveByTab` / `applyActiveTabMarkers`), compatibility repair, - orchestration (`runHydrationOnce`), visibility-gated runner - (`createHydratorRunner`), production deps - (`createProductionHydratorDeps`). +- `packages/herdr/src/hydrator.ts` — module: ID extraction, active-pane + matching (`collectPaneWorkItemIds` / `isActivePaneMatch`), demotion + decision, pane-based selection-list marker (`markItemsActiveByPane` / + `applyActivePaneMarkers`), compatibility repair, orchestration + (`runHydrationOnce`), visibility-gated runner (`createHydratorRunner`), + production deps (`createProductionHydratorDeps`). - `packages/herdr/src/hydrator.test.ts` — unit tests (AC6 map below), - including the pure tab-marker helper. + including the pure pane-marker helper. +- `packages/herdr/src/hydrator.fixtures.ts` — recording exec seam + + worst-case pane/item fixtures. - `packages/herdr/src/worklist.ts` — registers the `hydrate` and - `active-tab-marker` scheduler tasks, hooks the focus-resume path, and - applies the injected `applyActiveTabMarkers` marker on the initial load, + `active-pane-marker` scheduler tasks, hooks the focus-resume path, and + applies the injected `applyActivePaneMarkers` marker on the initial load, each refresh, the marker cadence, and focus-resume. -- `packages/herdr/src/worklist-active-tab-marker.test.ts` — integration tests - for the selection-list marker wire-up (marker shown/hidden/scope). +- `packages/herdr/src/worklist-active-pane-marker.test.ts` — integration + tests for the selection-list marker wire-up (marker shown/hidden/scope). - `packages/herdr/src/index.ts` — production wiring of the - `applyActiveTabMarkers` seam. + `applyActivePaneMarkers` seam. - `packages/herdr/src/pane-title.ts` — `truncatePaneTitlePreservingSuffix` - keeps the work-item ID visible in truncated pane titles (a human-readable - concern now — the hydrator matches exact tab labels, not pane titles). + keeps the work-item ID visible in truncated pane titles, which is what makes + the pane-title match reliable. ## Test map (AC6) | Requirement | Test | |---|---| | ID extraction | `extractWorkItemIdsFromText` | -| active-tab matching (exact label, workspace-scoped) | `active-tab matching` | +| active-pane matching (title contains ID, workspace-scoped) | `active-pane matching` | | in_review → completed | `decideDemotion` + `runHydrationOnce` | | dependency-blocked → blocked | `decideDemotion` + `runHydrationOnce` | | else → open | `decideDemotion` + `runHydrationOnce` | | stage/status compatibility | `compatibleStage`, blocked-at-`in_progress` case | | focus-resume immediate run | `createHydratorRunner` (visibility flip) | -| selection-list marker (tab → in-progress) | `tab-based active marker (selection list)` + `worklist-active-tab-marker.test.ts` | -| fail-open error paths | `runHydrationOnce` (missing list, missing tabs, failed demotion) | +| selection-list marker (pane → in-progress) | `pane-based active marker (selection list)` + `worklist-active-pane-marker.test.ts` | +| green circle (pane → `agentState`) stamp/clear | `markItemsActiveByPane` cases + `worklist-active-pane-marker.test.ts` | +| event-driven pane refresh (no `herdr agent list` exec) | `worklist-agent-state-event.test.ts` | +| fail-open error paths | `runHydrationOnce` (missing list, missing panes, failed demotion) | ## Related - `WL-0MSOJLZD9004P8PI` (this feature) · `WL-0MTTSWE0G008M1VA` (folded-in no-activity timeout, deleted) · `WL-0MTTS9FY00055QJV` (RCA root cause E) · `WL-0MSJ4E8UA005KG9Y` (pane-title format, completed) · - `WL-0MSBQUJQX005RAT9` (agent-panes association — out of scope here, per - the operator's pane-title decision). \ No newline at end of file + `WL-0MV1BWETT005GSUJ` (pane-title active-detection rework, this item) · + `WL-0MSBQUJQX005RAT9` (agent-panes association — now record-keeping only; + the row green-circle icon is pane-title existence, WL-0MV1BWETT005GSUJ). diff --git a/docs/dev/pause-when-hidden.md b/docs/dev/pause-when-hidden.md index acb03a6f5..4233629d0 100644 --- a/docs/dev/pause-when-hidden.md +++ b/docs/dev/pause-when-hidden.md @@ -35,7 +35,7 @@ Two pollers were fixed: resumes the normal cadence. - The idle threshold is a named module constant (`IDLE_PAUSE_MS`, default 30_000ms) — no settings toggle, always on. -- Manual actions (navigation, shortcuts, `r`/`S`-style refreshes) are never +- Manual actions (navigation, shortcuts, `Ctrl+r`/`S`-style refreshes) are never gated; the detail-view widget (no interval) is unaffected. ### Herdr worklist pane (tab-focus gating) @@ -71,8 +71,20 @@ Two pollers were fixed: chunk. Non-mouse chunks clear the buffer. - Mouse actions dispatched: left-click selects a row, double-click (same row, ≤400 ms) opens detail; wheel/touch-scroll navigates list or scrolls detail - (j/k-equivalent); filter-prompt taps apply a stage filter. Drag-motion and - release events are inert. Middle/right buttons are ignored. + (j/k-equivalent); filter-prompt taps apply a stage filter. Middle/right + buttons are ignored; release events are inert. +- **Drag-to-scroll (WL-0MSI72S0Y0060BRK):** motion events are split by the + SGR button field. Button-less motion (`(button & 3) === 3`) drives the + hover tooltip (hover-row/hover-none); left-button-held motion + (`(button & 32) !== 0`, `(button & 3) === 0` — e.g. touch drag / click-drag) + drives drag-to-scroll via the pure `mapMouseDrag` mapper plus an + accumulated `MouseDragState`. Movement past `DRAG_THRESHOLD_CELLS` (1 row) + activates the drag: the list scrolls by whole rows (`scrollBy`, clamped to + `[0, maxOffset]`) and the detail view by whole lines (reusing the existing + detail clamp); the pre-press selection is restored on activation so a drag + never selects, and a drag-release never clicks. Drag is inert in filter + mode and the metadata panel. Drag-motion is therefore **no longer inert** — + this supersedes the earlier "motion events are inert" note above. - **Fail-soft:** terminals that do not support SGR mouse reporting (1006) never send mouse events — the worklist remains fully keyboard-usable. - Mouse input is ignored during modal states (code-freeze notice, form input, @@ -81,8 +93,9 @@ Two pollers were fixed: default on raw-mode entry. Pressing `Alt+m` (`\x1bm`) toggles it off so the terminal's native text-selection (drag-select to copy content from the terminal) works again, and toggles it back on to resume mouse interaction. - The toggle always works (handled before modal-state guards) and is reflected - in the footer hint (`alt+m mouse on` / `alt+m mouse off`). + The toggle always works (handled before modal-state guards). Its inline + footer advertisement was removed in WL-0MTV97GV20088CS7 in favour of the + `?` help screen; the binding itself is unchanged. - When the TUI is paused (hidden tab), mouse input is irrelevant because the `onData` handler is never called — stdin is only read while the pane is visible (the input loop runs inside the scheduler-visible cadence). diff --git a/docs/dev/rca-wrongful-needsproducerreview-removal.md b/docs/dev/rca-wrongful-needsproducerreview-removal.md new file mode 100644 index 000000000..cee4ea961 --- /dev/null +++ b/docs/dev/rca-wrongful-needsproducerreview-removal.md @@ -0,0 +1,108 @@ +# RCA — How the wrongful `needsProducerReview` removal was created and implemented + +**Item:** WL-0MVAOYBZM0065AEN (discovered-from WL-0MVAMS4RO000H3B0) +**Date:** 2026-10-16 +**Subject:** WL-0MV85WGIT004PHRC (Remove needsProducerReview from herdr, theme and interview, commit `d18ea50d`), its core sibling WL-0MV85WFYU0071YQ9 (commit `e0f04202`), and their parent epic WL-0MV82CHGQ000HAZZ. + +## Summary + +A real, narrow bug — the herdr audit-tier claim flipping `completed`/`in_review` items into a hybrid `in-progress`/`in_review` state and stranding them on a failed audit (WL-0MV7W77AY005MGBL) — was fixed through an epic (WL-0MV82CHGQ000HAZZ) whose "Option A" bundled the legitimate claim fix together with an **unrequested, destructive removal** of the `needsProducerReview` flag, its CLI surface (`wl reviewed`), its selection-list icon, and its release/delivery semantics. The producer approved the **fix mechanism** (status-neutral assignee CAS claim) but no explicit producer decision "remove the producer-review feature" was ever recorded: the removal rode along inside an autonomously-intaken epic, advanced through intake → plan → implement → in_review on the automated lifecycle, with its own open interview questions (Q2, Q3) never answered. The result was the user-visible symptom reported as the parent bug: the producer-review icon disappeared from the herdr selection list, and the feature the operator relied on was gone. + +## Trace points + +### (a) Decision trail: root-cause bug → 'Option A' intake + +- **WL-0MV7W77AY005MGBL** (open, plan_complete) — *Herdr downtime audit claim leaves item in-progress/in_review; failed audit coerces to in-progress/plan_complete.* This is a genuine defect. Its intake interview recorded two producer answers: + - Q1 (fix approach): **(A)** status-neutral herdr claim (epic scope), (B) audit-runner restore fix, (C) staged B-now/A-later. **Producer answer: A.** + - Q2 (repo boundary): cross-repo audit-runner changes acceptable in SorraAgents. **Producer answer: (a) yes.** +- The producer's option choice was about **how** to fix the claim bug (status-neutral CAS), quoted verbatim in the bug's appendix: *"Chosen approach (producer decision): Option A — make the audit-tier claim status-/stage-neutral."* +- **Nowhere** in the root-cause bug's appendix, or in the epic's appendix, is there a producer answer approving *"remove the `needsProducerReview` flag and its icon."* The bug's constraints even say: *"`needsProducerReview` removal and the assignee-based scheduling hold are out of scope for the immediate bug fix — they belong to epic WL-0MV82CHGQ000HAZZ."* — i.e. the removal was a **separate, non-bug scope**, appended to the epic. + +### (b) Interview answers: producer-answered vs agent-auto-resolved + +- Epic WL-0MV82CHGQ000HAZZ was intaken by an automated herdr-dispatched session. Its Appendix records: *"No interactive interview conducted — herdr-dispatched session; seed context was exhaustive."* (epic description, "Open questions (Appendix)"). +- Appendix Q1 (sync regression guard) — answered later by **Map** (2026-10-14T20:04) — but this is an implementation detail about assignee-sync semantics, not approval of the removal. +- Appendix Q2 (producer-hold assignee value — i.e. the *semantics of the replacement*) — recorded **OPEN QUESTION** ("record the operator's identity during planning"); never answered. +- Appendix Q3 (dependency direction) — recorded **OPEN QUESTION** ("traceability reviewed during planning"); never answered. +- The epic's own "Approach (Option A, per root cause fix-options)" was written by the intaking agent with no producer review of the bundle. The producer's only direct answers (bug Q1/Q2, epic Q1) approve the **mechanism**, not the **damage**. + +### (c) Who/what created the epic and children, when, on what evidence + +| Item | Created | Session origin | How it advanced | +|---|---|---|---| +| WL-0MV82CHGQ000HAZZ (epic) | 2026-10-14T12:05 | pi session `herdr-1791969498…` (automated) | intake `Map` agent; plan split 12 children | +| WL-0MV85WFYU0071YQ9 (core removal) | 2026-10-15 | herdr-dispatched | implemented (commit `e0f04202`) | +| WL-0MV85WGIT004PHRC (herdr/theme/interview removal) | 2026-10-15 | herdr-dispatched | implemented (commit `d18ea50d`) | +| All 13 epic children | 2026-10-15 → 10-16 | herdr/implement sessions | each pushed to `dev`; all terminal | +| Parent in_review | 2026-10-16T01:26 | automated parent phase ("All children … terminal. Parent advanced.") | no human checkpoint | + +Evidence: epic + child comment timestamps (`wl comment list --json`), commit hashes (`e0f04202`, `d18ea50d`), session-id comments. Every implementation phase ran with `--allow-orphaned-stashes` after "11 orphaned stash(es) detected" — a workflow-hygiene warning acknowledged and overridden on every leg. + +### (d) Why a removal advanced without producer approval + +Chain of contributing factors: + +1. **Scope conflation.** The approved fix (status-neutral claim) and the unapproved removal (drop `needsProducerReview`, re-home producer hold onto `assignee`) were **packaged as one epic**. The epic's own constraint acknowledges the removal is outside the bug's immediate fix, yet the removal was planned as children C10–C12 of the same epic. +2. **No destructive-change approval gate.** No mechanism exists that requires an explicit producer sign-off before an item whose ACs remove an existing, user-visible feature (flag, command, icon) advances past `plan_complete`. Nothing checked "does any AC remove producer-review functionality?" — the removal passed intake, plan, implement and in_review without such a checkpoint. +3. **Open interview questions did not block.** The epic went to `plan_complete`/`in_review` with Q2 (replacement semantics) and Q3 open. The lifecycle treats an open question as non-blocking; only AC coverage gates advancement, and coverage is reviewed by the same automated pipeline that wrote the plan. +4. **Option presentation buried the damage.** The producer was asked about the "fix approach", not asked to confirm the removal. "Option A" read as a claim-CAS redesign; the fact that its fulfilment deleted the producer-review flag + icon was buried in child descriptions, never surfaced as a confirmation the producer could see. +5. **Automated advancement end-to-end.** An autonomous herdr-dispatched intake ("seed context exhaustive") fed an autonomous plan that fed implement sessions to `dev` and then an automated parent phase advanced the whole epic and parent to `in_review` in one comment — with no human in the loop at any stage after the initial mechanism decision. + +No evidence was found of any producer comment approving the removal. Search of the epic, both removal children and the root-cause bug's comments shows producer-authored messages only on Q1-sync semantics (Map, 2026-10-14T20:04) and on the root-bug Q1/Q2 (mechanism + repo boundary). + +### (e) What would have prevented it + +Preventive mechanisms are detailed in the recommendations below; the minimal set that would have caught this specific failure: an explicit producer-approval AC on destructive removals (blocked the epic), an open-question-advancement guard (blocked the epic at plan), and surfacing the removal as a separate, producer-confirmed decision (the operator would have answered "no", as they did in the intake of WL-0MVAMS4RO000H3B0: *"WL-0MV85WGIT004PHRC should not have been created and implemented."*). + +## Root-cause chain + +``` +Systemic: automated herdr lifecycle treats "seed context exhaustive" as an interview + └─▶ Proximate 1: epic bundled a + approved CLAIM fix with unapproved FEATURE removal + └─▶ Proximate 2: no destructive-change approval gate (nothing blocked the removal) + └─▶ Proximate 3: open questions Q2/Q3 did not block plan → implement → in_review + └─▶ Outcome: needsProducerReview flag, wl reviewed, and the + selection-list icon removed from dev (e0f04202, d18ea50d) + without a single producer-approved "remove this feature" decision +``` + +Contributing root causes: +- **R1 — Missing destructive-change gate** (process): removal of user-visible features is not gated on explicit producer approval. *Direct enabler.* +- **R2 — Open questions are advisory** (workflow): unresolved producer questions do not block stage advancement. +- **R3 — Option framing** (interview): fix-mechanism questions double as implicit scope approval; removal deltas are not separated. +- **R4 — Automated full-loop autonomy** (pipeline): intake→plan→implement→in_review with no human checkpoint; every leg overrode orphaned-stash hygiene warnings. +- **R5 — Self-cleaning removal** (repo hygiene): the removal deleted the very tests/docs that protected the feature in the same commits, so nothing subsequently failed (a symptom only surfaced via the operator's eye on the TUI). + +## Recommendations (3–5) + +1. **Explicit producer-approval AC for destructive removals.** + - *Control:* The intake/plan skills add a mandatory AC template when a work item removes or disables existing user-visible functionality: `Producer approved removal of — `. The item **cannot** advance past `plan_complete` without a non-empty, producer-authored approval evidence. + - *Where:* intake (`SKILL.md` AC template), plan coverage review (tree_coverage), and the implement definition gate. + - *Success signal:* no item whose ACs delete a flag/command/icon reaches `implement` stage with empty approval evidence; a `wl`-based DB query finds zero `plan_complete+` items with removal ACs lacking a producer comment. + +2. **Open producer questions block stage advancement.** + - *Control:* Any work item (epic or leaf) whose appendix contains an `OPEN QUESTION` labelled "directed to: producer" is **blocked from `plan_complete` and `in_review`** until the question is answered or explicitly withdrawn (owner: producer). + - *Where:* the shared `tree_coverage` / plan completion gate and the implement parent-advancement phase. + - *Success signal:* the epic pattern "Q2/Q3 OPEN but advanced" becomes impossible; automated parent advancement refuses to promote a parent whose children carry unresolved producer questions. + +3. **Separate "fix mechanism" from "feature removal" in option framing.** + - *Control:* When an epic presents options, the interview Appendix must list each **destructive delta** (what existing behaviour, command, flag, or icon will be removed) as its own multiple-choice/confirm entry with a producer answer — never buried inside a mechanism option. + - *Where:* plan/intake interview contract (`Appendix` recording rules). + - *Success signal:* an audit of any epic's appendix finds every removal delta with an explicit producer answer; "Option A" style bundles must enumerate their deletions. + +4. **Human checkpoint before automated parent advancement for removal-scope epics.** + - *Control:* `implement.py parent` refuses to advance an epic whose AC set includes a destructive-removal AC until a producer confirmation exists (mirrors rec. 1), and surfaces a **"hold for producer" summary** naming the removed features and affected surfaces instead of an "All children terminal → advanced" auto-comment. + - *Where:* implement parent/drive phase. + - *Success signal:* the 2026-10-16T01:26 autonomous advancement pattern (13 children + parent to in_review in one automated comment) cannot recur for destructive epics. + +5. **Guard against self-cleaning removals (delete-with-tests).** + - *Control:* A repo CI/workflow check flags commits that **both delete feature-protection test files and touch their source module** (as `e0f04202`/`d18ea50d` did) — demanding a human review note or a linked producer-approved removal item before `dev` → `main`. + - *Where:* pre-push hook or a small `scripts/guard-removal-self-clean.mjs`; aligned with the existing blame-guard style test in `tests/unit/icons.test.ts`. + - *Success signal:* the combined-removal pattern (source + tests + docs in one commit with no dismissal flag) fails the check. + +## Evidence links + +- Work items: WL-0MV7W77AY005MGBL, WL-0MV82CHGQ000HAZZ, WL-0MV85WFYU0071YQ9, WL-0MV85WGIT004PHRC, WL-0MV85WARS000THEC…WL-0MV85WH2K0010ISF (13 children), WL-0MVAMS4RO000H3B0, WL-0MVAOYBZM0065AEN. +- Commits: `e0f04202` (core removal), `d18ea50d` (herdr/theme/interview removal), `c381bbf6` (test-first precedent), `ff2305c5` (assignee-CAS impl). +- Docs: `docs/icons-design.md` §3b ("no dedicated icon column is rendered"), shared interview contract `wl reviewed` handoff reference (command removed by the removal). +- Code: `packages/herdr/src/producer-hold.ts`, `packages/herdr/src/grouping.ts` `inReviewBucket`, `packages/shared/src/icons.ts`. diff --git a/docs/dev/test-suite-baseline.md b/docs/dev/test-suite-baseline.md index b0f13b42c..0f7379327 100644 --- a/docs/dev/test-suite-baseline.md +++ b/docs/dev/test-suite-baseline.md @@ -264,11 +264,15 @@ Five alternating iterations of each state were run; the **median** is quoted > **Isolation note.** `vitest.measure.config.ts` extends `vitest.config.ts` > and aliases `@worklog/shared` (and its subpaths) to the worktree's own -> freshly built `packages/shared/dist`. This was required because concurrent -> `implement.py` builds in other worktrees repeatedly overwrite the main -> checkout's `packages/herdr/node_modules/@worklog/shared` copy (via -> `install-herdr.sh`), causing intermittent `ERR_MODULE_NOT_FOUND` collection -> failures. The alias does not change any test semantics. +> freshly built `packages/shared/dist`. This was required while concurrent +> `implement.py` builds in other worktrees repeatedly overwrote the main +> checkout's packaged `packages/herdr/node_modules/@worklog/shared` copy +> (via `install-herdr.sh`), causing intermittent `ERR_MODULE_NOT_FOUND` +> collection failures. Since WL-0MVAY631M004RZ3Q that dependency is a +> symlink to the live workspace package (link-shared.mjs) instead of a +> packaged copy, so builds no longer rewrite it; the alias is retained for +> measurement isolation (the worktree's own dist) and does not change any +> test semantics. ### Results diff --git a/docs/icons-design.md b/docs/icons-design.md index a23383f9c..6d3373c6d 100644 --- a/docs/icons-design.md +++ b/docs/icons-design.md @@ -17,6 +17,26 @@ > of those intentionally differ from the CLI's `src/theme.ts` choices (see > §13.1). +> **Help-screen legend (WL-0MTV97GV20088CS7):** the herdr worklist's full-pane +> help screen (pressing `?`) renders an **Icons** legend explaining every glyph +> shown in a work-item row. The legend is grouped by the icon **category** +> (`Status`, `Stage`, `Priority`, `Epic`, `Risk`, `Effort`, `Audit`, +> `Producer review`, `Agent`, `Coverage`) under a heading per group, with each +> group's icons on a single line. The category name prefix is removed from each +> meaning because the group heading already names it (e.g. `Status: open — not +> started` renders as `open — not started` under **Status**). Since the +> 2026-10-16 producer audit, every heading level is colour-coded (title gold, +> section headings blue, category sub-headings cyan) and separated by blank +> lines, and the whole overlay scrolls when content exceeds the pane height +> (`j/k`, arrows, PageUp/PageDown, `g/G`; the footer shows a live percentage; +> the title and the `? / Esc / q` dismiss line stay pinned). The on-screen +> legend is generated from `ICON_LEGEND` in `@worklog/shared/icons` (the same +> single source of truth the row renderer uses); a drift test in +> `packages/shared/src/icons-legend.test.ts` fails whenever a row glyph has no +> legend entry. The wording in this specification is the authoritative +> reference for those meanings — keep `ICON_LEGEND` aligned with the tables +> below. + ## Overview This document defines the icon set for work item **priority** and **status** used @@ -36,13 +56,13 @@ across the CLI (chalk) and TUI rendering paths. It covers: |-----------|--------|---------------|-------------------------|---------------------| | critical | `🚨` | `[CRIT]` | "Critical priority" | Rotating light - urgent/danger | | high | `⭐` | `[HIGH]` | "High priority" | Star - important | -| medium | `📋` | `[MED]` | "Medium priority" | Clipboard - standard task | +| medium | `🟨` | `[MED]` | "Medium priority" | Yellow square - standard task | | low | `🐢` | `[LOW]` | "Low priority" | Turtle - slow/low priority | **Colour association:** The emoji colours are enhanced with chalk color tags to match the existing colour scheme in the theme (`theme.priority` colours) so scanning by colour remains consistent. - critical: red (🚨) - high: orange (⭐) -- medium: yellow (📋) +- medium: yellow (🟨) - low: white (🐢) > The underlying title/id text colour scheme is priority-based: critical → red, high → orange, medium → yellow, low → white (revised 2026-09-08, WL-0MSJ2JFMO007PGQ6). See [COLOUR-MAPPING.md](COLOUR-MAPPING.md). @@ -327,7 +347,6 @@ old herdr copy had diverged, the shared module keeps the *herdr* choices: |------|----------------------|--------------------| | Audit unknown | `❓` | `❔` | | Audit stale-passed | `⏳` | `🟩` (see §3a) | -| Epic | `⊙` | `🏰` | | Risk critical | `💥` | `🚨` (key `severe`) | | Effort levels | `🔹 🔷 🔶 💠` | `🐜 🐇 🐕 🐘 🐋` | | Producer review undefined | `''` (nothing shown) | `✅` (defaults to complete) | @@ -342,7 +361,7 @@ Icons are prepended before the title in CLI list output: ``` > The Pi-based TUI browse list (which rendered status/stage/producer-review/ -> epic icons via `getIconPrefix` in `packages/tui/extensions/Worklog/lib/browse.ts`) +> icons via `getIconPrefix` in `packages/tui/extensions/Worklog/lib/browse.ts`) > has been removed — work item browsing is now provided by the Herdr plugin. > `src/theme.ts` remains the CLI's self-contained icon source; the Herdr > plugin imports from the shared `@worklog/shared/icons` module. @@ -356,12 +375,15 @@ column aligns across rows, and the columns are (left to right): | Column | Content | Notes | |--------|---------|-------| -| 0 | Priority icon | `🚨` critical, `⭐` high, `📋` medium, `🐢` low (added WL-0MTQYTA20009YXBT) | +| 0 | Priority icon | `🚨` critical, `⭐` high, `🟨` medium, `🐢` low (added WL-0MTQYTA20009YXBT) | | 1 | Agent status | Fixed-width reserved slot (`AGENT_SLOT_WIDTH`) | | 2 | Status icon | `statusIcon` | | 3 | Stage / audit icon | `stageDisplayIcon` (audit-aware for `in_review`) | | 4 | Producer review flag | `needsProducerReviewIcon` | -| 5 | Epic icon | Optional, when `issueType === 'epic'` | + +> The epic column (formerly column 5, `⊙` for `issueType === 'epic'`) was +> removed in WL-0MVAQ873D007LNWD — parent items are already marked by the +> expand arrow, so the indicator was redundant. When the priority is missing the priority column is empty; the fixed-width padding keeps the remaining columns aligned. In `noIcons` mode the priority @@ -472,5 +494,5 @@ Description: A test item for audit formatting **TUI list:** ``` ▸ 🚨 ⭐ Set up CI pipeline (TEST-1) - ├── 📋 🔄 Write tests (TEST-2) + ├── 🟨 🔄 Write tests (TEST-2) ``` diff --git a/package.json b/package.json index 69cbcc7c1..04d1fd929 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "worklog", - "version": "1.0.21", + "version": "1.0.22", "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 1442422c0..e1f987bb6 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -11,19 +11,19 @@ A Herdr plugin that provides a keyboard-navigable work item selection list for b - **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`. Panes spawned from the selection list open **without stealing focus**: the list keeps the keyboard focus while the command-output pane opens in the background (see [Design decisions](#design-decisions)). When the command carries a work-item ID, the pane is placed in that item's work-item-ID tab in the current workspace (see [Interactive pane placement](#interactive-pane-placement-work-item-id-tabs)). - **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. -- **Mouse and touch** — Click/tap a row to select; double-click to open detail; mouse wheel or touch-scroll to navigate (list mode) or scroll detail; tap stage options in the filter prompt. Requires SGR mouse reporting (enabled on raw-mode entry, disabled on exit). Terminals without it fall back to keyboard-only. **Alt+m toggles mouse tracking** on/off (WL-0MT0AP2LR000JFWN): while tracking is off, the terminal's native drag-select-to-copy works; press Alt+m again to resume mouse interaction. The current state is shown in the footer (`alt+m mouse on/off`). **Hover tooltip** (WL-0MT9XRZDK006GMUH): hovering the mouse over a row that has an associated agent-pane shows a metadata tooltip in the footer area (ID, Title, Command, Priority, Type, Risk, Effort, Start Time); `Esc` dismisses it until the mouse leaves and re-enters the list. +- **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, **Ctrl+r to refresh the list immediately** (re-fetches items from the worklog DB and re-renders, preserving the current selection when the item still exists — WL-0MV85T0LT008UT9H; works regardless of the auto-refresh setting). +- **Mouse and touch** — Click/tap a row to select; double-click to open detail; mouse wheel or touch-scroll to navigate (list mode) or scroll detail; tap stage options in the filter prompt. Requires SGR mouse reporting (enabled on raw-mode entry, disabled on exit). Terminals without it fall back to keyboard-only. **Alt+m toggles mouse tracking** on/off (WL-0MT0AP2LR000JFWN): while tracking is off, the terminal's native drag-select-to-copy works; press Alt+m again to resume mouse interaction. The footer no longer advertises the toggle (the binding is documented here and in the help screen). **Hover tooltip** (WL-0MT9XRZDK006GMUH): hovering the mouse over a row that has an associated agent-pane shows a metadata tooltip in the footer area (ID, Title, Command, Priority, Type, Risk, Effort, Start Time); `Esc` dismisses it until the mouse leaves and re-enters the list. **Drag-to-scroll** (WL-0MSI72S0Y0060BRK): with the left button (or touch contact) held, drag vertically to grab and scroll the content — the worklist scrolls by whole rows and the detail view by whole lines, both clamped to their bounds; the selection never moves from a drag. Motion is split by button state: button-less motion drives the hover tooltip, while left-button-held motion drives drag-to-scroll, so plain clicks and double-clicks are unaffected. - **Fold indicators** — When the worklist has more items than fit the visible list area, the list shows dim `▼ more` / `▲ more` markers so you always know when items are hidden below the fold or above the current scroll position (WL-0MSG8YXYJ008PWJJ). See [Selection List Behaviour](#selection-list-behaviour). - **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. The agent pane opens **without stealing focus** from the selection list (see [Design decisions](#design-decisions)). When the command carries a work-item ID, the pane is placed in that item's work-item-ID tab in the current workspace (reused if it already exists) — see [Interactive pane placement](#interactive-pane-placement-work-item-id-tabs). - **Downtime worker (local-LLM idle dispatch)** — During operator idle time the plugin dispatches pi agent panes to run audits/refactors of completed items against the local llama-server (see [Downtime worker](#downtime-worker-local-llm-idle-dispatch)) -- **Hydrator (self-healing `in_progress` claims)** — Every 30 seconds the plugin fetches all `in_progress` work items, matches each against the tabs of the current workspace (the tab label exactly equals the work-item ID), and releases any claim with no matching tab so it re-enters the dispatchable pool: `in_review` items complete, dependency-blocked items are marked `blocked`, everything else returns to `open` at its claimed stage. The check also runs immediately when the worklog tab regains focus, and is fully visibility-gated (a hidden tab spawns zero `wl`/`herdr` processes). The same exact-label tab set drives the selection list's **in-progress marker**: an item with an exact-match tab is shown as `in-progress` (marker + Group N) even when its worklog status is still `open`, and loses the marker when the tab closes. See [Hydrator](#hydrator-self-healing-in_progress-claims-wl-0msojlzd9004p8pi). +- **Hydrator (self-healing `in_progress` claims)** — Every 30 seconds the plugin fetches all `in_progress` work items, matches each against the panes of the current workspace (the pane title contains the work-item ID), and releases any claim with no matching pane so it re-enters the dispatchable pool: `in_review` items complete, dependency-blocked items are marked `blocked`, everything else returns to `open` at its claimed stage. The check also runs immediately when the worklog tab regains focus, and is fully visibility-gated (a hidden tab spawns zero `wl`/`herdr` processes). The same pane-title lookup drives the selection list's **in-progress marker**: an item with a matching pane is shown as `in-progress` (marker + Group N) even when its worklog status is still `open`, and loses the marker when the pane closes. See [Hydrator](#hydrator-self-healing-in_progress-claims-wl-0msojlzd9004p8pi). - **Mode-switch worker (activity-gated proxy mode switching)** — Automatically switches the llama-proxy between fast (cloud) and cheap (local) modes: agent-route commands fire an immediate fast switch (fail-open), while a full operator-idle window plus a proxy-idle check triggers the cheap switch (fail-closed). The proxy URL reuses `downtimeProxyUrl` and the plugin's switches are manual overrides that the proxy's own time schedule reclaims. See [Mode-switch worker](#mode-switch-worker-activity-gated-proxy-mode-switching-wl-0msn3fwv5008kqe9). -- **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 — including the dispatched command — 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. Hovering a pane-associated row shows a tooltip with the work-item metadata (including the recorded command and pane start time). See [Agent status icons](#agent-status-icons). +- **Work-item pane indicator** — The selection list shows a green circle icon (🟢) in the icon prefix of every item with an open work-item pane in the current workspace: a pane whose title carries the work-item ID (the dispatch pane-title convention preserves the ID as a trailing suffix). The icon appears exactly when such a pane is open and never otherwise (producer's rule, WL-0MV1BWETT005GSUJ) — it is pane existence, not a live agent status. The worklist also records which pi agent pane is attached to each dispatched item — including the dispatched command — persisted to the gitignored `.worklog/agent-panes.json`, shared across worklist panes (used for the hover tooltip). The icon occupies a fixed-width slot so the item-ID column never shifts. See [Green-circle icon (work-item pane open)](#green-circle-icon-work-item-pane-open). - **Open Pi Agent action** — The plugin provides an action to open a fresh interactive pi session pane - **Tab-based opening** — The worklist opens in a new tab in the current workspace, providing full-screen access without reducing space for existing panes - **Worklog tab** — The `open-worklog-tab` action (bound to `prefix+l`) opens the worklist pane in a new tab and renames that tab to **`Worklog`** so it is instantly recognisable in the tab row among other open tabs. See [Worklog tab](#worklog-tab). - **Quit** — Press `q` to exit -- **Metadata panel** — 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) is shown in a panel below the list. The list takes as much vertical space as its content needs, up to the full pane height; the metadata panel fills whatever space remains — expanding when the list is short, and sitting at the bottom (minimum 3 rows) when the list is long. The panel scrolls independently with `m`/`M` (down/up) so long metadata never affects list navigation. See [Metadata panel](#metadata-panel). +- **Metadata panel** — The selected item's metadata (ID, title, status, stage, priority, type, risk, effort, tags, audit info, its **active dependency blockers**, and more, plus a **description preview** — first up-to-3 lines of the item's description) is shown in a panel below the list. The list takes as much vertical space as its content needs, up to the full pane height; the metadata panel fills whatever space remains — expanding when the list is short, and sitting at the bottom (minimum 3 rows) when the list is long. 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_review`, `done` — no custom stage values). Actively-worked items are identified by `status = in-progress`, not by a stage. 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, 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). @@ -33,7 +33,7 @@ A Herdr plugin that provides a keyboard-navigable work item selection list for b ## Requirements -- [Herdr](https://herdr.dev) v0.7.0 or later (the plugin loads on 0.7.0–0.7.4 with polling-only behavior; the event-driven status path — immediate agent-status icons and focus/visibility updates — requires herdr ≥ 0.7.5 and degrades to polling on older versions) +- [Herdr](https://herdr.dev) v0.7.0 or later (the plugin loads on 0.7.0–0.7.4 with polling-only behavior; the event-driven path — immediate pane-existence icon and focus/visibility updates — requires herdr ≥ 0.7.5 and degrades to polling on older versions) - [Worklog CLI (`wl`)](https://github.com/your-org/worklog) installed and on PATH ## Installation @@ -57,6 +57,7 @@ cd packages/herdr && npm run build - Links the plugin from the **main checkout** with `herdr plugin link /packages/herdr/herdr-plugin.toml`. When the build runs inside a linked git worktree (as happens during `/skill:implement`), the main checkout is resolved via `git worktree list` so the global herdr plugin is never registered against a transient worktree path — a worktree-based link would dangle once the worktree is cleaned up and silently break `prefix+l` (WL-0MSRG481O007QVEA). `herdr plugin link` updates an existing link in place, so a stale link from an older build is corrected automatically. - Inserts the `prefix+l` → `worklog-selection-list.open-worklog-tab` keybinding into your herdr config (`~/.config/herdr/config.toml`, or `$HERDR_CONFIG_PATH` when set) **only if** it is not already present — re-running the build never creates duplicate keybindings. - Migrates a legacy `prefix+l` → `worklog-selection-list.open-worklist` (or intermediate `open-podcast-editor-tab`) binding in-place to the new action, so the Worklog tab name takes effect without a manual config edit. When the new binding is already present, any leftover legacy block is purged instead, so a stale duplicate `prefix+l` key cannot survive a partially-migrated config (WL-0MSRG4828007ITI4). +- Installs the plugin's npm dependencies (`npm ci`/`npm install` in `packages/herdr`) and then **links `@worklog/shared` to the live workspace package**: `install-herdr.sh` runs `packages/herdr/scripts/link-shared.mjs`, which replaces npm's packaged **copy** of `@worklog/shared` (a `file:` dependency) with a relative symlink to `packages/shared`. A packaged copy goes stale whenever `packages/shared` changes — the plugin would then import a missing export and herdr would close the pane on startup (WL-0MVAY631M004RZ3Q). With the symlink the plugin always reads current Shared code after a build. The plugin's own `postinstall` and `npm run build` (which now compiles `packages/shared` first) re-assert the same link, so a plain `npm install` or `npm run build` inside `packages/herdr` is safe too. - Warns (without failing the build) when `herdr` is not on PATH or the config cannot be written, so `npm run build` succeeds in CI/offline environments. Alternatively, `npm run install` (or `npm run install:plugin` for the herdr step alone) runs the same install script outside of a build context. @@ -120,7 +121,8 @@ The plugin pane will then be available via the Herdr plugin system. 4. Workflow shortcuts (single-key): - Press `c` — Create a new work item - - Press `d` — **Toggle downtime dispatch for the current pane** (per-pane only — sibling panes and other worklog roots are unaffected; the header shows `[Downtime Off]` while off and the live status when on. The disable persists across plugin restarts via a `.herdr-downtime-disabled` marker in the worklog root; press `d` again to return to following the global setting. See [Downtime worker](#downtime-worker-local-llm-idle-dispatch)) + - Press `d` — **Toggle downtime dispatch for the current pane** (per-pane only — sibling panes and other worklog roots are unaffected; the header shows `[Downtime Off]` while off and the live status when on. The disable persists across plugin restarts via a `.herdr-downtime-disabled` marker in the worklog root; press `d` again to return to following the global setting. The binding remains fully functional — only its inline footer advertisement was replaced (see Help screen below). See [Downtime worker](#downtime-worker-local-llm-idle-dispatch)) + - Press `?` — **Open the help screen**: a full-pane overlay listing every list-view shortcut (grouped by chord leader) plus an icon legend explaining every glyph rendered in a work-item row. `?`, `Esc` or `q` dismiss it back to the list with no state change. The help screen is display-only — it never dispatches commands, spawns panes, or mutates the worklog. The footer advertises it as `?: Help`. When `showHelpText` is `false` the footer hint and the `?` overlay are both hidden. See [Help screen](#help-screen). - Press `i` — Run the implement workflow on the selected item (intake_complete, plan_complete) - 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) @@ -207,22 +209,22 @@ Semantics: This view is **per-worklog-root**; machine-wide cross-project aggregation is out of scope here and tracked separately (WL-0MTJQOZ0K007KD40). -### Agent status icons +### Green-circle icon (work-item pane open) -Rows that have a worklist-spawned pi agent pane attached show the agent's live status as the first icon in the row's icon prefix: +The row's green-circle icon (the second icon of the icon prefix) shows whether a **work-item pane is open in the current workspace** — the producer's rule (WL-0MV1BWETT005GSUJ): | Icon | Meaning | |------|---------| -| 🟢 | Agent is working on the item | -| ⛔ | Agent is blocked | -| ⚪ | Agent is idle (spawned but not currently active) | -| *(none)* | No agent attached, or the agent finished (`done`/closed) | +| 🟢 | A pane in this workspace has the work-item ID in its title (the item is being worked on) | +| *(none)* | No such pane is open in this workspace | -The icon occupies a fixed-width slot, so the remaining icons and the item-ID column stay perfectly aligned whether or not a row has an agent. State is refreshed from the herdr CLI on each worklist refresh (with a short TTL); if the CLI is unavailable the list renders without icons rather than failing. +The icon is pane-existence, not a live agent status: a freshly-spawned or `unknown`-status pane, and even a `done` agent whose pane is still open, show the green circle; the moment the pane closes (or is moved to another workspace) the icon disappears. It is driven by the same `herdr pane list --workspace ` pane-title lookup as the in-progress marker (`markItemsActiveByPane` in `packages/herdr/src/hydrator.ts`; see `docs/dev/herdr-hydrator.md`) and is refreshed on the initial load, every refresh, the 30 s hydrator cadence, focus-resume, and pane lifecycle events. The icon occupies a fixed-width slot, so the remaining icons and the item-ID column stay perfectly aligned whether or not a row has a pane. Fail-open: if the pane lookup is unavailable the list renders without green circles rather than failing. -**Event-driven updates (herdr ≥ 0.7.5, WL-0MSHB7DHO004RHBJ)** — when the herdr event subscription is active, a `pane_agent_status_changed` event updates the icon **immediately** (within one render cycle, no `herdr agent list` exec). `pane_agent_detected` re-reads the shared `.worklog/agent-panes.json` so agents spawned by any herdr instance in the same session (including late-spawned ones) are picked up; `pane_closed`/`pane_exited` prune associations so icons clear instantly. Rapid status changes are coalesced per render cycle (no render thrash). `herdr agent list` remains the one-time initial snapshot and the fail-open poll fallback when events are unavailable. +The shared event-driven agent-status tracker (`AgentTracker`, `.worklog/agent-panes.json`) is still used for record-keeping (hover tooltip, dispatch bookkeeping), but it no longer drives the row icon (a pane absent from `herdr agent list` used to be pruned and lose its icon permanently, and a `done` status used to clear the icon while the pane stayed open — both violated the pane-existence rule). -**Hover tooltip (WL-0MT9XRZDK006GMUH)** — hovering the mouse over a row with an agent icon shows a tooltip in the footer area above the metadata panel, replacing the footer hints while visible: +**Event-driven refresh (herdr ≥ 0.7.5, WL-0MSHB7DHO004RHBJ)** — `pane_agent_status_changed`, `pane_agent_detected`, `pane_closed` and `pane_exited` events re-run the pane-title lookup immediately (no 30 s wait), so opening or closing a work-item pane updates the green circle promptly. + +**Hover tooltip (WL-0MT9XRZDK006GMUH)** — hovering the mouse over a row with a recorded work-item pane association shows a tooltip in the footer area above the metadata panel, replacing the footer hints while visible: | Field | Source | |-------|--------| @@ -235,7 +237,7 @@ The icon occupies a fixed-width slot, so the remaining icons and the item-ID col | Effort | `workItem.effort` | | Start Time | `AgentPaneEntry.recordedAt` (ISO timestamp, rendered `DD/MM/YY HH:MM`) | -Pressing `Esc` dismisses the tooltip until the mouse leaves and re-enters the list rows. Rows without an attached agent never show the tooltip. +Pressing `Esc` dismisses the tooltip until the mouse leaves and re-enters the list rows. Rows without an attached agent never show the tooltip. The tooltip is driven by **button-less** mouse motion only (`button & 3 = 3`); left-button-held motion is consumed by drag-to-scroll (WL-0MSI72S0Y0060BRK), so a drag never pops a tooltip and the tooltip never interferes with dragging. ### From the command line ```bash @@ -263,7 +265,7 @@ The plugin respects the following environment variables: Settings are persisted in `~/.config/herdr/worklog-plugin.json`. Key settings include: -- `autoRefresh` — Enable periodic auto-refresh of the work item list (default: `true`). Refresh ticks are **DB-change gated**: when the worklog DB has not changed since the last cycle, an auto-refresh tick spawns zero `wl` processes (no `wl next`, no `wl list`, no children fetches, no action-count fetch). The gate uses the wl CLI's monotonic per-worklog-dir state counter (`~/.cache/wl/state/.json`) as a cheap cross-process signal; a read error fails **open** (the cycle runs). Manual `r` always runs (WL-0MSJ1OLTL009N4IQ). +- `autoRefresh` — Enable periodic auto-refresh of the work item list (default: `true`). Refresh ticks are **DB-change gated**: when the worklog DB has not changed since the last cycle, an auto-refresh tick spawns zero `wl` processes (no `wl next`, no `wl list`, no children fetches, no action-count fetch). The gate uses the wl CLI's monotonic per-worklog-dir state counter (`~/.cache/wl/state/.json`) as a cheap cross-process signal; a read error fails **open** (the cycle runs). Manual `Ctrl+r` always runs (WL-0MSJ1OLTL009N4IQ). - `refreshIntervalMs` — Interval in ms between auto-refreshes (default: `30000`). Refresh cycles are single-flight with a **trailing refresh**: a tick/request that fires while a refresh is still awaiting its `wl` calls sets a pending flag and coalesces to a single trailing cycle that runs after the current cycle completes — without unbounded queuing (at-most-one trailing per in-flight cycle). This guarantees that a background command's `onExit` refresh is never silently dropped when it overlaps another fetch, so Herb-selection style chords such as `u p c` always land after one dispatch (WL-0MSBVYBMD004007C, WL-0MTIB7JAN004MQ8N). Each refresh is **atomic with respect to expanded state**: children of expanded parents are re-fetched in parallel with the top-level list and applied in one synchronous swap, so an expanded hierarchy never momentarily collapses mid-refresh (WL-0MSBVBNGH002RDP5). **Bounded wl spawns** — every `wl` spawn in the refresh/sync fetch path (`wl next`, `wl list`, `wl show`, `wl sync`, …) runs with a bounded `DEFAULT_WL_TIMEOUT_MS` (60s, matching the auto-sync spawn safety timeout) unless the caller supplies an override (e.g. the claim path's 3s `CLAIM_TIMEOUT_MS`). A hung `wl` child is killed after the bound, the refresh fails with a `Refresh failed` toast, the single-flight guard is cleared in `finally`, and the next tick retries — a hung spawn never wedges `refreshInFlight` permanently (WL-0MSJNJXX2001NMHS). The `checkWlAvailable` startup probe (`wl --version`) is bounded the same way so a hung probe cannot block TUI startup. @@ -278,7 +280,7 @@ Settings are persisted in `~/.config/herdr/worklog-plugin.json`. Key settings in **Self-heals the work queue.** Work items claimed as `in_progress` are correctly excluded from downtime dispatch (single-flight — an active claim -owns them), but a claim with no matching tab (tab closed, session died, +owns them), but a claim with no matching pane (pane closed, session died, claim abandoned) lingers at the top of the queue and is never re-selected. The hydrator detects those claims and releases them: @@ -286,41 +288,36 @@ The hydrator detects those claims and releases them: watchdog `HYDRATOR_RUN_TIMEOUT_MS`); the check also runs **immediately** when the worklog tab regains focus (same runner as the tick). - **Signal** — fetch `wl list --status in-progress`, fetch - `herdr tab list --workspace `, and match each item against a tab in - the **current workspace** whose label **exactly equals** the work-item ID; a - label that merely contains the ID does not count. No matching tab → the - claim is stale. + `herdr pane list --workspace `, and match each item against a pane in + the **current workspace** whose **title contains** the work-item ID. No + matching pane → the claim is stale. - **Pane-title ID survival (F0)** — matching is keyed on the work-item ID, so the ID must survive wherever it appears. Every pane spawned for an item gets a title ending in ` - WL-` (manual, shell, and downtime builders in `pane-title.ts`), and the 60-char truncation always preserves that trailing suffix (`truncatePaneTitlePreservingSuffix` — the pane-title amendment - WL-0MTF5WNRB009RHRU), so the ID stays visible and attributable in every - pane title. The hydrator itself matches on **exact tab labels** — item-ID - tabs are labelled with the exact ID (never truncated) by the shared - dispatcher-anchor builders — so a long pane title can never break the - match; the suffix-preservation rule is what keeps the ID as the - association key an operator scans for. + WL-0MTF5WNRB009RHRU), so the ID stays visible in every pane title and the + pane-title match is reliable. - **Release semantics** — `in_review` items → `completed`; items with an active outbound dependency blocker → `blocked`; otherwise → `open` at the claimed stage (folded-in no-activity/claim-age timeout, WL-0MTTSWE0G008M1VA; - a no-tab claim is released regardless of activity age). Stage is repaired + a no-pane claim is released regardless of activity age). Stage is repaired to a status-compatible value when needed. - **Safety** — the cycle is fail-open: an unavailable/unparseable - `wl`/`herdr` call aborts WITHOUT demoting; an item with a live tab is never - touched; a hidden tab spawns zero processes. -- **Selection-list marker** (WL-0MV1BWETT005GSUJ) — the same exact-label tab - set drives the selection list's in-progress marker: an item with an - exact-match tab is rendered `in-progress` (marker + Group N) even when its - worklog status is still `open`, and loses the marker when the tab closes. + `wl`/`herdr` call aborts WITHOUT demoting; an item with a matching pane is + never touched; a hidden tab spawns zero processes. +- **Selection-list marker** (WL-0MV1BWETT005GSUJ) — the same pane-title lookup + drives the selection list's in-progress marker: an item with a matching pane + is rendered `in-progress` (marker + Group N) even when its worklog status is + still `open`, and loses the marker when the pane closes. Terminal `in_review`/`completed` items keep their own review marker. Because - opening/closing a work-item tab does not touch the worklog DB (so the + opening/closing a work-item pane does not touch the worklog DB (so the DB-change-gated refresh tick cannot update the marker for it), the marker is applied on the initial load and every full refresh, and re-applied by the - dedicated `active-tab-marker` scheduler task on the hydrator cadence + dedicated `active-pane-marker` scheduler task on the hydrator cadence (`HYDRATOR_INTERVAL_MS`) and on focus-resume. That task spawns only - `herdr tab list` (never `wl`), preserving the zero-`wl`-when-idle guarantee; - the helper is idempotent, so a closed tab clears the marker. + `herdr pane list` (never `wl`), preserving the zero-`wl`-when-idle guarantee; + the helper is idempotent, so a closed pane clears the marker. See [`docs/dev/herdr-hydrator.md`](../../docs/dev/herdr-hydrator.md) for the design and test map. @@ -1401,6 +1398,31 @@ process churn and memory pressure (WL-0MSB1N0HB0007N6N). tab get` exec) and triggers an instant refresh on the hidden → visible transition. The 2s resume-poll becomes a fallback that runs only while the event path is unavailable. +- **One request per connection (WL-0MVAS988R008PNH6)** — herdr's API server + handles exactly **one** JSON-RPC request per connection; a streaming + `events.subscribe` response occupies the connection, and any second frame + on it (another subscribe, a `ping`, even a `pane.list`) makes the server + **reset** the connection, which shows up in `herdr-server.log` as + `events.subscribe … outcome="stream_closed"`. The subscriber therefore + keeps each subscription connection write-once: when the tracked pane set + changes (a pane is seen/closed), it **re-establishes a fresh connection** + carrying the complete subscription set in that connection's single initial + request, instead of resending `events.subscribe` on the live socket. + Batched pane changes and changes landing mid-reconnect coalesce into one + rebuild, and reconnect backoff resets on every successful subscribe — so a + pane-set change no longer collapses into the previous ~10 s reconnect loop. + An idle subscription stays open indefinitely and expects **no** keepalive + (verified against herdr 0.9.5: a 40 s+ idle probe never closed); sending a + keepalive on the stream is actively harmful, so the subscriber sends + nothing after the initial request. +- **Fast pane-focus refresh on a visible tab (WL-0MVAS988R008PNH6 AC2)** — + when the worklog pane regains keyboard focus inside an already-visible tab, + the server pushes `pane_focused` for the current pane; the handler + (`worklist.ts` `onPaneFocused`) applies the visibility value to the gate, + clears `[paused — hidden]`, and calls `doRefresh(true)` + `runHydrator()` + immediately — no 30 s wait. If the stream drops, the subscriber flips back + to the resume-poll fallback and re-establishes the event path on + reconnect (`onReconnected`), so focus refresh keeps working. - **Fail-open** — when visibility cannot be determined (no `HERDR_TAB_ID` env, herdr CLI missing/erroring, unparseable output, or the event subscription unavailable) the pane is treated as visible and polling @@ -1731,6 +1753,48 @@ bottom indicator row reservation trims the window so the list fully fits, the `▼ more` marker is omitted — the list then shows every remaining item without an indicator. +### Help screen + +Pressing `?` in the selection list opens a full-pane **help screen** +(WL-0MTV97GV20088CS7). It is display-only — it never dispatches commands, +spawns panes, or mutates the worklog — and is dismissed with `?`, `Esc` or +`q`, returning to the list with the selection, filters and navigation stack +unchanged. + +The overlay has two sections, separated by blank lines, with colour-coded +headings (title gold, section headings blue, command/category sub-headings +cyan): + +- **Commands** — every shortcut registered for the list view listed in a + single column, each row showing the chord key(s), label and the FULL + (untruncated, word-wrapped) description. Commands are grouped by chord + leader under a heading per family (`d — Downtime`, `c — Create`, `u — + Update`, `f — Filter`, …) so the sections read logically, with a blank + line separating each family. The list is + derived live from the shortcut registry, so future shortcut additions + appear automatically under their leader heading. +- **Icons** — a legend explaining every glyph rendered in a work-item row, + grouped by category under a heading (`Status`, `Stage`, `Priority`, + `Risk`, `Effort`, `Audit`, `Producer review`, `Agent`, `Coverage`) with + each group's icons listed on a single line under its own heading. The + category name prefix is + removed from each meaning (the heading already names the group), and the + legend is derived from the shared icon source of truth so new row glyphs + cannot silently miss it. + +The footer advertises the screen as `?: Help`. When content exceeds the pane +height the overlay is **scrollable** (2026-10-16 audit): the content window +moves with `j`/`↓` (down one line), `k`/`↑` (up one line), PageUp/PageDown +(page), `g`/`G` (top/bottom), and the footer shows a live percentage +(`[↑↓/j:k scroll g/G top/bot]`). The title and the dismiss hint line +(`? / Esc / q`) stay pinned, and scrolling mutates no list state. The `d` +downtime toggle and `Alt+m` mouse-tracking bindings +remain fully functional; only their inline footer advertisements were removed +in favour of the help screen. + +The help screen honours the `showHelpText` setting: when help text is +disabled, the `?: Help` footer hint and the `?` overlay are not offered. + ## Metadata panel The list view shows the **selected** row's metadata in a panel below the @@ -1755,8 +1819,9 @@ first rows), so a long list never scrolls off the top. helpers as the list rows, so the two sections can never diverge: e.g. `🔄 in_progress`, `⭐ high`, `📥 intake_complete`. The Stage row mirrors the list's audit-aware `in_review` icon (✅/❌/❓ when the audit is fresh, - ⏳ when stale-but-passed, 🔍 otherwise); the Type row shows the epic icon - (⊙) for `epic` items only (other types stay text-only); and the Audit and + ⏳ when stale-but-passed, 🔍 otherwise); the Type row renders the plain + issue-type label (`epic`, `feature`, …) with no icon — the `⊙` epic glyph + was removed (WL-0MVAQ873D007LNWD); and the Audit and Reviewed rows pair their icons with text labels (`✅ ready to close`, `❌ needs review`, …). When icons are disabled (`showIcons: false`) every metadata value falls back to **plain text** — no emoji and no bracketed @@ -1768,6 +1833,33 @@ first rows), so a long list never scrolls off the top. the row is omitted entirely when there are no `.md` Key Files. The row is **display-only** — opening a document happens from the detail view's Related Docs table of contents (see [Markdown viewer](#markdown-viewer)). +- When the item has at least one **active dependency blocker** (an outbound + `depends-on` edge whose target is neither `completed`/`deleted` nor at + stage `in_review`/`done`), the panel appends one **`Blocked By`** row per + blocker — `Blocked By WL-xxxxxxxx — [<status-icon> <status>]` — + at the end of the metadata section (before the description preview). The + blocker's `status` from the `wl dep list` edge is appended as an icon + + text suffix when present (consistent with the Status row convention, + WL-0MSGIXHHI009KFW9); a status-less edge renders the ID + title alone. + The rows appear for ANY selected item with active blockers, regardless of + its stored `status` (a dependency-blocked `open` item is covered), and + are omitted entirely when there are none, so unblocked items render + byte-for-byte as before. The lookup runs only for the **selected** item + (`wl dep list`, with a `wl show` stage enrichment per still-possible + blocker), is cached per item ID, and is **reused across list refreshes** + (WL-0MV8Q5X8U004KKAP): the cache is invalidated only when the selected + item's own payload changes (`status`/`stage`/`updatedAt` — a dependency + add/remove reconciles the from-item's status and bumps `updatedAt`), so + repeated refresh triggers (DB-change watch, auto-sync cycles, `wl next` + re-sorts) never drop the resolved rows and the `Blocked By` line cannot + flicker on/off; the in-flight lookup is single-flight (one `wl dep list` + spawn per item until its edges change). While a re-resolve is in flight + the last known rows are still rendered (never a blank frame). The render + path never blocks on it — a failed or unparseable lookup simply yields no + rows (fail-open). The detail view renders the SAME rows via the same + cache (WL-0MVBMLY2Z0032CYR, see [Metadata panel](#metadata-panel)); the + hover tooltip deliberately omits them (it is a compact pane-status + summary and renders no async lookup). - For actively-worked items (`status = in-progress`), the panel additionally shows **`Last command:`** — the most recent command the plugin dispatched against that item (`none yet` until the first @@ -2062,7 +2154,7 @@ packages/herdr/ - Everything else is written to stdout with a `CMD:` prefix for the calling framework (Herdr) to execute. - **Selection-list dispatch keeps focus** — Every pane spawned from the worklist selection list (pi agent panes via `send-to-pi.sh`, and command-output panes via `run-in-pane.sh`) opens **without moving focus** by default (WL-0MSHIA53D009DJOT): the dispatch passes `--no-focus` to both launchers, so the final zoom/focus step is skipped and the selection list keeps the keyboard focus. The user can read dispatch feedback via toasts and inspect the opened pane with herdr pane navigation (`prefix+o`, `prefix+x` to close). The `P n` and `r i` shortcuts opt in to **focus the new pane immediately** (see **Focused shortcuts via `focus: true`** below). Out of scope and unchanged: the downtime worker (already `--no-focus`), the `open-pi-agent` unbound plugin action, and `open.sh`/`toggle.sh`. - **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 (or, when the command carries a work-item ID, the item-ID tab's anchor pane — see [Interactive pane placement](#interactive-pane-placement-work-item-id-tabs)), creates a new pane, runs `pi` with the command as the initial prompt, and renames the pane to "Pi Agent". The dispatch passes `--no-focus` (selection-list dispatch keeps focus, see above) unless the shortcut opts in to **focus the new pane** (`focus: true`, see **Focused shortcuts via `focus: true`** above), in which case `--focus` is passed and the new pane is zoomed. Agent commands are routed before any prefix handling, so they are unaffected by `!!`/`!` processing. -- **No-pane dispatch via `open_pane: false`** — A shortcut entry may carry an optional `open_pane: false` flag (WL-0MSJLD1I70045ZUL) to run its command **in the background without opening a pane**: shell (`!!`/`!`) commands execute via detached `bash -c` and agent commands run headless (`pi -p --mode json`, honoring the entry's `model`), with stdout/stderr captured to a per-run log file under `<tmpdir>/herdr-background-logs/` (the path is written to stderr so it can be located for inspection). No pane is created, so the work-item ↔ pane association is skipped for agent commands. The bundled quiet state-change shortcuts use it: `a-y` audit approve, `a-r` audit reject, `u-p-*` priority updates, and `x-c`/`x-d` close/delete — they complete silently and the worklist refresh shows the updated state. If a background command exits non-zero, a **failure toast** is shown with the command, its exit code (or termination signal) and a short excerpt of its output (WL-0MUEBQLRD00288VV); the full output remains in the per-run log file. Shortcuts without the flag (all other bundled entries) open a pane exactly as today. +- **No-pane dispatch via `open_pane: false`** — A shortcut entry may carry an optional `open_pane: false` flag (WL-0MSJLD1I70045ZUL) to run its command **in the background without opening a pane**: shell (`!!`/`!`) commands execute via detached `bash -c` and agent commands run headless (`pi -p --mode json`, honoring the entry's `model`), with stdout/stderr captured to a per-run log file under `<tmpdir>/herdr-background-logs/` (the path is written to stderr so it can be located for inspection). No pane is created, so the work-item ↔ pane association is skipped for agent commands. The bundled quiet state-change shortcuts use it: `a-y` audit approve, `a-r` audit reject (whose `<reason>` placeholder is collected by the form dialog before the background dispatch — WL-0MV6J4HQ0009OJPQ), `c-q` quick create (whose `<title>`/`<description>`/`<priority>` placeholders are collected by the form dialog first, then `wl create` runs in the background and the list refreshes on completion — WL-0MV6J5JXL002D8C4), `u-p-*` priority updates, and `x-c`/`x-d` close/delete — they complete silently and the worklist refresh shows the updated state. If a background command exits non-zero, a **failure toast** is shown with the command, its exit code (or termination signal) and a short excerpt of its output (WL-0MUEBQLRD00288VV); the full output remains in the per-run log file. Shortcuts without the flag (all other bundled entries) open a pane exactly as today. - **Focused shortcuts via `focus: true`** — A shortcut entry may carry an optional `focus: true` flag (WL-0MT70LC6B009TL3Q) to **focus the newly opened pane** immediately after it spawns. When absent or `false` (the default) the selection list keeps focus (the behaviour above). The flag is orthogonal to `open_pane: false` (no pane → focus is moot) and invalid values are logged and treated as absent (no-focus). Focus is applied to the pane's **tab** as well as the pane itself (`run-in-pane.sh` resolves the new pane's tab via `herdr pane get` and runs `herdr tab focus` before the pane zoom, WL-0MURJ0LFH002O95I): a focused ID-carrying dispatch is anchored to the work-item-ID tab (see [Interactive pane placement](#interactive-pane-placement-work-item-id-tabs)), and `pane zoom` alone cannot switch tabs — without the tab focus the new pane would open in a background tab and the selection list would keep focus. The bundled entries with the flag are `P n` (new Pi session — blank `/prompt:`) and `r i` (producer interview — `wl interview`): `P n` puts the cursor straight in the new Pi pane for immediate typing, and `r i` puts the cursor straight in the interview pane so the producer can answer outstanding questions without an extra `prefix+o` step. Any future shortcut can opt in with the same field without a schema change. - **Model lease release on pane close** — Pi agent panes launched by `send-to-pi.sh` or `open-pi-agent.sh` run pi via `shared/run-pi-agent.sh`, which gives the session a deterministic id (`pi --session-id herdr-<timestamp>-<pid>-<rand>`) and registers EXIT/TERM/HUP/INT traps. When the pi session ends — normal exit or pane close (`prefix+x`) — the wrapper runs `shared/release-lease-on-exit.mjs`, which posts to the Local Proxy's `POST {baseUrl}/leases/release` using the **same shared implementation** as the Pi extension (`@worklog/shared/lease-release`), so the proxy's dispatch lease is reclaimed promptly instead of lingering until timeout. The release is strictly best-effort: failures (unreachable proxy, missing `~/.pi/agent/models.json`, unconfigured provider) are silently discarded, a 5s request timeout bounds the pane-close path, and the wrapper always propagates pi's exit status (WL-0MSGI7UIH008USVB). - **Worklog tab naming** — `herdr plugin pane open` creates tabs with generated numeric labels. The `open-worklog-tab` action wraps the same pane-open command and renames the created tab to "Worklog" via `herdr tab rename` (socket API, not session-state editing), so the worklog pane is instantly recognisable in the tab row. Each press still opens a new tab; only the label changes. @@ -2077,7 +2169,7 @@ packages/herdr/ - The merge is **deterministic and deduplicated** (dedup key = `view` + `chord`); within the local file, later entries win for the same `view`+`chord`. - Local entries are validated with the same rules as bundled entries: an invalid entry is **logged and skipped**, and a missing/malformed local file (bad JSON, non-array) falls back to bundled-only with an error logged — a broken local file never crashes the plugin. - When no worklog root is resolved (uninitialized project) or no local file exists, the registry is exactly the bundled defaults and all dispatch behaviour is unchanged. -- **Metadata panel** — The selected item's metadata is shown in a panel below the list (`formatMetadataPanel` in `worklist.ts`). The list takes up to the full pane height and the panel fills the remaining space (minimum 3 rows), computed via `computeDynamicLayout` (WL-0MSQ44MDX008U69J); the panel scrolls independently (`m`/`M`) with a scroll indicator. Row building is shared with the detail view via `buildMetaRows`, so the two views never drift apart (see WL-0MSAYNVBY006LM9X). +- **Metadata panel** — The selected item's metadata is shown in a panel below the list (`formatMetadataPanel` in `worklist.ts`). The list takes up to the full pane height and the panel fills the remaining space (minimum 3 rows), computed via `computeDynamicLayout` (WL-0MSQ44MDX008U69J); the panel scrolls independently (`m`/`M`) with a scroll indicator. Row building is shared with the detail view via `buildMetaRows`, so the two views never drift apart (see WL-0MSAYNVBY006LM9X). **`Blocked By` rows (WL-0MV8EZRDJ001TDA8, flicker fix WL-0MV8Q5X8U004KKAP; detail-view parity and blocker status WL-0MVBMLY2Z0032CYR):** the selected item's active blockers are resolved via `fetchBlockersForItem` (`fetcher.ts`: `wl dep list <id> --json` + `wl show` stage enrichment, filtered with the shared `isActiveBlocker` predicate and parsed with `parseDepListBlockersOutput`), cached per item ID in `runWorklistTui` and **kept across refreshes** — the cache is invalidated per item only when its payload signature (`status`/`stage`/`updatedAt`) changes, so unrelated refresh cycles reuse the resolved rows instead of re-entering the unresolved frame (this was the endless `Blocked By` flicker: `blockerCache.clear()` ran on every refresh); while a re-resolve is in flight the last known value is still rendered (single-flight, no duplicate spawns). Each `wl dep list` edge's `status` is passed through on the `BlockerRef` and rendered as an icon + text suffix on the row (a status-less edge renders the ID + title alone). `formatMetadataPanel` appends one `Blocked By <id> — <title> [<status>]` row per blocker at the end of the metadata rows, and `formatDetailContent` renders the SAME rows in the detail-view metadata table (the detail view resolves blockers through the same single-flight + signature-keyed cache, so the two surfaces never spawn duplicate lookups or flicker; a failed lookup yields no rows — fail-open). The detail-view scroll clamp (`detailScrollDown`) counts the rows so they are reachable when the view scrolls. The `m`/`M` scroll clamp uses the unpadded content height (`metadataPanelContentHeight`) so the offset reaches the true bottom of the panel. - **Command log** — Dispatched commands targeting a work item are recorded in `command-log.ts` before execution; the panel surfaces the last command for `in_progress` items. Recording is fire-and-forget (never breaks dispatch) and the log file is written atomically (see WL-0MSAYNVBY006LM9X). ## Code Freeze @@ -2219,12 +2311,27 @@ Semantics: - Any non-boolean value is logged as invalid and treated as omitted (a pane opens) — a bad value never hides or breaks a shortcut. - Bundled quiet state-change shortcuts carry `"open_pane": false`: `a-y` - (audit approve), `a-r` (audit reject), `u-p-l/m/h/c` (priority updates), - and `x-c`/`x-d` (close/delete). They run to completion in the background - and the worklist refresh shows the updated state. All other bundled - shortcuts omit the field and keep today's pane-opening behaviour. -- Form-edited commands (unknown `<identifier>` placeholders) always open a - pane — the flag applies to direct shortcut dispatch only. + (audit approve), `a-r` (audit reject), `c-q` (quick create), + `u-p-l/m/h/c` (priority updates), and `x-c`/`x-d` (close/delete). They run + to completion in the background and the worklist refresh shows the updated + state — including `a-r` and `c-q`, whose placeholders + (`<reason>`/`<title>`/`<description>`/`<priority>`) are collected first: + after the placeholder form is submitted the resolved command is dispatched + with the inherited `open_pane: false`, so it runs in the background exactly + like the placeholder-free shortcuts (WL-0MV6J4HQ0009OJPQ, + WL-0MV6J5JXL002D8C4). `c-q`'s background `wl create` refreshes the list on + completion so the newly created item appears without a manual refresh + (WL-0MT1KB70U0012X6T). All other bundled shortcuts omit the field and keep + today's pane-opening behaviour. +- Form-triggered commands (shortcuts whose command contains an unknown + `<identifier>` placeholder, e.g. `a-r`'s `<reason>`) honour the flag + identically to placeholder-free shortcuts (WL-0MV6J4HQ0009OJPQ): once the + placeholder form is submitted, the form-dialog dispatch forwards + `openPane` (plus `onRefresh` for background dispatches and `focus`) using + the same arity rules as direct dispatch. A form-triggered shortcut that + omits `open_pane` opens a pane exactly as before, and a background + form-triggered dispatch still raises the failure toast on a non-zero exit + and refreshes the list when the command completes. ### Shortcut filtering during a freeze @@ -2243,16 +2350,42 @@ This plugin only **reads** the marker; writing/clearing it is the ship release p ## Development ```bash +# Run the plugin directly (outside Herdr) +# src/start.ts is the resilient pane entry (the manifest command); it wraps +# src/index.ts so a stale @worklog/shared fails with an actionable message. +npx tsx packages/herdr/src/start.ts + # Run tests -npx vitest run tests/herdr/ +npx vitest run tests/herdr/ tests/unit/start-launcher.test.ts -# Run the plugin directly (outside Herdr) -npx tsx packages/herdr/src/index.ts +# Build TypeScript (compiles packages/shared first, then the plugin) +cd packages/herdr && npm run build +``` -# Build TypeScript -cd packages/herdr && npx tsc +## Troubleshooting + +### The Worklog tab closes immediately on open + +The pane intentionally never dies silently. The entry launcher +(`src/start.ts`) prints an actionable diagnostic and — when it is attached +to a real terminal — stays open until you press Enter. + +If it reports **"The plugin loaded a STALE copy of the shared Worklog code"** +(or an ESM error like `does not provide an export named ...`), the compiled +`packages/shared/dist` predates the current source. Rebuild from the +repository root and reopen the tab: + +```bash +npm run build ``` +`npm run build` rebuilds `packages/shared/dist` and re-links +`packages/herdr/node_modules/@worklog/shared` to the live workspace package +(via `scripts/install-herdr.sh` step 0b / `link-shared.mjs`). If you only +want to refresh the plugin dependency without a full build, run +`npm install --prefix packages/herdr` (its `postinstall` re-links the +symlink). + ## License MIT — see [LICENSE](../../LICENSE) for details. diff --git a/packages/herdr/herdr-plugin.toml b/packages/herdr/herdr-plugin.toml index f78a9a9c2..96ca6b137 100644 --- a/packages/herdr/herdr-plugin.toml +++ b/packages/herdr/herdr-plugin.toml @@ -15,11 +15,16 @@ platforms = ["linux", "macos", "windows"] command = ["npm", "run", "build"] # Pane: work item selection list +# +# Entry is src/start.ts — the resilient launcher that wraps index.ts in a +# dynamic import so a stale @worklog/shared (missing an ESM export) fails +# with an actionable diagnostic instead of a silent pane exit +# (WL-0MVAY631M004RZ3Q). [[panes]] id = "worklist" title = "Work Items" placement = "tab" -command = ["npx", "tsx", "src/index.ts"] +command = ["npx", "tsx", "src/start.ts"] # Main action: toggle the worklist pane [[actions]] diff --git a/packages/herdr/package.json b/packages/herdr/package.json index bff43ed5d..c1ea01ad3 100644 --- a/packages/herdr/package.json +++ b/packages/herdr/package.json @@ -8,7 +8,9 @@ "worklog-pane-close": "dist/pane-close-cli.js" }, "scripts": { - "build": "tsc && cp src/shortcuts.json dist/shortcuts.json && chmod +x dist/pane-close-cli.js", + "build": "npm run build:shared && tsc && cp src/shortcuts.json dist/shortcuts.json && chmod +x dist/pane-close-cli.js", + "build:shared": "tsc -p ../../packages/shared/tsconfig.json", + "postinstall": "node ./scripts/link-shared.mjs", "dev": "tsx src/index.ts" }, "dependencies": { diff --git a/packages/herdr/scripts/link-shared.mjs b/packages/herdr/scripts/link-shared.mjs new file mode 100644 index 000000000..4d799e232 --- /dev/null +++ b/packages/herdr/scripts/link-shared.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/** + * packages/herdr/scripts/link-shared.mjs — make the plugin's @worklog/shared + * resolution live instead of a stale packaged copy (WL-0MVAY631M004RZ3Q). + * + * npm installs a `file:` dependency as a PACKAGED COPY inside + * `packages/herdr/node_modules/@worklog/shared`. That copy silently goes + * stale whenever `packages/shared` changes: at pane startup the plugin + * imports a named export that is missing from the old copy and the ESM + * linker throws (`SyntaxError: ... does not provide an export named ...`), + * so herdr closes the pane with no explanation. + * + * This script replaces any such copy with a relative SYMLINK to the live + * workspace package (`packages/shared`), matching how npm workspaces wire + * intra-repo dependencies. The plugin then always reads the current + * `packages/shared` (whose `dist/` is refreshed by `npm run build`), so a + * build can never leave a stale copy behind. + * + * Usage: + * node link-shared.mjs [packages/herdr dir] + * + * The directory defaults to this script's parent (the herdr package) and + * may be passed explicitly so the installer can target a checkout whose + * script location differs (e.g. a linked git worktree). The script is + * idempotent and fail-open: if linking is impossible it warns (exit 0) so + * builds never hard-break on a tooling edge case. + * + * The function `linkSharedPackage()` is exported for behavioural tests. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** `node_modules/@worklog/shared` inside a herdr package. */ +export function resolveSharedLinkTarget(herdrDir) { + return path.join(herdrDir, 'node_modules', '@worklog', 'shared'); +} + +/** + * Ensure `packages/herdr/node_modules/@worklog/shared` resolves to the live + * workspace `packages/shared` (a relative symlink on POSIX). + * + * @param {string} herdrDir - the herdr plugin package directory + * @returns {'linked'|'unchanged'} what the function did + * @throws when the workspace shared package does not exist. + */ +export function linkSharedPackage(herdrDir) { + const linkPath = resolveSharedLinkTarget(herdrDir); + const linkParent = path.dirname(linkPath); + const workspaceShared = path.resolve(herdrDir, '..', 'shared'); // packages/shared + if (!fs.existsSync(workspaceShared)) { + throw new Error( + `workspace shared package not found at ${workspaceShared} — cannot link @worklog/shared`, + ); + } + fs.mkdirSync(linkParent, { recursive: true }); + + // On Windows use an absolute junction target (junctions need no admin); + // elsewhere a relative symlink keeps the link meaningful when the repo + // is moved as a whole. + const target = process.platform === 'win32' ? workspaceShared : path.join('..', '..', '..', 'shared'); + + // Already linked correctly? Nothing to do. + try { + const stat = fs.lstatSync(linkPath); + if (stat.isSymbolicLink() && fs.readlinkSync(linkPath) === target) { + return 'unchanged'; + } + } catch { + // Not present (or not a link) — link it below. + } + + // Remove whatever npm left behind (a packaged copy, a broken link, ...) + // and link the live workspace package. + fs.rmSync(linkPath, { recursive: true, force: true }); + fs.symlinkSync(target, linkPath, process.platform === 'win32' ? 'junction' : 'dir'); + return 'linked'; +} + +function main() { + const scriptDir = path.dirname(fileURLToPath(import.meta.url)); + const herdrDir = process.argv[2] + ? path.resolve(process.argv[2]) + : path.resolve(scriptDir, '..'); + const result = linkSharedPackage(herdrDir); + const linkPath = resolveSharedLinkTarget(herdrDir); + const label = result === 'unchanged' ? 'already linked' : 'linked'; + process.stdout.write( + `[worklog-plugin] @worklog/shared ${label} to the live workspace package (${path.relative(process.cwd(), linkPath)})\n`, + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main(); + } catch (err) { + process.stderr.write( + `[worklog-plugin] WARNING: could not link @worklog/shared to the workspace package: ${err.message}\n`, + ); + process.exitCode = 0; + } +} diff --git a/packages/herdr/scripts/open.sh b/packages/herdr/scripts/open.sh index 4a41136cc..8568fb54b 100755 --- a/packages/herdr/scripts/open.sh +++ b/packages/herdr/scripts/open.sh @@ -122,7 +122,7 @@ fi echo "[open-worklist] resolved cwd='$cwd'" >&2 # Pass the resolved CWD as an environment variable instead of using -# --cwd. The pane command ("npx tsx src/index.ts") uses a RELATIVE +# --cwd. The pane command ("npx tsx src/start.ts") uses a RELATIVE # path, so changing CWD via --cwd would break script resolution. # The plugin reads HERDR_RESOLVED_CWD to find the correct .worklog/. exec "$herdr_bin" plugin pane open \ diff --git a/packages/herdr/scripts/toggle.sh b/packages/herdr/scripts/toggle.sh index 7f6a23d8e..70ff03c1c 100755 --- a/packages/herdr/scripts/toggle.sh +++ b/packages/herdr/scripts/toggle.sh @@ -128,7 +128,7 @@ try: for p in (panes if isinstance(panes, list) else []): label = p.get('label', '') cmd = ' '.join(p.get('command', []) or []) - if label == 'Work Items' or 'worklog-selection-list' in cmd or 'packages/herdr/src/index.ts' in cmd: + if label == 'Work Items' or 'worklog-selection-list' in cmd or 'packages/herdr/src/index.ts' in cmd or 'packages/herdr/src/start.ts' in cmd: print(p.get('pane_id', '')) break except: diff --git a/packages/herdr/shared/herdr-agent-state-protocol.md b/packages/herdr/shared/herdr-agent-state-protocol.md index 57d8592fd..fccb4c49d 100644 --- a/packages/herdr/shared/herdr-agent-state-protocol.md +++ b/packages/herdr/shared/herdr-agent-state-protocol.md @@ -317,10 +317,29 @@ Event types include `pane_agent_status_changed`, `pane_focused`, ### Subscriber lifecycle -- The subscriber (`packages/herdr/src/events.ts`) connects at plugin start, - reconnects with exponential backoff (500ms base, capped at 10s) on socket - errors, and closes/unsubscribes on TUI exit (no leaked connections). -- Per-pane subscriptions are synced from the shared +The subscriber (`packages/herdr/src/events.ts`) connects at plugin start, +reconnects with exponential backoff (500 ms base, capped at 10 s) on socket +errors, and closes/unsubscribes on TUI exit (no leaked connections). + +**One request per connection (WL-0MVAS988R008PNH6).** The herdr API server +handles exactly **one** JSON-RPC request per connection. An `events.subscribe` +stream therefore **occupies** its connection: any second request frame on that +socket — another `events.subscribe`, a `ping`, even `pane.list` — makes the +server reset the connection, logged as `events.subscribe … +outcome="stream_closed"`. Keepalives are neither required nor accepted on the +stream: an idle subscription stays open indefinitely (verified against herdr +0.9.5 — a 40 s+ idle probe never closed), so the client sends nothing after +the single initial request. + +Consequently a pane-set change is applied by **re-establishing a fresh +connection** whose single initial request carries the complete subscription +set (`addPaneSubscription(s)` / `removePaneSubscription(s)`); the subscriber +never resends `events.subscribe` on the live socket. Batched panes and +changes landing during an in-flight reconnect coalesce into one rebuild, and +backoff resets on every successful subscribe — so routine stream closes +reconnect promptly at the base delay instead of ratcheting to the 10 s cap. + +- Per-pane subscriptions are synced (batched) from the shared `.worklog/agent-panes.json` pane set and updated on `pane_agent_detected` (add) and `pane_closed`/`pane_exited` (drop). - **Session scope (limitation):** events are scoped to the herdr **session** @@ -329,7 +348,9 @@ Event types include `pane_agent_status_changed`, `pane_focused`, split across **different** sessions does not share events. - **Fail-open:** an unreachable socket / older herdr (pre-0.7.5) / transient reconnect keeps today's polling (`herdr agent list`, `herdr tab get`); - socket errors never crash the TUI. + socket errors never crash the TUI. While the stream is down the worklist + re-enables the tab-level `resume-poll` fallback and restores the event path + on reconnect. ## Related diff --git a/packages/herdr/src/__fixtures__/stale-entry.ts b/packages/herdr/src/__fixtures__/stale-entry.ts new file mode 100644 index 000000000..e54143cf8 --- /dev/null +++ b/packages/herdr/src/__fixtures__/stale-entry.ts @@ -0,0 +1,18 @@ +// packages/herdr/src/__fixtures__/stale-entry.ts +// +// Fixture entry point that imports a named export which the (stale) shared +// module above does not provide. Loading this module fails with exactly the +// ESM SyntaxError the real plugin hit when its vendored @worklog/shared was +// stale: +// +// SyntaxError: The requested module '...' does not provide an export +// named 'needsProducerReviewIcon' +// +// The pane launcher (src/start.ts) must surface this as an actionable +// diagnostic instead of letting the pane die silently. +// +// NOTE: this fixture is excluded from the herdr tsconfig (src/__fixtures__) +// because the missing export is the very failure it simulates. +import { needsProducerReviewIcon } from './stale-shared.mjs'; + +console.log(needsProducerReviewIcon); diff --git a/packages/herdr/src/__fixtures__/stale-shared.mjs b/packages/herdr/src/__fixtures__/stale-shared.mjs new file mode 100644 index 000000000..77694c00d --- /dev/null +++ b/packages/herdr/src/__fixtures__/stale-shared.mjs @@ -0,0 +1,6 @@ +// packages/herdr/src/__fixtures__/stale-shared.mjs +// +// Fixture reproducing a STALE vendored @worklog/shared module +// (WL-0MVAY631M004RZ3Q): the module used to export a different surface and +// is missing the export the plugin's entry point imports. +export const legacyStatusIcon = () => 'stale'; diff --git a/packages/herdr/src/agent-tracker.ts b/packages/herdr/src/agent-tracker.ts index 42cd16c95..ced0af323 100644 --- a/packages/herdr/src/agent-tracker.ts +++ b/packages/herdr/src/agent-tracker.ts @@ -412,6 +412,12 @@ export class AgentTracker { * into children), setting `agentState` only for states with a rendered icon * (`idle` / `working` / `blocked`). `done` / `unknown` / missing states are * left unset so no icon renders. Fail-open: never throws. + * + * NOTE: the production worklist no longer calls this for its ROW icons + * (WL-0MV1BWETT005GSUJ) — the green-circle icon is pane-title existence + * (`markItemsActiveByPane` in hydrator.ts), which is guaranteed to appear + * for every open work-item pane and to disappear when none exists. These + * merge helpers remain as a retained module API and for tests. */ export async function mergeAgentStates(items: WorkItem[], tracker: AgentTracker): Promise<void> { let states: Map<string, AgentState>; diff --git a/packages/herdr/src/chord-quick-create.test.ts b/packages/herdr/src/chord-quick-create.test.ts index a56e1153d..a8db4017a 100644 --- a/packages/herdr/src/chord-quick-create.test.ts +++ b/packages/herdr/src/chord-quick-create.test.ts @@ -13,6 +13,11 @@ * - `c` then `q` opens the quick-create form (it does NOT quit), and * - the form dispatches the expected `wl create` command on submit. * + * Since WL-0MV6J5JXL002D8C4 the `c q` entry carries `open_pane: false`, so + * the form dispatches the command for background (no-pane) execution: the + * resolved `openPane === false` flag and the completion-refresh hook are + * forwarded to `onCommand`. + * * Run: npx vitest run packages/herdr/src/chord-quick-create.test.ts */ @@ -31,6 +36,7 @@ vi.mock('./fetcher.js', async (importOriginal) => { ...actual, fetchActionableCount: vi.fn().mockResolvedValue(0), fetchChildrenForItem: vi.fn().mockResolvedValue([]), + fetchBlockersForItem: vi.fn().mockResolvedValue([]), }; }); @@ -118,7 +124,9 @@ function quickCreateFormShowing(): boolean { return out.includes('Command Input') && out.includes('title') && out.includes('description'); } -function startTui(onCommand?: (c: string, model?: string) => void): Promise<unknown> { +function startTui( + onCommand?: (c: string, model?: string, openPane?: boolean, onRefresh?: () => Promise<void>) => void, +): Promise<unknown> { return runWorklistTui(async () => [], [], loadShortcutConfig(), { autoRefresh: false, autoSync: false, @@ -150,7 +158,7 @@ describe('c q (create quick) chord (WL-0MTCLB50D0026YA7)', () => { await p; }); - it('dispatches wl create with the form values on submit', async () => { + it('dispatches wl create in the background (no pane) with the form values on submit', async () => { const onCommand = vi.fn(); const p = startTui(onCommand); await tick(); @@ -178,10 +186,16 @@ describe('c q (create quick) chord (WL-0MTCLB50D0026YA7)', () => { await tick(); expect(onCommand).toHaveBeenCalledTimes(1); - // Shell-route values are POSIX single-quoted (form-dialog.ts). + // Quick create opts into background dispatch (`open_pane: false` in + // shortcuts.json, WL-0MV6J5JXL002D8C4): the form dialog forwards the + // inherited flag (3rd arg `false`) plus the completion-refresh hook + // (4th arg) so no pane opens and the list refreshes when `wl create` + // exits. Shell-route values are POSIX single-quoted (form-dialog.ts). expect(onCommand).toHaveBeenCalledWith( "!!wl create -t 'My title' -d 'My desc' -p 'medium'", undefined, + false, + expect.any(Function), ); dataHandler?.(Buffer.from('q')); diff --git a/packages/herdr/src/claim.test.ts b/packages/herdr/src/claim.test.ts index 0e0d169ae..c4de8b9e7 100644 --- a/packages/herdr/src/claim.test.ts +++ b/packages/herdr/src/claim.test.ts @@ -232,6 +232,29 @@ describe('rollbackClaimWorkItem (fetcher, WL-0MT32F908002YFFA)', () => { expect(callArgs[callArgs.indexOf('--stage') + 1]).toBe('in_review'); }); + it('non-audit rollback clears the claim assignee guarded by --if-assignee <agent> (WL-0MV85WD400091OZ6)', async () => { + const mockFn = vi.fn().mockResolvedValue({ + stdout: '{"success":true}', + stderr: '', + }); + setExecFileAsync(mockFn as any); + + const ok = await rollbackClaimWorkItem('WL-IMP', { + status: 'open', + stage: 'plan_complete', + assignee: 'herdr-1', + }); + + expect(ok).toBe(true); + const callArgs = mockFn.mock.calls[0][1] as string[]; + expect(callArgs[callArgs.indexOf('--status') + 1]).toBe('open'); + expect(callArgs[callArgs.indexOf('--if-status') + 1]).toBe('in_progress'); + expect(callArgs[callArgs.indexOf('--stage') + 1]).toBe('plan_complete'); + // The failed dispatch must release the scheduling hold (empty assignee). + expect(callArgs[callArgs.indexOf('--assignee') + 1]).toBe(''); + expect(callArgs[callArgs.indexOf('--if-assignee') + 1]).toBe('herdr-1'); + }); + it('defaults to status open and omits the stage guard when no original state is given', async () => { const mockFn = vi.fn().mockResolvedValue({ stdout: '{"success":true}', diff --git a/packages/herdr/src/code-freeze-dialog.test.ts b/packages/herdr/src/code-freeze-dialog.test.ts index e151ab984..c4c074075 100644 --- a/packages/herdr/src/code-freeze-dialog.test.ts +++ b/packages/herdr/src/code-freeze-dialog.test.ts @@ -34,6 +34,7 @@ vi.mock('./fetcher.js', async (importOriginal) => { ...actual, fetchActionableCount: vi.fn().mockResolvedValue(0), fetchChildrenForItem: vi.fn().mockResolvedValue([]), + fetchBlockersForItem: vi.fn().mockResolvedValue([]), }; }); diff --git a/packages/herdr/src/downtime-worker.assignee-claim.test.ts b/packages/herdr/src/downtime-worker.assignee-claim.test.ts new file mode 100644 index 000000000..4fcaca1b0 --- /dev/null +++ b/packages/herdr/src/downtime-worker.assignee-claim.test.ts @@ -0,0 +1,269 @@ +/** + * packages/herdr/src/downtime-worker.assignee-claim.test.ts — dispatch/claim + * contracts for the status-neutral audit-tier claim (epic WL-0MV82CHGQ000HAZZ, + * test-first child WL-0MV85WARS000THEC; implementation child C4 + * WL-0MV85WCJJ0087VMP). + * + * The audit-tier claim must be STATUS-NEUTRAL: it changes only the assignee + * (CAS-guarded), so an audited `completed`/`in_review` item never enters the + * `in-progress`/`in_review` hybrid that stranding autocracies were built on + * (root cause WL-0MV7W77AY005MGBL). Tests here pin: + * + * AC1/AC5 — herdr dispatch claim → simulated audit Phase-2 failure → item + * back at completed/in_review, re-selectable (no + * needsProducerReview-column dependency). + * C4 AC — `claimWorkItem` is status-neutral for the audit tier (writes + * NO --status/--stage); `rollbackClaimWorkItem` clears the + * assignee guarded by `--if-assignee <agent>`. + * C4 AC — `selectAuditCandidate` excludes audit candidates with a + * non-empty assignee (the scheduling hold). + * + * The dispatch-level claim→failure→rollback→re-selection WIRING is already + * covered by the existing audit-tier suite (WL-0MT32F908002YFFA / + * WL-0MSRBFFLN005W3VT markers + rollbacks); this file pins the parts that + * CHANGE under Option A — the fetcher command shape and the assignee-based + * selection exclusion — plus one cross-seam regression that runs the real + * fetcher against a real shared DB through a small wl-command interpreter + * (a harness that maps emitted CLI args onto `updateIfMatches`; the CAS + * primitive itself is the production code under test). + * + * Test-first discipline: specs that pinned the TARGET were committed under + * vitest `it.fails(...)` (expected-failure) so the suite stayed green while + * the contract was red; C4 flipped `it.fails(` → `it(` as it landed. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { WorklogDatabase } from '@worklog/shared'; +import { claimWorkItem, rollbackClaimWorkItem, setExecFileAsync, resetExecFileAsync } from './fetcher.js'; +import { selectAuditCandidate, type AuditCandidate } from './downtime-worker.js'; + +// ── Test seam: a minimal wl-command interpreter over a real shared DB ── +// +// `claimWorkItem`/`rollbackClaimWorkItem` shell out to the `wl` CLI through +// `execFileAsync`. This harness replaces that seam with an interpreter that +// maps the emitted CLI args onto the shared DB's `updateIfMatches` CAS +// primitive — so the spec exercises the real fetcher arg construction and +// the real database semantics without subprocess latency. It covers only +// the update surface the contracts above rely on. +type FakeExecResult = { stdout: string; stderr?: string }; +function makeWlInterpreter(db: WorklogDatabase) { + return async (_binary: string, args: string[]): Promise<FakeExecResult> => { + // Args arrive as [<global...>, 'update', <id>, <key>, <value>...] with a + // trailing '--json'. Normalise by locating the 'update' token. + const cmdIdx = args.findIndex((a) => a === 'update'); + if (cmdIdx === -1) { + throw new Error(JSON.stringify({ success: false, error: 'unsupported-command', args })); + } + const id = args[cmdIdx + 1]; + const opts: Record<string, string> = {}; + for (let i = cmdIdx + 2; i < args.length; i += 1) { + if (args[i].startsWith('--') && i + 1 < args.length) { + opts[args[i].slice(2)] = args[i + 1]; + i += 1; + } + } + + const input: Record<string, string> = {}; + if (opts.assignee !== undefined) input.assignee = opts.assignee; + if (opts.status !== undefined) input.status = opts.status; + if (opts.stage !== undefined) input.stage = opts.stage; + + const expected: Record<string, string> = {}; + if (opts['if-status'] !== undefined) expected.status = opts['if-status']; + if (opts['if-stage'] !== undefined) expected.stage = opts['if-stage']; + if (opts['if-assignee'] !== undefined) expected.assignee = opts['if-assignee']; + + const result = db.updateIfMatches(id, input, expected); + if (result.ok) { + return { stdout: JSON.stringify({ success: true, workItem: result.item }) }; + } + if (result.reason === 'stale') { + throw new Error(JSON.stringify({ success: false, error: 'stale' })); + } + throw new Error(JSON.stringify({ success: false, error: 'not-found' })); + }; +} + +const FIXED_TS = '2026-01-01T00:00:00.000Z'; + +function makeItem(overrides: Record<string, unknown> = {}): Record<string, unknown> { + return { + id: 'WL-AUD', + title: 'Review me', + description: 'needs audit', + status: 'completed', + priority: 'medium', + sortIndex: 0, + parentId: null, + createdAt: FIXED_TS, + updatedAt: FIXED_TS, + tags: [], + assignee: '', + stage: 'in_review', + issueType: 'task', + createdBy: '', + deletedBy: '', + deleteReason: '', + risk: '', + effort: '', + needsProducerReview: false, + ...overrides, + }; +} + +describe('status-neutral audit-tier claim (WL-0MV85WARS000THEC)', () => { + beforeEach(() => { + resetExecFileAsync(); + }); + + afterEach(() => { + resetExecFileAsync(); + }); + + // ── C4 AC: claimWorkItem command shape ──────────────────────────── + + it('claimWorkItem for the audit tier is status-neutral: assignee CAS only, no --status/--stage', async () => { + const recorded: string[][] = []; + setExecFileAsync((async (_b: string, args: string[]) => { + recorded.push(args); + return { stdout: '{"success":true}' }; + }) as any); + + const result = await claimWorkItem('WL-AUD', 'audit-tier', { status: 'completed', stage: 'in_review' }); + expect(result.success).toBe(true); + + const callArgs = recorded[0]; + // The audit tier must NOT flip the item to in_progress (the hybrid the + // failure-restore path could not represent) — no status/stage writes. + expect(callArgs).not.toContain('--status'); + expect(callArgs).not.toContain('--stage'); + // The claim IS a guarded assignee write: the item is only claimed while + // it is still audit-bucket AND still unassigned. + expect(callArgs[callArgs.indexOf('--assignee') + 1]).toBe('audit-tier'); + expect(callArgs[callArgs.indexOf('--if-status') + 1]).toBe('completed'); + expect(callArgs[callArgs.indexOf('--if-stage') + 1]).toBe('in_review'); + expect(callArgs[callArgs.indexOf('--if-assignee') + 1]).toBe(''); + }); + + it('non-audit tiers keep the status-flip claim (in_progress) — unchanged behaviour', async () => { + const recorded: string[][] = []; + setExecFileAsync((async (_b: string, args: string[]) => { + recorded.push(args); + return { stdout: '{"success":true}' }; + }) as any); + + await claimWorkItem('WL-IMP', 'Map', { status: 'open', stage: 'plan_complete' }); + + const callArgs = recorded[0]; + expect(callArgs[callArgs.indexOf('--status') + 1]).toBe('in_progress'); + expect(callArgs).not.toContain('--if-assignee'); + }); + + // ── C4 AC: rollbackClaimWorkItem command shape ──────────────────── + + it('rollbackClaimWorkItem for the audit tier clears the assignee guarded by --if-assignee <agent>', async () => { + const recorded: string[][] = []; + setExecFileAsync((async (_b: string, args: string[]) => { + recorded.push(args); + return { stdout: '{"success":true}' }; + }) as any); + + const ok = await rollbackClaimWorkItem( + 'WL-AUD', + // Contract: the claimed agent rides on the original expected state; + // only THIS claim's assignee may be cleared (no clobbering). + { status: 'completed', stage: 'in_review', assignee: 'audit-tier' } as any, + ); + expect(ok).toBe(true); + + const callArgs = recorded[0]; + expect(callArgs).toContain('update'); + expect(callArgs).toContain('--assignee'); + expect(callArgs[callArgs.indexOf('--assignee') + 1]).toBe(''); + expect(callArgs[callArgs.indexOf('--if-assignee') + 1]).toBe('audit-tier'); + // Status-neutral rollback: never rewrites status/stage (keeps the item + // at completed/in_review without a hybrid write). + expect(callArgs).not.toContain('--status'); + expect(callArgs).not.toContain('--stage'); + }); + + // ── AC1/AC5 regression: claim → simulated audit Phase-2 failure ───── + // The item must read back completed/in_review (never a hybrid) and be + // RE-SELECTABLE by the audit tier without any needsProducerReview column. + + it('dispatch claim → simulated audit Phase-2 failure → completed/in_review again, re-selectable', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'wl-herdr-assignee-')); + const db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true); + try { + db.import([makeItem()] as any); + + setExecFileAsync(makeWlInterpreter(db) as any); + + // Phase 1: the herdr audit dispatcher claims the item. + const claim = await claimWorkItem('WL-AUD', 'audit-tier', { status: 'completed', stage: 'in_review' }); + expect(claim.success).toBe(true); + + // Between claim and Phase-2 completion the item stays in the audit + // bucket — never in-progress/in_review. + let stored = db.get('WL-AUD'); + expect(stored?.status).toBe('completed'); + expect(stored?.stage).toBe('in_review'); + expect(stored?.assignee).toBe('audit-tier'); + + // Phase 2 FAILS (timeout/infra) → the guarded rollback releases the + // claim. + const rollbackOk = await rollbackClaimWorkItem( + 'WL-AUD', + { status: 'completed', stage: 'in_review', assignee: 'audit-tier' } as any, + ); + expect(rollbackOk).toBe(true); + + // The item is back at completed/in_review — and the claim residue is + // gone (assignee cleared) — no needsProducerReview involvement. + stored = db.get('WL-AUD'); + expect(stored?.status).toBe('completed'); + expect(stored?.stage).toBe('in_review'); + expect(stored?.assignee).toBe(''); + expect(stored?.needsProducerReview).toBe(false); + + // Re-selectable: the audit tier's own selector picks it again. + const candidate: AuditCandidate = { + id: 'WL-AUD', + title: 'Review me', + auditedAt: null, // missing → not fresh → eligible for re-dispatch + updatedAt: new Date().toISOString(), + sortIndex: 0, + }; + expect(selectAuditCandidate([candidate], Date.now())?.id).toBe('WL-AUD'); + } finally { + resetExecFileAsync(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + // ── C4 AC: scheduling hold — assigned items are not audit-selected ── + + it('selectAuditCandidate excludes audit candidates with a non-empty assignee', () => { + const now = Date.now(); + const due: AuditCandidate = { + id: 'WL-FREE', + title: 'Free to audit', + auditedAt: null, + updatedAt: new Date(now - 3600_000).toISOString(), + sortIndex: 1, + }; + // The producer-hold item would win on sortIndex but must be excluded. + const held = { + ...due, + id: 'WL-HELD', + assignee: 'producer', + sortIndex: 0, + } as AuditCandidate; + + const selected = selectAuditCandidate([held, due], now); + expect(selected?.id).toBe('WL-FREE'); + }); +}); diff --git a/packages/herdr/src/downtime-worker.audit-tier.test.ts b/packages/herdr/src/downtime-worker.audit-tier.test.ts index 5aedd0c38..1facdc5ba 100644 --- a/packages/herdr/src/downtime-worker.audit-tier.test.ts +++ b/packages/herdr/src/downtime-worker.audit-tier.test.ts @@ -47,7 +47,9 @@ describe('dispatch audit tier', () => { expect(outcome.kind).toBe('audit'); expect(outcome.candidate?.id).toBe('WL-AUD'); expect(deps.getNextItem).not.toHaveBeenCalled(); + // Observable contract: deps.claimItem boundary CAS call — sole channel proving the claim args expect(deps.claimItem).toHaveBeenCalledWith('WL-AUD', { status: 'completed', stage: 'in_review' }, '/repo'); + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the spawn expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:audit WL-AUD'), { model: 'plan', cwd: '/repo', itemId: 'WL-AUD', itemTitle: 'Audit me' }, @@ -81,7 +83,9 @@ describe('dispatch audit tier', () => { expect(outcome.kind).toBe('intake'); expect(outcome.candidate?.id).toBe('WL-IDE'); expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); + // Observable contract: Nth tier-ordering (audit→plan→intake fallback) — outcome.kind alone cannot prove sequence; retained per ordering rule expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); + // Observable contract: Nth tier-ordering (plan→intake fallback) — retained per ordering rule expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); }); @@ -171,15 +175,19 @@ describe('audit tier interim freshness re-check (WL-0MT8KSTOE00871E7)', () => { expect(outcome.candidate?.id).toBe('WL-PLN'); // No AUDIT dispatch side-effects: the audit candidate is never claimed, // no audit comment/marker, no audit spawn. + // Observable contract: deps.claimItem boundary CAS call — sole channel proving the claim args expect(deps.claimItem).toHaveBeenCalledWith('WL-PLN', { status: 'open', stage: 'intake_complete' }, '/repo'); + // Observable contract: negative boundary assertion — proves recordDispatch NOT called with the downgraded marker; unique no-op coverage expect(deps.recordDispatch).not.toHaveBeenCalledWith( expect.objectContaining({ itemId: 'WL-AUD', kind: 'audit' }), ); + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the spawn expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:plan WL-PLN'), expect.any(Object), ); // The re-check was performed exactly once on the candidate. + // Observable contract: hasFreshAudit boundary dep — sole channel proving the interim-audit check ran on the right item expect(deps.hasFreshAudit).toHaveBeenCalledWith('WL-AUD', '/repo'); }); @@ -260,8 +268,10 @@ describe('audit tier interim freshness re-check (WL-0MT8KSTOE00871E7)', () => { expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('audit'); + // Observable contract: deps.claimItem boundary CAS call — sole channel proving the claim args expect(deps.claimItem).toHaveBeenCalledWith('WL-AUD', { status: 'completed', stage: 'in_review' }, '/repo'); expect(deps.recordDispatch).toHaveBeenCalledTimes(1); // marker/comment written + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the spawn expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:audit WL-AUD'), { model: 'plan', cwd: '/repo', itemId: 'WL-AUD', itemTitle: 'Audit me' }, @@ -331,6 +341,7 @@ describe('dispatch active-audit single-flight (parent WL-0MT3PHW4I002SNOV)', () const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); // The single-flight check runs first, then the candidate lookup. + // Observable contract: getActiveAudit boundary dep — cwd arg is the observable audit-state query contract expect(deps.getActiveAudit).toHaveBeenCalledWith('/repo'); expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); expect(outcome.dispatched).toBe(true); @@ -414,6 +425,7 @@ describe('dispatch active-audit single-flight (parent WL-0MT3PHW4I002SNOV)', () expect(outcome.kind).toBe('implement'); expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + // Observable contract: stderrSpy observable output stream — sole channel for the skipped/saturated reason log (producer Q1-A/plan-A) expect(stderrSpy).toHaveBeenCalledWith( expect.stringContaining('Downtime audit tier skipped: audit-in-flight'), ); @@ -542,6 +554,7 @@ describe('dispatch host-wide audit saturation (WL-0MUIVE0YG000UVIA)', () => { const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + // Observable contract: getActiveAudit boundary dep — cwd arg observable; host-wide skip behaviour expect(deps.getActiveAudit).toHaveBeenCalledWith('/repo'); expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); expect(outcome.dispatched).toBe(true); @@ -604,15 +617,19 @@ describe('dispatch host-wide audit saturation (WL-0MUIVE0YG000UVIA)', () => { expect(outcome.kind).toBe('implement'); expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + // Observable contract: stderrSpy observable output stream — sole channel for the skipped/saturated reason log (producer Q1-A/plan-A) expect(stderrSpy).toHaveBeenCalledWith( expect.stringContaining('Downtime audit tier skipped: audit-host-saturated'), ); + // Observable contract: stderrSpy observable output stream — sole channel for the skipped/saturated reason log (producer Q1-A/plan-A) expect(stderrSpy).toHaveBeenCalledWith( expect.stringContaining('concurrentAudits=1'), ); + // Observable contract: stderrSpy observable output stream — sole channel for the skipped/saturated reason log (producer Q1-A/plan-A) expect(stderrSpy).toHaveBeenCalledWith( expect.stringContaining('contentionQueueDepth=0'), ); + // Observable contract: stderrSpy observable output stream — sole channel for the skipped/saturated reason log (producer Q1-A/plan-A) expect(stderrSpy).toHaveBeenCalledWith( expect.stringContaining('freeSlots=2'), ); @@ -869,6 +886,7 @@ describe('dispatch audit trail', () => { expect(outcome.dispatched).toBe(false); expect(outcome.reason).toBe('claim-rolled-back'); expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + // Observable contract: rollbackClaim boundary dep — sole channel proving the rollback CAS args expect(deps.rollbackClaim).toHaveBeenCalledWith( 'WL-ABC', { status: 'open', stage: 'intake_complete' }, @@ -890,6 +908,7 @@ describe('dispatch audit trail', () => { expect(outcome.dispatched).toBe(false); expect(outcome.reason).toBe('claim-rolled-back'); expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + // Observable contract: rollbackClaim boundary dep — sole channel proving the rollback CAS args expect(deps.rollbackClaim).toHaveBeenCalledWith( 'WL-ABC', { status: 'open', stage: 'intake_complete' }, @@ -1045,6 +1064,7 @@ describe('dispatch CAS claim race', () => { // A failure trace is appended so the audit log distinguishes // "attempted" from "opened" (AC2) — it never logs success for a pane // that never appeared. + // Observable contract: recordDispatchFailure boundary dep — failure-trace payload is the observable recovery contract expect(deps.recordDispatchFailure).toHaveBeenCalledWith( expect.objectContaining({ itemId: 'WL-ABC', @@ -1070,6 +1090,7 @@ describe('dispatch CAS claim race', () => { expect(outcome.reason).toBe('spawn-failed'); expect(outcome.exitCode).toBe(1); // The failure trace carries the exit code for the audit log (AC2). + // Observable contract: recordDispatchFailure boundary dep — failure-trace payload is the observable recovery contract expect(deps.recordDispatchFailure).toHaveBeenCalledWith( expect.objectContaining({ itemId: 'WL-ABC', kind: 'plan', exitCode: 1 }), ); @@ -1090,9 +1111,11 @@ describe('dispatch CAS claim race', () => { expect(outcome.reason).toBe('spawn-failed'); // The failure trace is preserved AND the claim is rolled back to the // tier's pre-claim state — the item returns to the selectable backlog. + // Observable contract: recordDispatchFailure boundary dep — failure-trace payload is the observable recovery contract expect(deps.recordDispatchFailure).toHaveBeenCalledWith( expect.objectContaining({ itemId: 'WL-ABC', kind: 'plan', error: 'ENOENT: send-to-pi.sh' }), ); + // Observable contract: rollbackClaim boundary dep — sole channel proving the rollback CAS args expect(deps.rollbackClaim).toHaveBeenCalledWith( 'WL-ABC', { status: 'open', stage: 'intake_complete' }, @@ -1116,6 +1139,7 @@ describe('dispatch CAS claim race', () => { // The audit tier's pre-claim state is completed/in_review, NOT open — // rolling back to `open` would silently move the item out of the audit // queue (WL-0MT32F908002YFFA AC1/AC2). + // Observable contract: rollbackClaim boundary dep — sole channel proving the rollback CAS args expect(deps.rollbackClaim).toHaveBeenCalledWith( 'WL-AUD', { status: 'completed', stage: 'in_review' }, diff --git a/packages/herdr/src/downtime-worker.critical-first.test.ts b/packages/herdr/src/downtime-worker.critical-first.test.ts index cb91ac424..1ef8de49d 100644 --- a/packages/herdr/src/downtime-worker.critical-first.test.ts +++ b/packages/herdr/src/downtime-worker.critical-first.test.ts @@ -491,9 +491,12 @@ describe('dispatch critical-first tier', () => { expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('intake'); expect(outcome.candidate?.id).toBe('WL-CRIT-I'); + // Observable contract: getNextCriticalCandidate boundary dep — cwd arg is the observable critical-tier query contract expect(deps.getNextCriticalCandidate).toHaveBeenCalledWith('/repo'); expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); + // Observable contract: claimItem boundary CAS call — sole channel proving claim args (incl. gate counts where asserted) expect(deps.claimItem).toHaveBeenCalledWith('WL-CRIT-I', { status: 'open', stage: 'idea' }, '/repo'); + // Observable contract: spawnAgentPane boundary call — sole observable channel for the spawn expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:intake WL-CRIT-I'), { model: 'plan', cwd: '/repo', itemId: 'WL-CRIT-I', itemTitle: 'Critical idea WL-CRIT-I' }, @@ -518,7 +521,9 @@ describe('dispatch critical-first tier', () => { expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('plan'); expect(outcome.candidate?.id).toBe('WL-CRIT-R'); + // Observable contract: claimItem boundary CAS call — sole channel proving claim args (incl. gate counts where asserted) expect(deps.claimItem).toHaveBeenCalledWith('WL-CRIT-R', { status: 'open', stage: 'intake_complete' }, '/repo'); + // Observable contract: spawnAgentPane boundary call — sole observable channel for the spawn expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:plan WL-CRIT-R'), expect.anything(), @@ -538,7 +543,9 @@ describe('dispatch critical-first tier', () => { expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('implement'); expect(outcome.candidate?.id).toBe('WL-CRIT-P'); + // Observable contract: claimItem boundary CAS call — sole channel proving claim args (incl. gate counts where asserted) expect(deps.claimItem).toHaveBeenCalledWith('WL-CRIT-P', { status: 'open', stage: 'plan_complete' }, '/repo'); + // Observable contract: spawnAgentPane boundary call — sole observable channel for the spawn expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:implement WL-CRIT-P'), expect.anything(), @@ -634,10 +641,12 @@ describe('dispatch critical-first tier', () => { expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('plan'); expect(outcome.candidate?.id).toBe('WL-PLAN'); + // Observable contract: spawnAgentPane boundary call — sole observable channel for the spawn expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:plan WL-PLAN'), expect.anything(), ); + // Observable contract: negative boundary assertion — proves claimItem NOT called during freeze; unique fail-closed coverage expect(deps.claimItem).not.toHaveBeenCalledWith('WL-CRIT-P', expect.anything()); }); @@ -654,7 +663,9 @@ describe('dispatch critical-first tier', () => { expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('plan'); + // Observable contract: claimItem boundary CAS call — sole channel proving claim args (incl. gate counts where asserted) expect(deps.claimItem).toHaveBeenCalledWith('WL-CRIT-R', { status: 'open', stage: 'intake_complete' }, '/repo'); + // Observable contract: spawnAgentPane boundary call — sole observable channel for the spawn expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:plan WL-CRIT-R'), expect.anything(), @@ -679,10 +690,12 @@ describe('dispatch critical-first tier', () => { // Frozen: the critical implement candidate is skipped (fail-closed on // the ambiguous marker); the intake tier still dispatches. expect(outcome.kind).toBe('intake'); + // Observable contract: spawnAgentPane boundary call — sole observable channel for the spawn expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:intake WL-IDEA'), expect.anything(), ); + // Observable contract: negative boundary assertion — proves claimItem NOT called during freeze; unique fail-closed coverage expect(deps.claimItem).not.toHaveBeenCalledWith('WL-CRIT-P', expect.anything()); }); @@ -718,7 +731,9 @@ describe('dispatch critical-first tier', () => { expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('plan'); expect(outcome.candidate?.id).toBe('WL-BLOCKER'); + // Observable contract: claimItem boundary CAS call — sole channel proving claim args (incl. gate counts where asserted) expect(deps.claimItem).toHaveBeenCalledWith('WL-BLOCKER', { status: 'open', stage: 'intake_complete' }, '/repo'); + // Observable contract: spawnAgentPane boundary call — sole observable channel for the spawn expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:plan WL-BLOCKER'), expect.anything(), @@ -1055,6 +1070,7 @@ describe('needsProducerReview exclusion', () => { expect(outcome.candidate?.id).toBe('B-PLAN'); // The CAS guard must match the item's ACTUAL status, not the tier's // nominal `open` — otherwise the claim aborts as stale. + // Observable contract: claimItem boundary CAS call — sole channel proving claim args (incl. gate counts where asserted) expect(deps.claimItem).toHaveBeenCalledWith( 'B-PLAN', { status: 'blocked', stage: 'intake_complete' }, @@ -1159,6 +1175,7 @@ describe('review-queue depth gate (WL-0MT2UQWOR007CYY9)', () => { expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('implement'); + // Observable contract: claimItem boundary CAS call — sole channel proving claim args (incl. gate counts where asserted) expect(deps.claimItem).toHaveBeenCalledWith('IMP-CRIT', { status: 'open', stage: 'plan_complete' }, '/repo'); }); @@ -1189,6 +1206,7 @@ describe('review-queue depth gate (WL-0MT2UQWOR007CYY9)', () => { expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('implement'); + // Observable contract: claimItem boundary CAS call — sole channel proving claim args (incl. gate counts where asserted) expect(deps.claimItem).toHaveBeenCalledWith('IMP-MED', { status: 'open', stage: 'plan_complete' }, '/repo'); }); @@ -1238,7 +1256,6 @@ describe('review-queue depth gate (WL-0MT2UQWOR007CYY9)', () => { expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('plan'); // Plan tier should be reached - expect(deps.getNextItem).toHaveBeenCalledWith('intake_complete', '/repo'); }); it('AC3: gate boundary — count == browseItemCount - 1: gate NOT active, all priorities dispatch', async () => { @@ -1472,6 +1489,7 @@ describe('review-queue depth gate — live Herdr-head path (WL-0MTTSWC1X005P4VD) expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('audit'); expect(outcome.candidate?.id).toBe('AUD-1'); + // Observable contract: spawnAgentPane boundary call — sole observable channel for the spawn expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:audit AUD-1'), expect.anything(), @@ -1661,6 +1679,7 @@ describe('worker keeps polling while the review queue is deep (WL-0MTTSWC1X005P4 expect(resumed.dispatched).toBe(true); expect(worker.paused).toBe(false); // The implement pane actually spawned (tick results carry no kind field). + // Observable contract: spawnAgentPane boundary call — sole observable channel for the spawn expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:implement IMP-HELD'), expect.anything(), diff --git a/packages/herdr/src/downtime-worker.dispatch.test.ts b/packages/herdr/src/downtime-worker.dispatch.test.ts index e7b2e5f48..7baa7a4fe 100644 --- a/packages/herdr/src/downtime-worker.dispatch.test.ts +++ b/packages/herdr/src/downtime-worker.dispatch.test.ts @@ -31,11 +31,11 @@ describe('dispatch selection', () => { const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - expect(deps.getNextItem).toHaveBeenCalledWith('intake_complete', '/repo'); expect(deps.getNextItem).toHaveBeenCalledTimes(1); expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('plan'); expect(outcome.candidate?.id).toBe('WL-ABC'); + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the pane-spawn side effect expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:plan WL-ABC'), { model: 'plan', cwd: '/repo', itemId: 'WL-ABC', itemTitle: 'Some task' }, @@ -54,6 +54,7 @@ describe('dispatch selection', () => { // The claim carries the expected state the tier selected the item in // (RCA WL-0MSRBFFLN005W3VT design point 1 — compare-and-swap). + // Observable contract: deps.claimItem boundary CAS call — sole channel proving the claim args (status/stage guard) expect(deps.claimItem).toHaveBeenCalledWith('WL-ABC', { status: 'open', stage: 'intake_complete' }, '/repo'); const claimOrder = (deps.claimItem as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0]; const spawnOrder = (deps.spawnAgentPane as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0]; @@ -70,11 +71,15 @@ describe('dispatch selection', () => { const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + // Observable contract: Nth tier-ordering assertion — outcome.kind alone cannot prove the query sequence; retained per ordering rule expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); + // Observable contract: Nth tier-ordering assertion — outcome.kind alone cannot prove the query sequence; retained per ordering rule expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); expect(outcome.kind).toBe('intake'); expect(outcome.candidate?.id).toBe('WL-DEF'); + // Observable contract: deps.claimItem boundary CAS call — sole channel proving the claim args (status/stage guard) expect(deps.claimItem).toHaveBeenCalledWith('WL-DEF', { status: 'open', stage: 'idea' }, '/repo'); + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the pane-spawn side effect expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:intake WL-DEF'), expect.anything(), @@ -86,7 +91,9 @@ describe('dispatch selection', () => { const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + // Observable contract: Nth tier-ordering assertion — outcome.kind alone cannot prove the query sequence; retained per ordering rule expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); + // Observable contract: Nth tier-ordering assertion — outcome.kind alone cannot prove the query sequence; retained per ordering rule expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); expect(outcome.dispatched).toBe(false); expect(outcome.reason).toBe('no-candidate'); @@ -102,7 +109,9 @@ describe('dispatch selection', () => { // Tier 2 error does NOT short-circuit: tier 3 is still attempted. expect(deps.getNextItem).toHaveBeenCalledTimes(2); + // Observable contract: Nth tier-ordering assertion — outcome.kind alone cannot prove the query sequence; retained per ordering rule expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); + // Observable contract: Nth tier-ordering assertion — outcome.kind alone cannot prove the query sequence; retained per ordering rule expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); expect(outcome.dispatched).toBe(false); expect(outcome.reason).toBe('wl-error'); @@ -123,9 +132,13 @@ describe('dispatch selection', () => { expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('intake'); expect(outcome.candidate?.id).toBe('WL-IDE'); + // Observable contract: Nth tier-ordering assertion — outcome.kind alone cannot prove the query sequence; retained per ordering rule expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); + // Observable contract: Nth tier-ordering assertion — outcome.kind alone cannot prove the query sequence; retained per ordering rule expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); + // Observable contract: deps.claimItem boundary CAS call — sole channel proving the claim args (status/stage guard) expect(deps.claimItem).toHaveBeenCalledWith('WL-IDE', { status: 'open', stage: 'idea' }, '/repo'); + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the pane-spawn side effect expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:intake WL-IDE'), expect.anything(), @@ -160,7 +173,9 @@ describe('dispatch selection', () => { const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + // Observable contract: Nth tier-ordering assertion — outcome.kind alone cannot prove the query sequence; retained per ordering rule expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); + // Observable contract: Nth tier-ordering assertion — outcome.kind alone cannot prove the query sequence; retained per ordering rule expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); expect(outcome.dispatched).toBe(false); expect(outcome.reason).toBe('wl-error'); @@ -214,6 +229,7 @@ describe('dispatcher anchor wiring (C0 dispatcher workspace)', () => { expect(deps.getDispatcherAnchor).toHaveBeenCalled(); // The resolved anchor pane id flows into the spawn opts (→ --anchor in // the send-to-pi.sh args built by buildDowntimePaneArgs). + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the pane-spawn side effect expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:plan WL-ABC'), expect.objectContaining({ anchorId: 'wD:pANCHOR' }), @@ -231,6 +247,7 @@ describe('dispatcher anchor wiring (C0 dispatcher workspace)', () => { const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); expect(outcome.dispatched).toBe(true); + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the pane-spawn side effect expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:plan WL-ABC'), expect.not.objectContaining({ anchorId: expect.anything() }), @@ -272,6 +289,7 @@ describe('dispatcher anchor wiring (C0 dispatcher workspace)', () => { expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('scheduled'); + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the pane-spawn side effect expect(deps.spawnAgentPane).toHaveBeenCalledWith( 'Run the nightly sweep', expect.objectContaining({ anchorId: 'wD:pANCHOR' }), @@ -301,7 +319,9 @@ describe('per-prefix Dispatcher tab dispatch wiring (C1 fallback)', () => { const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); expect(outcome.dispatched).toBe(true); + // Observable contract: injected boundary adapter (anchor/window/item-tab lookup) — args (cwd, prefix, itemId) are the observable routing contract expect(getDispatcherTabAnchor).toHaveBeenCalledWith('/repo', 'WL'); + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the pane-spawn side effect expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:plan WL-ABC'), expect.objectContaining({ anchorId: 'wD:tWL:p1' }), @@ -331,12 +351,16 @@ describe('per-prefix Dispatcher tab dispatch wiring (C1 fallback)', () => { await dispatchDowntimeWork(wlDeps, { model: 'plan', cwd: '/repo-wl' }); await dispatchDowntimeWork(tceDeps, { model: 'plan', cwd: '/repo-tce' }); + // Observable contract: injected boundary adapter (anchor/window/item-tab lookup) — args (cwd, prefix, itemId) are the observable routing contract expect(wlDeps.getDispatcherTabAnchor).toHaveBeenCalledWith('/repo-wl', 'WL'); + // Observable contract: injected boundary adapter (anchor/window/item-tab lookup) — args (cwd, prefix, itemId) are the observable routing contract expect(tceDeps.getDispatcherTabAnchor).toHaveBeenCalledWith('/repo-tce', 'TCE'); + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the pane-spawn side effect expect(wlDeps.spawnAgentPane).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ anchorId: 'wD:tWL:p1', cwd: '/repo-wl' }), ); + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the pane-spawn side effect expect(tceDeps.spawnAgentPane).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ anchorId: 'wD:tTCE:p1', cwd: '/repo-tce' }), @@ -362,9 +386,12 @@ describe('per-prefix Dispatcher tab dispatch wiring (C1 fallback)', () => { await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + // Observable contract: injected boundary adapter (anchor/window/item-tab lookup) — args (cwd, prefix, itemId) are the observable routing contract expect(resolveProjectWorkspace).toHaveBeenCalledWith('/repo'); + // Observable contract: injected boundary adapter (anchor/window/item-tab lookup) — args (cwd, prefix, itemId) are the observable routing contract expect(getItemTabAnchor).toHaveBeenCalledWith('/repo', 'wC', 'WL-ABC'); expect(getDispatcherTabAnchor).not.toHaveBeenCalled(); + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the pane-spawn side effect expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ anchorId: 'wC:tWL-ABC:p1' }), @@ -391,10 +418,13 @@ describe('per-prefix Dispatcher tab dispatch wiring (C1 fallback)', () => { const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); expect(outcome.dispatched).toBe(true); + // Observable contract: injected boundary adapter (anchor/window/item-tab lookup) — args (cwd, prefix, itemId) are the observable routing contract expect(resolveProjectWorkspace).toHaveBeenCalledWith('/repo'); expect(getItemTabAnchor).not.toHaveBeenCalled(); + // Observable contract: injected boundary adapter (anchor/window/item-tab lookup) — args (cwd, prefix, itemId) are the observable routing contract expect(getDispatcherTabAnchor).toHaveBeenCalledWith('/repo', 'WL'); expect(getDispatcherAnchor).not.toHaveBeenCalled(); + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the pane-spawn side effect expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ anchorId: 'wD:tWL:p1' }), @@ -503,9 +533,12 @@ describe('project workspace + item-ID tab dispatch wiring (primary path)', () => const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); expect(outcome.dispatched).toBe(true); + // Observable contract: injected boundary adapter (anchor/window/item-tab lookup) — args (cwd, prefix, itemId) are the observable routing contract expect(resolveProjectWorkspace).toHaveBeenCalledWith('/repo'); + // Observable contract: injected boundary adapter (anchor/window/item-tab lookup) — args (cwd, prefix, itemId) are the observable routing contract expect(getItemTabAnchor).toHaveBeenCalledWith('/repo', 'wC', 'WL-ABC'); expect(getDispatcherAnchor).not.toHaveBeenCalled(); + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the pane-spawn side effect expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:plan WL-ABC'), expect.objectContaining({ anchorId: 'wC:tWL-ABC:p1' }), @@ -528,8 +561,10 @@ describe('project workspace + item-ID tab dispatch wiring (primary path)', () => const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); expect(outcome.dispatched).toBe(true); + // Observable contract: injected boundary adapter (anchor/window/item-tab lookup) — args (cwd, prefix, itemId) are the observable routing contract expect(getDispatcherAnchor).toHaveBeenCalledWith('/repo'); expect(getItemTabAnchor).not.toHaveBeenCalled(); + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the pane-spawn side effect expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ anchorId: 'wD:pFALLBACK' }), @@ -591,6 +626,7 @@ describe('project workspace + item-ID tab dispatch wiring (primary path)', () => expect(outcome.dispatched).toBe(true); expect(getDispatcherAnchor).toHaveBeenCalled(); + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the pane-spawn side effect expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ anchorId: 'wD:pFALLBACK' }), @@ -625,6 +661,7 @@ describe('project workspace + item-ID tab dispatch wiring (primary path)', () => await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + // Observable contract: injected boundary adapter (anchor/window/item-tab lookup) — args (cwd, prefix, itemId) are the observable routing contract expect(getItemTabAnchor).toHaveBeenCalledWith('/repo', 'wC', 'WL-ABC'); expect('getDispatcherTabAnchor' in deps).toBe(false); }); @@ -654,10 +691,12 @@ describe('project workspace + item-ID tab dispatch wiring (primary path)', () => expect(first.dispatched).toBe(true); expect(second.dispatched).toBe(true); + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the pane-spawn side effect expect(wl.spawnAgentPane).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ anchorId: 'wC:tWL-1:p1', cwd: '/repo-wl' }), ); + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the pane-spawn side effect expect(tce.spawnAgentPane).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ anchorId: 'wY:tTCE-2:p1', cwd: '/repo-tce' }), @@ -682,6 +721,7 @@ describe('project workspace + item-ID tab dispatch wiring (primary path)', () => expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('scheduled'); expect(resolveProjectWorkspace).not.toHaveBeenCalled(); + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the pane-spawn side effect expect(deps.spawnAgentPane).toHaveBeenCalledWith( 'Run the nightly sweep', expect.objectContaining({ anchorId: 'wD:pSCHED' }), @@ -705,9 +745,11 @@ describe('project workspace + item-ID tab dispatch wiring (primary path)', () => const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo-tce' }); expect(outcome.dispatched).toBe(true); + // Observable contract: recordDispatch boundary dep — marker payload (id/project root) is the observable audit-log contract expect(recordDispatch).toHaveBeenCalledWith( expect.objectContaining({ itemId: 'TCE-9', kind: 'plan', cwd: '/repo-tce' }), ); + // Observable contract: deps.spawnAgentPane boundary call — sole observable channel for the pane-spawn side effect expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ cwd: '/repo-tce', anchorId: 'wY:tTCE-9:p1' }), @@ -741,6 +783,7 @@ describe('project workspace + item-ID tab dispatch wiring (primary path)', () => expect(outcome.dispatched).toBe(true); expect(closePane).toHaveBeenCalledTimes(1); + // Observable contract: closePane boundary dep — pane-key + root args are the observable close contract expect(closePane).toHaveBeenCalledWith('wC:tWL-ABC:p1', '/repo'); }); @@ -861,6 +904,7 @@ describe('project workspace + item-ID tab dispatch wiring (primary path)', () => expect(outcome.dispatched).toBe(true); expect(closePane).toHaveBeenCalledTimes(1); + // Observable contract: closePane boundary dep — pane-key + root args are the observable close contract expect(closePane).toHaveBeenCalledWith('wC:tWL-ABC:p1', '/repo'); }); @@ -888,6 +932,7 @@ describe('project workspace + item-ID tab dispatch wiring (primary path)', () => expect(outcome.dispatched).toBe(true); expect(closePane).toHaveBeenCalledTimes(1); + // Observable contract: closePane boundary dep — pane-key + root args are the observable close contract expect(closePane).toHaveBeenCalledWith('wC:tWL-ABC:p1', '/repo'); }); diff --git a/packages/herdr/src/downtime-worker.orchestrator.test.ts b/packages/herdr/src/downtime-worker.orchestrator.test.ts index 9e8e9b760..01480e215 100644 --- a/packages/herdr/src/downtime-worker.orchestrator.test.ts +++ b/packages/herdr/src/downtime-worker.orchestrator.test.ts @@ -169,6 +169,7 @@ describe('downtime worker orchestrator (createDowntimeWorker)', () => { // idleAllSlotsFree: 4 of 4 slots free, total 4 → forwarded verbatim so // the spawn boundary never has to reach back into the worker. + // Observable contract: spawnAgentPane boundary call expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ @@ -401,6 +402,7 @@ describe('downtime no-candidate cooldown (createDowntimeWorker)', () => { const result = await worker.tick(); expect(result.dispatched).toBe(true); + // Observable contract: spawnAgentPane boundary call expect(deps.spawnAgentPane).toHaveBeenCalledWith( '/skill:refactor', expect.objectContaining({ paneName: 'Downtime /skill:refactor' }), @@ -849,6 +851,7 @@ describe('three-strike rule on CLI errors (createDowntimeWorker)', () => { expect(worker.paused).toBe(true); expect(worker.errorStrikes).toBe(0); expect(deps.recordError).toHaveBeenCalledTimes(3); + // Observable contract: recordError boundary dep — three-strike bookkeeping is the observable failure contract expect(deps.recordError).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/repo' }), ); diff --git a/packages/herdr/src/downtime-worker.pane.test.ts b/packages/herdr/src/downtime-worker.pane.test.ts index d4aec9900..43fa759e7 100644 --- a/packages/herdr/src/downtime-worker.pane.test.ts +++ b/packages/herdr/src/downtime-worker.pane.test.ts @@ -101,13 +101,12 @@ describe('downtime pane spawn (send-to-pi.sh)', () => { const spawned = await spawnDowntimePane('/path/to/send-to-pi.sh', ['--no-focus', 'prompt'], { cwd: '/repo' }, spawnFn); + // Observable contract: spawnFn injected boundary adapter — args (script, argv, config) are the observable spawn contract expect(spawnFn).toHaveBeenCalledWith('/path/to/send-to-pi.sh', ['--no-focus', 'prompt'], { cwd: '/repo', }); const handle = (spawnFn as ReturnType<typeof vi.fn>).mock.results[0].value; expect(handle.unref).toHaveBeenCalled(); - expect(handle.once).toHaveBeenCalledWith('error', expect.any(Function)); - expect(handle.once).toHaveBeenCalledWith('exit', expect.any(Function)); // No error/exit within the probe window → the pane is assumed opened. expect(spawned).toEqual({ ok: true }); }); @@ -124,8 +123,6 @@ describe('downtime pane spawn (send-to-pi.sh)', () => { const spawned = await spawnDowntimePane('/path/to/missing.sh', [], { cwd: '/repo' }, spawnFn); expect(spawned).toEqual({ ok: false, error: 'ENOENT: send-to-pi.sh' }); - expect(handle.once).toHaveBeenCalledWith('error', expect.any(Function)); - expect(handle.once).toHaveBeenCalledWith('exit', expect.any(Function)); }); it('resolves {ok:false} with the exit trace on a non-zero script exit within the probe window', async () => { @@ -311,6 +308,7 @@ describe('downtime pane spawn (send-to-pi.sh)', () => { await spawnDowntimePane('/path/to/send-to-pi.sh', [], { cwd: '/repo', config }, spawnFn); + // Observable contract: spawnFn injected boundary adapter — config forward is the observable spawn contract expect(spawnFn).toHaveBeenCalledWith('/path/to/send-to-pi.sh', [], { cwd: '/repo', config }); }); }); @@ -748,6 +746,7 @@ describe('worker tick: the LLM idle check is the sole concurrency limiter (WL-0M const result = await worker.tick(); expect(result.idle).toBe(true); expect(callback).toHaveBeenCalledTimes(1); + // Observable contract: onProxyIdle callback injected boundary adapter — status argument is the observable proxy-state contract (producer plan-A) expect(callback).toHaveBeenCalledWith(idleAllSlotsFree); } finally { vi.useRealTimers(); @@ -821,6 +820,7 @@ describe('worker tick: the LLM idle check is the sole concurrency limiter (WL-0M expect(result.dispatched).toBe(false); expect(result.idle).toBe(true); // proxy is idle, just threshold not met expect(callback).toHaveBeenCalledTimes(1); + // Observable contract: onProxyIdle callback injected boundary adapter — status argument is the observable proxy-state contract (producer plan-A) expect(callback).toHaveBeenCalledWith(idleAllSlotsFree); } finally { vi.useRealTimers(); @@ -986,6 +986,7 @@ describe('pane-lifecycle monitor (WL-0MU308WSF0002JWN) [disabled behaviour]', () const res = await monitorDispatchedPanes(deps, root); expect(res).toMatchObject({ checked: 1, logged: 1, closed: 1, errors: 0 }); + // Observable contract: closePane boundary dep — pane-key/root/opts args are the observable close contract expect(closePane).toHaveBeenCalledWith('w1:p1', root, { itemId: 'WL-1', kind: 'intake' }); const pc = await paneCloseEntries(root); expect(pc).toHaveLength(1); @@ -1012,6 +1013,7 @@ describe('pane-lifecycle monitor (WL-0MU308WSF0002JWN) [disabled behaviour]', () const res = await monitorDispatchedPanes(deps, root); expect(res).toMatchObject({ logged: 1, closed: 1 }); + // Observable contract: closePane boundary dep — pane-key/root/opts args are the observable close contract expect(closePane).toHaveBeenCalledWith('w1:p2', root, { itemId: 'WL-2', kind: 'plan' }); expect((await paneCloseEntries(root))[0]).toMatchObject({ outcome: 'closed-as-plan-complete', closed: true }); }); @@ -1030,6 +1032,7 @@ describe('pane-lifecycle monitor (WL-0MU308WSF0002JWN) [disabled behaviour]', () const res = await monitorDispatchedPanes(deps, root); expect(res).toMatchObject({ logged: 1, closed: 1 }); + // Observable contract: closePane boundary dep — pane-key/root/opts args are the observable close contract expect(closePane).toHaveBeenCalledWith('w1:p3', root, { itemId: 'WL-3', kind: 'audit' }); expect((await paneCloseEntries(root))[0]).toMatchObject({ outcome: 'audit-failed', closed: true }); }); @@ -1154,6 +1157,7 @@ describe('pane-lifecycle monitor (WL-0MU308WSF0002JWN) [disabled behaviour]', () expect(res).toMatchObject({ checked: 2, logged: 1, closed: 1, errors: 1 }); expect(closePane).toHaveBeenCalledTimes(1); + // Observable contract: closePane boundary dep — pane-key/root/opts args are the observable close contract expect(closePane).toHaveBeenCalledWith('w1:pB', root, { itemId: 'WL-GOOD', kind: 'intake' }); }); @@ -1182,6 +1186,7 @@ describe('pane-lifecycle monitor (WL-0MU308WSF0002JWN) [disabled behaviour]', () await monitorDispatchedPanes(deps, root); + // Observable contract: closePane boundary dep — pane-key/root/opts args are the observable close contract expect(closePane).toHaveBeenCalledWith('w1:p11', root, { itemId: 'WL-11', kind: 'plan' }); expect((await paneCloseEntries(root))[0]).toMatchObject({ outcome: 'requires-attention', @@ -1351,6 +1356,7 @@ describe('worker tick pane-lifecycle integration (WL-0MU4US5MP001JFEN) [disabled await worker.tick(); + // Observable contract: runPaneCloseReaper injected boundary adapter — scheduled-reaper wiring is the observable recovery contract await vi.waitFor(() => expect(runPaneCloseReaper).toHaveBeenCalledWith( root, { idleThresholdMinutes: 30, ledgerPath: undefined }, diff --git a/packages/herdr/src/downtime-worker.parse.test.ts b/packages/herdr/src/downtime-worker.parse.test.ts index 365454957..63ecd4ded 100644 --- a/packages/herdr/src/downtime-worker.parse.test.ts +++ b/packages/herdr/src/downtime-worker.parse.test.ts @@ -167,6 +167,7 @@ describe('dispatch log: selection path/reason + pane id enrichment (WL-0MUBVL251 await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + // Observable contract: recordDispatch boundary dep — selectionPath/selectionReason payload is the observable audit-log contract expect(deps.recordDispatch).toHaveBeenCalledWith( expect.objectContaining({ itemId: 'CR-SEL', @@ -184,6 +185,7 @@ describe('dispatch log: selection path/reason + pane id enrichment (WL-0MUBVL251 await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + // Observable contract: recordDispatch boundary dep — selectionPath/selectionReason payload is the observable audit-log contract expect(deps.recordDispatch).toHaveBeenCalledWith( expect.objectContaining({ itemId: 'IMP-SEL', diff --git a/packages/herdr/src/downtime-worker.settings.test.ts b/packages/herdr/src/downtime-worker.settings.test.ts index 18ecfa1fd..7f00fd376 100644 --- a/packages/herdr/src/downtime-worker.settings.test.ts +++ b/packages/herdr/src/downtime-worker.settings.test.ts @@ -89,6 +89,7 @@ describe('endpoint failures and poller', () => { const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); const poller = createDowntimePoller('http://proxy:8000', fetcher); await poller.poll(); + // Observable contract: fetcher injected boundary adapter — URL arg is the sole observable channel for URL construction (producer plan-A) expect(fetcher).toHaveBeenCalledWith('http://proxy:8000/llama/local/status', expect.anything()); }); @@ -96,6 +97,7 @@ describe('endpoint failures and poller', () => { const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); const poller = createDowntimePoller('http://proxy:8000/', fetcher); await poller.poll(); + // Observable contract: fetcher injected boundary adapter — URL arg is the sole observable channel for URL construction (producer plan-A) expect(fetcher).toHaveBeenCalledWith('http://proxy:8000/llama/local/status', expect.anything()); }); diff --git a/packages/herdr/src/downtime-worker.sprint-view.test.ts b/packages/herdr/src/downtime-worker.sprint-view.test.ts index 2b24eb859..45c226276 100644 --- a/packages/herdr/src/downtime-worker.sprint-view.test.ts +++ b/packages/herdr/src/downtime-worker.sprint-view.test.ts @@ -110,6 +110,7 @@ describe('critical-first scan on Herdr-head path (WL-0MU6UL3XY001M3VT)', () => { expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('implement'); expect(outcome.candidate?.id).toBe('CR-1'); + // Observable contract: claimItem boundary CAS call expect(deps.claimItem).toHaveBeenCalledWith( 'CR-1', { status: 'open', stage: 'plan_complete' }, root, ); @@ -341,6 +342,7 @@ describe('Herdr-head dispatch threads needsProducerReview (WL-0MU72WJ8C0005GIE)' expect(outcome.kind).toBe('implement'); expect(outcome.candidate?.id).toBe('NPR-ALLOWED'); // The gated item was never claimed. + // Observable contract: negative boundary assertion — proves claimItem NOT called for blocked items; unique NOP coverage expect(deps.claimItem).not.toHaveBeenCalledWith('NPR-GATED', expect.any(Object), '/repo'); }); @@ -355,6 +357,7 @@ describe('Herdr-head dispatch threads needsProducerReview (WL-0MU72WJ8C0005GIE)' expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('intake'); expect(outcome.candidate?.id).toBe('NPR-IDEA-ALLOWED'); + // Observable contract: negative boundary assertion — proves claimItem NOT called for blocked items; unique NOP coverage expect(deps.claimItem).not.toHaveBeenCalledWith('NPR-IDEA-GATED', expect.any(Object), '/repo'); }); @@ -834,6 +837,7 @@ describe('sprint-view-only dispatch window (WL-0MUNS8X97007C9H9; supersedes WL-0 await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + // Observable contract: getHerdrListHead boundary dep — count arg proves effective browseItemCount is forwarded expect(getHerdrListHead).toHaveBeenCalledWith('/repo', 20); }); @@ -855,6 +859,7 @@ describe('sprint-view-only dispatch window (WL-0MUNS8X97007C9H9; supersedes WL-0 await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + // Observable contract: getHerdrListHead boundary dep — count arg proves effective browseItemCount is forwarded expect(getHerdrListHead).toHaveBeenCalledWith('/repo', 20); }); @@ -864,6 +869,7 @@ describe('sprint-view-only dispatch window (WL-0MUNS8X97007C9H9; supersedes WL-0 await computeMostImportantItem(deps, '/repo', Date.now(), 33); + // Observable contract: getHerdrListHead boundary dep — count arg proves effective browseItemCount is forwarded expect(getHerdrListHead).toHaveBeenCalledWith('/repo', 33); }); @@ -874,7 +880,9 @@ describe('sprint-view-only dispatch window (WL-0MUNS8X97007C9H9; supersedes WL-0 await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 15 }); await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 40 }); + // Observable contract: Nth ordering assertion — proves browseItemCount re-read per dispatch cycle; outcome cannot prove the per-cycle sequence expect(getHerdrListHead).toHaveBeenNthCalledWith(1, '/repo', 15); + // Observable contract: Nth ordering assertion — proves browseItemCount re-read per dispatch cycle; outcome cannot prove the per-cycle sequence expect(getHerdrListHead).toHaveBeenNthCalledWith(2, '/repo', 40); }); }); @@ -900,6 +908,7 @@ describe('sprint-view-only dispatch window (WL-0MUNS8X97007C9H9; supersedes WL-0 // The critical-only extension still consulted the SAME ranking path // (never a second ranking). expect(getHerdrListHead).toHaveBeenCalledTimes(2); + // Observable contract: Nth ordering assertion — proves browseItemCount re-read per dispatch cycle; outcome cannot prove the per-cycle sequence expect(getHerdrListHead).toHaveBeenNthCalledWith( 2, '/repo', @@ -1048,6 +1057,7 @@ describe('sprint-view-only dispatch window (WL-0MUNS8X97007C9H9; supersedes WL-0 expect(extended.map((i) => i.id)).toEqual(['C', 'E']); expect(extended.every((i) => i.priority === 'critical')).toBe(true); + // Observable contract: `dep` injected boundary adapter — proves extended-window count forwarded expect(dep).toHaveBeenCalledWith('/repo', 1 + DOWNTIME_DISPATCH_EXTEND_MAX); }); @@ -1149,6 +1159,7 @@ describe('dispatched success-marker staleness on the live path (WL-0MU6UL0RJ008I expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('plan'); expect(outcome.candidate?.id).toBe('PLN-1'); + // Observable contract: spawnAgentPane boundary call expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:plan PLN-1'), expect.anything(), diff --git a/packages/herdr/src/downtime-worker.test.ts b/packages/herdr/src/downtime-worker.test.ts index 9345590db..c2e00242f 100644 --- a/packages/herdr/src/downtime-worker.test.ts +++ b/packages/herdr/src/downtime-worker.test.ts @@ -734,6 +734,7 @@ describe('per-item/per-kind dispatch-attempt cap (WL-0MUKYEXMK0033MFK)', () => { }); expect(deps.markNeedsProducerReview).toHaveBeenCalledTimes(1); + // Observable contract: markNeedsProducerReview boundary dep — itemId+cwd args are the observable producer-flag contract expect(deps.markNeedsProducerReview).toHaveBeenCalledWith('IMP-1', cwd); }); @@ -809,6 +810,7 @@ describe('per-item/per-kind dispatch-attempt cap (WL-0MUKYEXMK0033MFK)', () => { expect('noCandidate' in result).toBe(false); expect('candidate' in result).toBe(false); } + // Observable contract: markNeedsProducerReview boundary dep — itemId+cwd args are the observable producer-flag contract expect(deps.markNeedsProducerReview).toHaveBeenCalledWith('IMP-1', cwd); }); @@ -869,6 +871,7 @@ describe('per-item/per-kind dispatch-attempt cap (WL-0MUKYEXMK0033MFK)', () => { expect(outcome.reason).toBe('attempt-budget-exhausted'); expect(deps.claimItem).not.toHaveBeenCalled(); expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + // Observable contract: markNeedsProducerReview boundary dep — itemId+cwd args are the observable producer-flag contract expect(deps.markNeedsProducerReview).toHaveBeenCalledWith('IMP-1', cwd); }); @@ -890,6 +893,7 @@ describe('per-item/per-kind dispatch-attempt cap (WL-0MUKYEXMK0033MFK)', () => { expect(outcome.dispatched).toBe(false); expect(outcome.reason).toBe('attempt-budget-exhausted'); + // Observable contract: markNeedsProducerReview boundary dep — itemId+cwd args are the observable producer-flag contract expect(deps.markNeedsProducerReview).toHaveBeenCalledWith('PLN-1', cwd); }); }); @@ -966,6 +970,7 @@ describe('stalled-work scan before new-item dispatch (WL-0MUYMBSA90092QV2)', () expect(outcome).toEqual({ dispatched: false, reason: 'stalled-resume' }); expect(scanStalledPanes).toHaveBeenCalledTimes(1); + // Observable contract: resumeStalledPane boundary dep — sole channel proving the resume path ran expect(resumeStalledPane).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/repo', kind: 'implement' }), expect.objectContaining({ enabled: true, thresholdMs: expect.any(Number) }), @@ -1426,10 +1431,12 @@ describe('own-backlog fallback safety gates (WL-0MUZPBRX3007STFK)', () => { const outcome = await dispatchFallback(deps, coordinationDir, cwd); // The fallback was attempted (post-implementation) — RED before it lands. + // Observable contract: getHerdrListHead boundary dep — cwd+count args are the observable scan seam contract expect(deps.getHerdrListHead).toHaveBeenCalledWith(cwd, expect.any(Number)); // The frozen implement is never claimed; the plan fallback dispatches. expect(outcome.dispatched).toBe(true); expect(outcome.candidate?.id).toBe('WL-OOV-PLAN'); + // Observable contract: negative boundary assertion — proves claimItem NOT called for out-of-view items; unique NOP coverage expect(deps.claimItem).not.toHaveBeenCalledWith('WL-OOV-IMPL', expect.anything(), expect.anything()); }, ); @@ -1447,9 +1454,11 @@ describe('own-backlog fallback safety gates (WL-0MUZPBRX3007STFK)', () => { const outcome = await dispatchFallback(deps, coordinationDir, cwd); + // Observable contract: getHerdrListHead boundary dep — cwd+count args are the observable scan seam contract expect(deps.getHerdrListHead).toHaveBeenCalledWith(cwd, expect.any(Number)); expect(outcome.dispatched).toBe(true); expect(outcome.candidate?.id).toBe('WL-OOV-PLAN'); + // Observable contract: negative boundary assertion — proves claimItem NOT called for out-of-view items; unique NOP coverage expect(deps.claimItem).not.toHaveBeenCalledWith('WL-OOV-IMPL', expect.anything(), expect.anything()); }, ); @@ -1482,11 +1491,14 @@ describe('own-backlog fallback safety gates (WL-0MUZPBRX3007STFK)', () => { markerStaleWindowMs: WINDOW_MS, }); + // Observable contract: getHerdrListHead boundary dep — cwd+count args are the observable scan seam contract expect(deps.getHerdrListHead).toHaveBeenCalledWith(cwd, expect.any(Number)); // The exhausted item is flagged and skipped; the next eligible dispatches. expect(outcome.dispatched).toBe(true); expect(outcome.candidate?.id).toBe('WL-OOV-FRESH'); + // Observable contract: markNeedsProducerReview boundary dep — itemId+cwd args are the observable producer-flag contract expect(deps.markNeedsProducerReview).toHaveBeenCalledWith('WL-OOV-BUDGET', cwd); + // Observable contract: negative boundary assertion — proves claimItem NOT called for out-of-view items; unique NOP coverage expect(deps.claimItem).not.toHaveBeenCalledWith('WL-OOV-BUDGET', expect.anything(), expect.anything()); }, ); @@ -1515,6 +1527,7 @@ describe('own-backlog fallback safety gates (WL-0MUZPBRX3007STFK)', () => { const outcome = await dispatchFallback(deps, coordinationDir, cwd); + // Observable contract: getHerdrListHead boundary dep — cwd+count args are the observable scan seam contract expect(deps.getHerdrListHead).toHaveBeenCalledWith(cwd, expect.any(Number)); expect(outcome.dispatched).toBe(false); expect(outcome.reason).toBe('in-flight-pane'); diff --git a/packages/herdr/src/downtime-worker.tiers.test.ts b/packages/herdr/src/downtime-worker.tiers.test.ts index 7204183c7..48f0e323e 100644 --- a/packages/herdr/src/downtime-worker.tiers.test.ts +++ b/packages/herdr/src/downtime-worker.tiers.test.ts @@ -72,8 +72,11 @@ describe('dispatch implement tier', () => { expect(outcome.candidate?.id).toBe('WL-IMP'); expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); + // Observable contract: getNextImplementCandidate boundary dep — sole channel proving the implement-tier query contract expect(deps.getNextImplementCandidate).toHaveBeenCalledWith('/repo'); + // Observable contract: claimItem boundary CAS call expect(deps.claimItem).toHaveBeenCalledWith('WL-IMP', { status: 'open', stage: 'plan_complete' }, '/repo'); + // Observable contract: spawnAgentPane boundary call expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:implement WL-IMP'), { model: 'plan', cwd: '/repo', itemId: 'WL-IMP', itemTitle: 'Implement me' }, @@ -139,6 +142,7 @@ describe('dispatch implement tier', () => { expect(outcome.kind).toBe('plan'); expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); expect(deps.getNextItem).toHaveBeenCalledTimes(1); + // Observable contract: Nth tier-ordering (plan/intake fallback) — outcome.kind alone cannot prove sequence; retained per ordering rule expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); }); @@ -155,7 +159,9 @@ describe('dispatch implement tier', () => { const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); expect(outcome.kind).toBe('intake'); + // Observable contract: Nth tier-ordering (plan/intake fallback) — outcome.kind alone cannot prove sequence; retained per ordering rule expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); + // Observable contract: Nth tier-ordering (plan/intake fallback) — outcome.kind alone cannot prove sequence; retained per ordering rule expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); }); @@ -220,10 +226,11 @@ describe('dispatch code-freeze gate', () => { expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); // Plan/intake tiers still dispatch. - expect(deps.getNextItem).toHaveBeenCalledWith('intake_complete', '/repo'); expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('plan'); + // Observable contract: claimItem boundary CAS call expect(deps.claimItem).toHaveBeenCalledWith('WL-PLAN', { status: 'open', stage: 'intake_complete' }, '/repo'); + // Observable contract: spawnAgentPane boundary call expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:plan WL-PLAN'), expect.anything(), @@ -308,6 +315,7 @@ describe('dispatch code-freeze gate', () => { expect(resumed.dispatched).toBe(true); expect(resumed.kind).toBe('implement'); expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); + // Observable contract: spawnAgentPane boundary call expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:implement WL-IMP'), expect.anything(), @@ -324,6 +332,7 @@ describe('dispatch code-freeze gate', () => { expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('audit'); + // Observable contract: getNextAuditCandidate boundary dep — sole channel proving the audit-tier query contract expect(deps.getNextAuditCandidate).toHaveBeenCalledWith('/repo'); }); }); @@ -362,10 +371,12 @@ describe('dispatch scheduled-prompts tier', () => { const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + // Observable contract: getDueScheduledPrompt boundary dep — sole channel proving the scheduled-tier query contract expect(deps.getDueScheduledPrompt).toHaveBeenCalledWith('/repo'); expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('scheduled'); // The prompt text is the dispatch payload — not a skill+work-item wrap. + // Observable contract: spawnAgentPane boundary call expect(deps.spawnAgentPane).toHaveBeenCalledWith('/skill:refactor', { model: 'plan', cwd: '/repo', @@ -388,11 +399,13 @@ describe('dispatch scheduled-prompts tier', () => { // The trigger timestamp is persisted (atomic config update) and the // rolling log marker is written with kind scheduled + noItemComment. + // Observable contract: recordScheduledPromptTrigger boundary dep — itemId/lastTriggeredAt payload is the observable persistence contract expect(deps.recordScheduledPromptTrigger).toHaveBeenCalledWith( '/repo', '/skill:refactor', expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), ); + // Observable contract: recordDispatch boundary dep — marker payload observable expect(deps.recordDispatch).toHaveBeenCalledWith( expect.objectContaining({ itemId: '/skill:refactor', @@ -424,6 +437,7 @@ describe('dispatch scheduled-prompts tier', () => { const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + // Observable contract: getDueScheduledPrompt boundary dep expect(deps.getDueScheduledPrompt).toHaveBeenCalledWith('/repo'); expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('audit'); @@ -447,6 +461,7 @@ describe('dispatch scheduled-prompts tier', () => { expect(deps.getDueScheduledPrompt).not.toHaveBeenCalled(); expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('plan'); + // Observable contract: spawnAgentPane boundary call expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:plan WL-PLAN'), expect.anything(), @@ -509,6 +524,7 @@ describe('dispatch scheduled-prompts tier', () => { expect(outcome.dispatched).toBe(false); expect(outcome.reason).toBe('spawn-failed'); expect(outcome.error).toBe('ENOENT'); + // Observable contract: recordDispatchFailure boundary dep — failure-trace payload observable expect(deps.recordDispatchFailure).toHaveBeenCalledWith( expect.objectContaining({ itemId: '/skill:refactor', diff --git a/packages/herdr/src/downtime-worker.ts b/packages/herdr/src/downtime-worker.ts index bc291cecc..cf8935d26 100644 --- a/packages/herdr/src/downtime-worker.ts +++ b/packages/herdr/src/downtime-worker.ts @@ -1232,6 +1232,12 @@ export interface DowntimeHerdrItem { updatedAt?: string; sortIndex?: number; parentId?: string | null; + /** + * Soft-delete attribution (`wl delete` sets it; SA-0MUJBLZC60085DI9): a + * non-empty value excludes the item from every dispatch path. Carried by + * the fetcher's `WorkItem`. + */ + deletedBy?: string; /** * Needs-producer-review flag (WL-0MTIAL65N004T22F): carried by the * fetcher's `WorkItem`; when `true` the item is not dispatchable — the @@ -1240,6 +1246,48 @@ export interface DowntimeHerdrItem { * the check-in offer computation. */ needsProducerReview?: boolean; + /** + * Current assignee (empty/absent = unassigned). A non-empty assignee is a + * scheduling hold: the item is held by a human/agent and must not be + * dispatched at any tier (WL-0MV85WCJJ0087VMP). + */ + assignee?: string; +} + +/** + * Whether a candidate/work item carries a soft-delete marker. + * + * `wl delete` sets `deletedBy` (and `deleteReason`) and may leave the + * status untouched, so a status-based filter alone can re-select a deleted + * item and dispatch it a second time (SA-0MUJBLZC60085DI9). A non-empty + * (non-whitespace) `deletedBy` is the authoritative deletion marker and + * excludes the item from every dispatch path. Blank/absent values mean the + * item is live. + */ +export function isSoftDeleted( + item: { deletedBy?: string | null } | null | undefined, +): boolean { + if (item === null || item === undefined) return false; + const deletedBy = item.deletedBy; + return typeof deletedBy === 'string' && deletedBy.trim() !== ''; +} + +/** + * Whether a candidate/work item is held by a non-empty assignee. + * + * An assignee (human or agent) means the item is already being worked, so it + * must never be auto-dispatched at any tier (WL-0MV85WCJJ0087VMP). The + * audit-tier claim additionally CASes on the empty assignee + * (`--if-assignee ""`), so a held item would fail stale there anyway; this + * helper excludes it up front at every selection path. Blank/absent values + * mean the item is free to claim. + */ +export function isAssigneeHeld( + item: { assignee?: string | null } | null | undefined, +): boolean { + if (item === null || item === undefined) return false; + const assignee = item.assignee; + return typeof assignee === 'string' && assignee.trim() !== ''; } export type DowntimeHerdrListResult = | { ok: true; items: DowntimeHerdrItem[] } @@ -1273,6 +1321,11 @@ export interface DowntimeCandidate { sortIndex?: number; /** Worklog priority level (critical/high/medium/low) — round-robin grouping key. */ priority?: string; + /** + * Soft-delete attribution (SA-0MUJBLZC60085DI9): a non-empty `deletedBy` + * marks the item as deleted and excludes it from every `select*` filter. + */ + deletedBy?: string; /** * Needs producer review flag (parent WL-0MTIAL65N004T22F): when `true`, * the item must NOT be auto-dispatched by the downtime worker — a human @@ -1280,6 +1333,12 @@ export interface DowntimeCandidate { * Filtered in every `select*` function (AC1). */ needsProducerReview?: boolean; + /** + * Current assignee (empty/absent = unassigned). A non-empty assignee is a + * scheduling hold — the item is held by a human/agent and is excluded from + * every `select*` filter (WL-0MV85WCJJ0087VMP). + */ + assignee?: string; } /** @@ -1299,6 +1358,10 @@ export interface ImplementCandidate { sortIndex?: number; /** Worklog priority level (critical/high/medium/low) — round-robin grouping key. */ priority?: string; + /** Soft-delete attribution (SA-0MUJBLZC60085DI9): non-empty ⇒ excluded from selection. */ + deletedBy?: string; + /** Assignee hold (WL-0MV85WCJJ0087VMP): non-empty ⇒ excluded from selection. */ + assignee?: string; /** * Needs producer review flag (parent WL-0MTIAL65N004T22F): when `true`, * exclude from implement-tier selection (AC1). @@ -1325,6 +1388,8 @@ export interface AuditCandidate { sortIndex?: number; /** Worklog priority level (critical/high/medium/low) — round-robin grouping key. */ priority?: string; + /** Soft-delete attribution (SA-0MUJBLZC60085DI9): non-empty ⇒ excluded from selection. */ + deletedBy?: string; /** * Needs producer review flag (parent WL-0MTIAL65N004T22F): when `true`, * exclude from audit-tier selection (AC1). @@ -1337,6 +1402,13 @@ export interface AuditCandidate { * any child that would otherwise slip through (WL-0MUBVH8QG0020H9L AC2). */ parentId?: string | null; + /** + * Current assignee (empty/absent = unassigned). A non-empty assignee is a + * scheduling hold — the audit tier excludes it so a producer-held item is + * never claimed (the claim's `--if-assignee ""` CAS would fail stale) + * (WL-0MV85WCJJ0087VMP). + */ + assignee?: string; } /** @@ -1372,6 +1444,12 @@ export interface DowntimeItemInfo { risk?: string; /** Effort level (XS/S/M/L/XL). */ effort?: string; + /** + * Current assignee (empty/absent = unassigned). A non-empty assignee is a + * scheduling hold: the item is held by a human/agent and must not be + * dispatched at any tier (WL-0MV85WCJJ0087VMP). + */ + assignee?: string; /** Latest audit timestamp (enriched; absent on show-only items). */ auditedAt?: string | null; /** Item update timestamp (audit-tier recency window). */ @@ -1382,6 +1460,12 @@ export interface DowntimeItemInfo { currentFingerprint?: string | null; /** wl priority order preserved for deterministic ordering. */ sortIndex?: number; + /** + * Soft-delete attribution such as parsed from `wl show --json` + * (SA-0MUJBLZC60085DI9): a non-empty `deletedBy` makes the item + * non-dispatchable (`classifyItemForDispatch` returns null). + */ + deletedBy?: string; /** * Needs producer review flag (parent WL-0MTIAL65N004T22F): when `true`, * exclude from classification (the item is not dispatchable) and the @@ -1439,6 +1523,14 @@ export type MostImportantItemResult = export interface DowntimeClaimExpected { status?: string; stage?: string; + /** + * The claiming agent's assignee (WL-0MV85WD400091OZ6). Carried from the + * claim to the failure rollback so the recovery can clear ONLY this claim's + * assignee (guarded by `--if-assignee <agent>`) — a failed dispatch must + * never leave a non-empty assignee (a non-empty assignee acts as a + * scheduling hold and would make the item permanently undispatchable). + */ + assignee?: string; } /** @@ -2770,6 +2862,10 @@ async function dispatchFromHerdrList( updatedAt: item.updatedAt, sortIndex: item.sortIndex, parentId: item.parentId, + deletedBy: item.deletedBy, + // Assignee hold (WL-0MV85WCJJ0087VMP): carried into the classification + // info so a held item is excluded by `classifyItemForDispatch` here too. + assignee: item.assignee, needsProducerReview: item.needsProducerReview, }; const k = classifyItemForDispatch(info, now); @@ -3470,9 +3566,18 @@ export function classifyItemForDispatch( info: DowntimeItemInfo, now: number = Date.now(), ): DowntimeSkillKind | null { + // Exclude soft-deleted items (SA-0MUJBLZC60085DI9 AC3): a non-empty + // `deletedBy` is the authoritative deletion marker — the item must never + // be dispatched, even if its status/stage would otherwise qualify. + if (isSoftDeleted(info)) return null; // Exclude items needing producer review (parent WL-0MTIAL65N004T22F AC1). // Items flagged for producer review must NOT be auto-dispatched at any tier. if (info.needsProducerReview === true) return null; + // Assignee hold (WL-0MV85WCJJ0087VMP): an item held by a non-empty + // assignee is never auto-dispatched at any tier. Assigning an item to a + // human/agent (e.g. the producer) makes it non-dispatchable, and the + // audit-tier claim's `--if-assignee ""` CAS would fail stale anyway. + if (isAssigneeHeld(info)) return null; const status = typeof info.status === 'string' ? info.status : ''; const stage = typeof info.stage === 'string' ? info.stage : ''; if (status === 'completed') { @@ -6757,6 +6862,8 @@ export function parseNextCandidatesOutput( ? nested.sortIndex : undefined, priority: typeof nested.priority === 'string' ? nested.priority : undefined, + deletedBy: typeof nested.deletedBy === 'string' ? nested.deletedBy : undefined, + assignee: typeof nested.assignee === 'string' ? nested.assignee : undefined, needsProducerReview: nested.needsProducerReview !== undefined ? Boolean(nested.needsProducerReview) : undefined, }); @@ -6900,8 +7007,10 @@ export function parseAuditCandidatesOutput(stdout: string): AuditCandidate[] | n sortIndex: typeof o.sortIndex === 'number' && Number.isFinite(o.sortIndex) ? o.sortIndex : undefined, priority: typeof o.priority === 'string' ? o.priority : undefined, parentId: o.parentId == null ? (o.parentId as null | undefined) : String(o.parentId), + deletedBy: typeof o.deletedBy === 'string' ? o.deletedBy : undefined, needsProducerReview: o.needsProducerReview !== undefined ? Boolean(o.needsProducerReview) : undefined, + assignee: typeof o.assignee === 'string' ? o.assignee : undefined, }); } return candidates; @@ -6983,6 +7092,7 @@ export function selectAuditCandidate( ): AuditCandidate | null { const recencyCutoff = now - DOWNTIME_AUDIT_RECENCY_WINDOW_MS; const filtered = candidates + .filter((c) => !isSoftDeleted(c)) .filter((c) => !isAuditFresh(c.auditedAt, c.updatedAt, c.fingerprint, c.currentFingerprint)) // Belt-and-suspenders child exclusion (WL-0MUBVH8QG0020H9L AC2): the // audit-tier query is root-only, so children are already excluded @@ -6992,6 +7102,10 @@ export function selectAuditCandidate( .filter((c) => !(dispatchedItemIds?.has(c.id) ?? false)) // Exclude items needing producer review (parent WL-0MTIAL65N004T22F AC1). .filter((c) => c.needsProducerReview !== true) + // Assignee hold (WL-0MV85WCJJ0087VMP): a non-empty assignee means the + // item is already held (e.g. by the producer), so it is not an audit + // candidate — the claim's `--if-assignee ""` CAS would fail stale. + .filter((c) => !isAssigneeHeld(c)) .filter((c) => { if (!c.updatedAt) return true; // missing → include const updated = new Date(c.updatedAt).getTime(); @@ -7119,6 +7233,8 @@ export function parseImplementCandidatesOutput(stdout: string): ImplementCandida effort: typeof o.effort === 'string' ? o.effort : undefined, sortIndex: typeof o.sortIndex === 'number' && Number.isFinite(o.sortIndex) ? o.sortIndex : undefined, priority: typeof o.priority === 'string' ? o.priority : undefined, + deletedBy: typeof o.deletedBy === 'string' ? o.deletedBy : undefined, + assignee: typeof o.assignee === 'string' ? o.assignee : undefined, needsProducerReview: o.needsProducerReview !== undefined ? Boolean(o.needsProducerReview) : undefined, }); @@ -7146,6 +7262,9 @@ export function selectImplementCandidate( registry?: RoundRobinRegistry, ): ImplementCandidate | null { const filtered = candidates + .filter((c) => !isSoftDeleted(c)) + // Assignee hold (WL-0MV85WCJJ0087VMP): a held item is never dispatched. + .filter((c) => !isAssigneeHeld(c)) .filter((c) => c.status === 'open') .filter((c) => { const risk = riskOrdinal(c.risk); @@ -7197,6 +7316,9 @@ export function selectNextCandidate( registry?: RoundRobinRegistry, ): DowntimeCandidate | null { const filtered = candidates + .filter((c) => !isSoftDeleted(c)) + // Assignee hold (WL-0MV85WCJJ0087VMP): a held item is never dispatched. + .filter((c) => !isAssigneeHeld(c)) .filter((c) => c.status === 'open') // Exclude items needing producer review (parent WL-0MTIAL65N004T22F AC1). .filter((c) => c.needsProducerReview !== true) @@ -7241,6 +7363,10 @@ export interface CriticalCandidate { sortIndex?: number; /** Worklog priority level (critical) — round-robin grouping key. */ priority?: string; + /** Soft-delete attribution (SA-0MUJBLZC60085DI9): non-empty ⇒ excluded from selection. */ + deletedBy?: string; + /** Assignee hold (WL-0MV85WCJJ0087VMP): non-empty ⇒ excluded from selection. */ + assignee?: string; /** * Needs producer review flag (parent WL-0MTIAL65N004T22F): when `true`, * exclude from critical-tier selection and from dependency-frontier @@ -7300,6 +7426,8 @@ export function parseCriticalCandidatesOutput(stdout: string): CriticalCandidate effort: typeof o.effort === 'string' ? o.effort : undefined, sortIndex: typeof o.sortIndex === 'number' && Number.isFinite(o.sortIndex) ? o.sortIndex : undefined, priority: typeof o.priority === 'string' ? o.priority : undefined, + deletedBy: typeof o.deletedBy === 'string' ? o.deletedBy : undefined, + assignee: typeof o.assignee === 'string' ? o.assignee : undefined, needsProducerReview: o.needsProducerReview !== undefined ? Boolean(o.needsProducerReview) : undefined, }); @@ -7328,6 +7456,9 @@ export function selectCriticalCandidate( registry?: RoundRobinRegistry, ): CriticalCandidate | null { const filtered = candidates + .filter((c) => !isSoftDeleted(c)) + // Assignee hold (WL-0MV85WCJJ0087VMP): a held item is never dispatched. + .filter((c) => !isAssigneeHeld(c)) .filter((c) => c.status === 'open') // Dispatchable stages only (idea / intake_complete / plan_complete). .filter((c) => criticalSkillKind(c.stage) !== null) @@ -7468,6 +7599,7 @@ export function parseShowItemOutput(stdout: string): DowntimeItemInfo | null { priority: typeof raw.priority === 'string' ? raw.priority : undefined, risk: typeof raw.risk === 'string' ? raw.risk : undefined, effort: typeof raw.effort === 'string' ? raw.effort : undefined, + assignee: typeof raw.assignee === 'string' ? raw.assignee : undefined, auditedAt: typeof raw.auditedAt === 'string' ? raw.auditedAt @@ -7476,6 +7608,7 @@ export function parseShowItemOutput(stdout: string): DowntimeItemInfo | null { : undefined, updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : undefined, sortIndex: typeof raw.sortIndex === 'number' && Number.isFinite(raw.sortIndex) ? raw.sortIndex : undefined, + deletedBy: typeof raw.deletedBy === 'string' ? raw.deletedBy : undefined, needsProducerReview: raw.needsProducerReview !== undefined ? Boolean(raw.needsProducerReview) : undefined, }; diff --git a/packages/herdr/src/events.test.ts b/packages/herdr/src/events.test.ts index 97f729f45..ed51f803c 100644 --- a/packages/herdr/src/events.test.ts +++ b/packages/herdr/src/events.test.ts @@ -403,6 +403,150 @@ describe('HerdrEventSubscriber — mock socket', () => { }); }); + // ── Keepalive & reconnect resilience (WL-0MVAS988R008PNH6) ───────── + + describe('keepalive & reconnect resilience (WL-0MVAS988R008PNH6)', () => { + /** Per-pane pane.agent_status_changed pane ids of a client's subscribe. */ + function subscribePaneIds( + client: NonNullable<ReturnType<MockHerdrSocket['getFirstClient']>>, + ): string[] { + const sub = client.requests.find((r) => r.method === 'events.subscribe'); + const params = sub?.params as + | { subscriptions?: Array<{ type: string; pane_id?: string }> } + | undefined; + return (params?.subscriptions ?? []) + .filter((s) => s.type === 'pane.agent_status_changed') + .map((s) => s.pane_id ?? ''); + } + + it('write-once discipline: a pane-set change never sends a second request on the live socket', async () => { + // Regression (WL-0MVAS988R008PNH6): the real herdr server accepts + // exactly ONE request per connection — any second request resets the + // connection (`stream_closed`). The subscriber must apply pane-set + // changes by re-establishing a fresh connection, never by writing a + // second frame on the established subscription socket. + const callbacks: HerdrEventCallbacks = {}; + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks, + trackedPaneIds: ['w1:p1'], + reconnectBaseDelayMs: 50, + maxReconnectAttempts: 5, + }); + + await subscriber.connect(); + const firstClient = mockServer.getFirstClient(); + expect(firstClient).toBeDefined(); + // The initial subscribe is the ONLY request on the first connection. + expect(firstClient!.requests.map((r) => r.method)).toEqual(['events.subscribe']); + + const addResult = await subscriber.addPaneSubscription('w1:p2'); + expect(addResult.type).toBe('subscribed'); + expect(subscriber.getTrackedPaneIds()).toEqual(['w1:p1', 'w1:p2']); + + await new Promise((r) => setTimeout(r, 50)); + // The original socket saw NO second request — still exactly one frame. + expect(firstClient!.requests.length).toBe(1); + + // The panes were applied via a NEW connection (the old one is gone + // from the mock's client set) carrying the full set. + const clients = mockServer.getConnectedClients(); + expect(clients.length).toBeGreaterThan(0); + const newest = clients[clients.length - 1]; + // The surviving connection is not the original one. + expect(newest).not.toBe(firstClient); + expect(subscribePaneIds(newest)).toEqual(['w1:p1', 'w1:p2']); + expect(newest.requests.length).toBe(1); // one request on the new socket too + expect(subscriber.health().status).toBe('active'); + + await subscriber.close(); + }); + + it('a closed/errored stream reconnects and re-subscribes with the full pane set (backoff reset)', async () => { + const onReconnected = vi.fn(); + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks: { onReconnected }, + trackedPaneIds: ['w1:p1'], + reconnectBaseDelayMs: 50, + maxReconnectAttempts: 5, + }); + + await subscriber.connect(); + const first = mockServer.getFirstClient(); + expect(first).toBeDefined(); + + // Server closes the stream (the stream_closed scenario). + first!.close(); + + // Wait for the backoff reconnect to land. + await new Promise((r) => setTimeout(r, 400)); + + expect(subscriber.health().status).toBe('active'); + const clients = mockServer.getConnectedClients(); + // The dead connection is gone; a fresh one re-subscribed. + const newest = clients[clients.length - 1]; + expect(newest).toBeDefined(); + expect(subscribePaneIds(newest)).toEqual(['w1:p1']); + expect(newest.requests.length).toBe(1); + // The stream was re-established after a loss → onReconnected fired. + expect(onReconnected).toHaveBeenCalled(); + + await subscriber.close(); + }); + + it('batched pane adds coalesce into a single reconnect with the full set', async () => { + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks: {}, + trackedPaneIds: ['w1:p1'], + reconnectBaseDelayMs: 50, + maxReconnectAttempts: 5, + }); + await subscriber.connect(); + const first = mockServer.getFirstClient(); + expect(first).toBeDefined(); + + const result = await subscriber.addPaneSubscriptions(['w1:p2', 'w1:p3']); + expect(result.type).toBe('subscribed'); + expect(subscriber.getTrackedPaneIds()).toEqual(['w1:p1', 'w1:p2', 'w1:p3']); + + await new Promise((r) => setTimeout(r, 50)); + // One restart connection only — never one per added pane. + const clients = mockServer.getConnectedClients(); + const newest = clients[clients.length - 1]; + expect(subscribePaneIds(newest)).toEqual(['w1:p1', 'w1:p2', 'w1:p3']); + // The original socket received no second frame. + expect(first!.requests.length).toBe(1); + expect(subscriber.health().status).toBe('active'); + + await subscriber.close(); + }); + + it('steady-state idle subscription sends no further frames (no keepalive ping needed on the stream)', async () => { + // Documented finding (WL-0MVAS988R008PNH6): the herdr server keeps an + // idle subscription open indefinitely and expects NO keepalive on the + // stream — a ping on the subscription socket would reset it. Assert the + // subscriber stays silent on the established socket over time. + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks: {}, + trackedPaneIds: ['w1:p1'], + }); + await subscriber.connect(); + const client = mockServer.getFirstClient(); + expect(client).toBeDefined(); + + await new Promise((r) => setTimeout(r, 150)); + + expect(client!.requests.length).toBe(1); + expect(client!.requests[0].method).toBe('events.subscribe'); + expect(subscriber.health().status).toBe('active'); + + await subscriber.close(); + }); + }); + // ── F2: close / cleanup ──────────────────────────────────────────── describe('close / cleanup', () => { diff --git a/packages/herdr/src/events.ts b/packages/herdr/src/events.ts index 3086e798c..e21510d09 100644 --- a/packages/herdr/src/events.ts +++ b/packages/herdr/src/events.ts @@ -30,6 +30,32 @@ * the TUI, never blocks refresh/sync. * * AC1–AC5 of WL-0MSHB7DHO004RHBJ covered. + * + * # One request per connection (WL-0MVAS988R008PNH6) + * + * Herdr's API server handles exactly ONE JSON-RPC request per connection + * (verified against herdr 0.9.5): a streaming `events.subscribe` response + * occupies the connection, and ANY second request — another subscribe, a + * `ping`, even a `pane.list` — causes the server to RESET the connection + * (TCP RST), logging the outstanding subscription as `stream_closed`. The + * previous design resent `events.subscribe` on the live socket whenever the + * tracked pane set changed (`resendSubscribeRequest`), so every pane-seen/ + * pane-gone event killed the subscription: after each reconnect the startup + * sync re-added panes, killing the fresh connection too — the ~10 s + * reconnect loop observed in herdr-server.log (sub-100…sub-115, matching + * MAX_RECONNECT_DELAY_MS). A plain idle subscription never closes (40 s+ + * probe); the server expects NO keepalive on the stream — sending one is + * actively harmful. + * + * The fix is the "keepalive the server expects": write exactly ONE request + * per connection. Pane-set changes (`addPaneSubscription` / + * `removePaneSubscription`) update `trackedPaneIds` and then re-establish a + * FRESH connection carrying the complete subscription set in the single + * initial request (bounded: batched pane changes and changes landing while a + * reconnect is in flight coalesce into one rebuild via a version counter). + * Reconnect backoff is retained for genuine socket failures and reset on + * every successful subscribe, so routine stream closes reconnect promptly + * at the base delay instead of ratcheting to the 10 s cap. */ import net from 'node:net'; @@ -123,6 +149,14 @@ export interface HerdrEventCallbacks { onPaneExited?: (data: PaneExitedEvent) => void; /** Called when a socket error occurs (for logging / monitoring). */ onError?: (error: Error) => void; + /** + * Called after the subscription is (re-)established following a prior + * stream loss or a pane-set restart. Lets callers flip their + * "events active" state back on (e.g. re-enable the pane_focused → refresh + * path and stop the resume-poll fallback). NOT called for the very first + * successful subscribe — `connect()` resolves for that (WL-0MVAS988R008PNH6). + */ + onReconnected?: () => void; } /** @@ -208,6 +242,19 @@ export class HerdrEventSubscriber { private requestCounter = 0; private pendingCallbacks = new Map<string, PendingCallback>(); + /** + * Monotonic version of the tracked pane set. Every add/remove bumps it; + * the restart loop re-subscribes until the version is stable, coalescing + * pane-set changes that land while a reconnect is in flight. + */ + private subscriptionVersion = 0; + + /** Single-flight promise for an in-progress (or scheduled) restart. */ + private restartPromise: Promise<EventResult> | null = null; + + /** True once the very first subscribe has succeeded (drives onReconnected). */ + private everActive = false; + /** * @param options Configuration options. */ @@ -252,8 +299,7 @@ export class HerdrEventSubscriber { const result = await this.doConnect(); this.isConnecting = false; if (result.type === 'subscribed') { - this.reconnectAttempts = 0; - this.currentHealth = { status: 'active', paneCount: this.trackedPaneIds.length }; + this.markSubscribed(); } return result; } catch (err: any) { @@ -526,101 +572,181 @@ export class HerdrEventSubscriber { } /** - * Add a per-pane subscription for a new pane. + * Add per-pane subscriptions for new panes (one or more). * * When a `pane_agent_detected` event arrives for a pane not yet tracked, * callers should invoke this to add a per-pane `pane.agent_status_changed` - * subscription. The pane ID is added to the tracked set and a new - * subscribe request is sent. + * subscription. The pane IDs are added to the tracked set, then the + * connection is re-established ONCE with the complete subscription set. * - * @param paneId The pane ID to subscribe to. + * WL-0MVAS988R008PNH6: herdr's server accepts exactly one request per + * connection, so a pane-set change is applied by restarting the + * connection with the full set in the single initial `events.subscribe` + * request — NEVER by writing a second request on the live socket (which + * the server answers by resetting the connection). + * + * @param paneIds The pane IDs to subscribe to. * @returns Result of the subscription update. */ - async addPaneSubscription(paneId: string): Promise<EventResult> { + async addPaneSubscriptions(paneIds: string[]): Promise<EventResult> { if (this.isClosed) { return { type: 'error', error: 'Subscriber is closed' }; } - if (this.trackedPaneIds.includes(paneId)) { + + let changed = false; + for (const paneId of paneIds) { + if (!this.trackedPaneIds.includes(paneId)) { + this.trackedPaneIds.push(paneId); + changed = true; + } + } + if (!changed) { return { type: 'subscribed', subscriptions: this.getSubscriptionCount() }; } - this.trackedPaneIds.push(paneId); + this.subscriptionVersion++; + return this.restartSubscription(); + } - // Resend subscribe request with the new pane - return this.resendSubscribeRequest(); + /** + * Add a per-pane subscription for a single new pane. + * + * @param paneId The pane ID to subscribe to. + * @returns Result of the subscription update. + */ + addPaneSubscription(paneId: string): Promise<EventResult> { + return this.addPaneSubscriptions([paneId]); } /** - * Remove a per-pane subscription. + * Remove per-pane subscriptions (one or more panes). * - * @param paneId The pane ID to remove. + * The pane IDs are dropped from the tracked set and the connection is + * re-established once with the remaining subscriptions (same + * one-request-per-connection discipline as addPaneSubscriptions). + * + * @param paneIds The pane IDs to remove. * @returns Result of the subscription update. */ - async removePaneSubscription(paneId: string): Promise<EventResult> { + async removePaneSubscriptions(paneIds: string[]): Promise<EventResult> { if (this.isClosed) { return { type: 'error', error: 'Subscriber is closed' }; } - const idx = this.trackedPaneIds.indexOf(paneId); - if (idx === -1) { + let changed = false; + for (const paneId of paneIds) { + const idx = this.trackedPaneIds.indexOf(paneId); + if (idx !== -1) { + this.trackedPaneIds.splice(idx, 1); + changed = true; + } + } + if (!changed) { return { type: 'unavailable', reason: 'Pane not tracked' }; } - this.trackedPaneIds.splice(idx, 1); + this.subscriptionVersion++; + return this.restartSubscription(); + } - // Resend subscribe request without this pane - return this.resendSubscribeRequest(); + /** + * Remove a per-pane subscription for a single pane. + * + * @param paneId The pane ID to remove. + * @returns Result of the subscription update. + */ + removePaneSubscription(paneId: string): Promise<EventResult> { + return this.removePaneSubscriptions([paneId]); } /** - * Resend the events.subscribe request with the current tracked pane set. + * Re-establish the subscription via a fresh connection, single-flight. + * + * A pane-set change cannot be applied in place (the server accepts one + * request per connection), so the subscriber tears the current socket + * down and runs a fresh `events.subscribe` carrying the complete tracked + * set. Batched calls (and changes landing while a restart is in flight) + * coalesce: `subscriptionVersion` is bumped by every add/remove, and the + * loop re-subscribes until the version is stable. + * + * A deliberate restart is not a failure: reconnect backoff is reset so a + * subsequent genuine stream close reconnects promptly at the base delay. */ - private resendSubscribeRequest(): Promise<EventResult> { - if (!this.socket || this.socket.destroyed || this.socket.writable === false) { - // Rollback: the pane was added/removed but we couldn't resubscribe - // This is a best-effort operation; in practice the reconnect - // mechanism will resubscribe with the correct pane set. - return Promise.resolve({ type: 'error', error: 'Socket not connected' }); + private restartSubscription(): Promise<EventResult> { + if (this.restartPromise) { + return this.restartPromise; } + this.restartPromise = this.runRestartLoop().finally(() => { + this.restartPromise = null; + }); + return this.restartPromise; + } - const subscriptions: SubscriptionEntry[] = [ - { type: 'pane.focused' }, - { type: 'pane.agent_detected' }, - { type: 'pane.closed' }, - { type: 'pane.exited' }, - ]; - - for (const paneId of this.trackedPaneIds) { - subscriptions.push({ - type: 'pane.agent_status_changed', - pane_id: paneId, - }); + private async runRestartLoop(): Promise<EventResult> { + let result: EventResult = { type: 'error', error: 'Subscription restart failed' }; + let stableVersion = -1; + while (this.subscriptionVersion !== stableVersion) { + stableVersion = this.subscriptionVersion; + result = await this.connectWithCurrentSet(); + if (this.isClosed || this.subscriptionVersion === stableVersion) { + break; + } } + return result; + } - const id = `resub-${++this.requestCounter}`; - const frame = frameRequest(id, 'events.subscribe', { subscriptions }); + /** + * Tear down the current socket (no error path) and connect with the full + * current tracked pane set — exactly one request on the fresh connection. + */ + private async connectWithCurrentSet(): Promise<EventResult> { + if (this.isClosed) { + return { type: 'error', error: 'Subscriber is closed' }; + } - return new Promise<EventResult>((resolve, reject) => { - const callback: PendingCallback = { - resolve: () => { - resolve({ type: 'subscribed', subscriptions: this.getSubscriptionCount() }); - }, - reject: (err) => { - resolve({ type: 'error', error: err.message }); - }, - }; + // Deliberate re-establishment: not a failure, so start from a clean + // backoff slate (a later genuine close reconnects at the base delay). + this.reconnectAttempts = 0; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } - this.pendingCallbacks.set(id, callback); + this.isConnecting = true; + if (this.socket) { + // Drop the old socket without triggering the error/reconnect path. + this.socket.removeAllListeners(); + this.socket.destroy(); + this.socket = null; + } - const timeout = setTimeout(() => { - this.pendingCallbacks.delete(id); - resolve({ type: 'error', error: 'Resubscribe timed out' }); - }, this.timeoutMs); + const result = await this.doConnect(); + this.isConnecting = false; + if (result.type === 'subscribed') { + this.markSubscribed(); + } else { + this.currentHealth = { + status: 'unavailable', + reason: result.type === 'error' ? result.error : result.reason, + }; + } + return result; + } - this.socket!.write(frame + '\n', () => { - // Write callback — if write fails, the error handler will catch it - }); - }); + /** + * Record a successful subscription: reset backoff, update health, and + * notify callers when the stream was (re-)established after a prior loss + * or restart (never for the very first subscribe — callers await + * `connect()` for that). + */ + private markSubscribed(): void { + const wasActive = this.everActive; + this.everActive = true; + this.reconnectAttempts = 0; + this.currentHealth = { status: 'active', paneCount: this.trackedPaneIds.length }; + if (wasActive) { + this.callbacks.onReconnected?.(); + } } /** diff --git a/packages/herdr/src/fetcher-blockers.test.ts b/packages/herdr/src/fetcher-blockers.test.ts new file mode 100644 index 000000000..82b653e5f --- /dev/null +++ b/packages/herdr/src/fetcher-blockers.test.ts @@ -0,0 +1,192 @@ +/** + * packages/herdr/src/fetcher-blockers.test.ts — Active dependency blockers + * for the Herdr metadata panel (WL-0MV8EZRDJ001TDA8). + * + * `fetchBlockersForItem(id)` runs `wl dep list <id> --json`, keeps only the + * outbound `depends-on` edges that still block via the shared + * `isActiveBlocker` predicate (`completed`/`deleted` status, or stage + * `in_review`/`done`), and returns `{ id, title, status? }` rows — the + * edge's `status` is passed through so the metadata panel can render it + * (WL-0MVBMLY2Z0032CYR). It is fail-open: a failed/unparseable lookup + * yields `[]` and never throws. + * + * Stages are not part of a `wl dep list` edge, so each non-terminal edge is + * enriched via `wl show` before classification (mirroring the hydrator's + * `listOutboundDeps`). + * + * Run: npx vitest run packages/herdr/src/fetcher-blockers.test.ts + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + fetchBlockersForItem, + setExecFileAsync, + resetExecFileAsync, + resetWorklogDir, +} from './fetcher.js'; + +/** Spawn-recorded `wl` invocations, reset per test. */ +let spawns: string[]; + +/** + * Install an exec mock: `wl dep list <id> --json` returns the supplied + * outbound edges; `wl show <id> --json` returns the supplied stage for that + * id (or an empty workItem when the id is not in `stages`). + */ +function installExecMock( + outbound: unknown[], + stages: Record<string, string> = {}, +): void { + spawns = []; + setExecFileAsync((async (_bin: string, args: string[]) => { + spawns.push(args.join(' ')); + if (args.includes('dep')) { + return { + stdout: JSON.stringify({ success: true, item: 'WL-X', inbound: [], outbound }), + stderr: '', + }; + } + if (args.includes('show')) { + const id = args[1]; + const stage = stages[id]; + return { + stdout: JSON.stringify( + stage !== undefined + ? { success: true, workItem: { id, title: `Item ${id}`, stage } } + : { success: true, count: 0, workItems: [] }, + ), + stderr: '', + }; + } + return { stdout: JSON.stringify({ success: true }), stderr: '' }; + }) as never); +} + +beforeEach(() => { + resetExecFileAsync(); + resetWorklogDir(); +}); + +afterEach(() => { + resetExecFileAsync(); + resetWorklogDir(); +}); + +describe('fetchBlockersForItem — active blockers (WL-0MV8EZRDJ001TDA8)', () => { + it('returns one { id, title, status } per active outbound depends-on edge', async () => { + installExecMock([ + { id: 'WL-B1', title: 'First blocker', status: 'open', direction: 'depends-on' }, + { id: 'WL-B2', title: 'Second blocker', status: 'in-progress', direction: 'depends-on' }, + ]); + + const blockers = await fetchBlockersForItem('WL-X'); + + expect(blockers).toEqual([ + { id: 'WL-B1', title: 'First blocker', status: 'open' }, + { id: 'WL-B2', title: 'Second blocker', status: 'in-progress' }, + ]); + }); + + it('passes the edge status through to the blocker ref (no extra lookups)', async () => { + installExecMock([ + { id: 'WL-B1', title: 'First blocker', status: 'blocked', direction: 'depends-on' }, + ]); + + const blockers = await fetchBlockersForItem('WL-X'); + + expect(blockers).toEqual([{ id: 'WL-B1', title: 'First blocker', status: 'blocked' }]); + // The stage enrichment still ran (one wl show), but the status came from + // the `dep list` edge itself — no per-blocker `wl show` for status. + const depSpawns = spawns.filter((s) => s.startsWith('dep list')); + expect(depSpawns).toHaveLength(1); + }); + + it('omits the status field when the edge carries none', async () => { + installExecMock([ + { id: 'WL-B1', title: 'No status ref', direction: 'depends-on' }, + ]); + + const blockers = await fetchBlockersForItem('WL-X'); + + expect(blockers).toEqual([{ id: 'WL-B1', title: 'No status ref' }]); + }); + + it('queries wl dep list for the requested item', async () => { + installExecMock([ + { id: 'WL-B1', title: 'First blocker', status: 'open', direction: 'depends-on' }, + ]); + + await fetchBlockersForItem('WL-X'); + + expect(spawns.some((s) => s.startsWith('dep list WL-X'))).toBe(true); + }); + + it('filters out terminal-by-status edges (completed, deleted)', async () => { + installExecMock([ + { id: 'WL-DONE', title: 'Done', status: 'completed', direction: 'depends-on' }, + { id: 'WL-DEL', title: 'Deleted', status: 'deleted', direction: 'depends-on' }, + { id: 'WL-OPEN', title: 'Still blocking', status: 'blocked', direction: 'depends-on' }, + ]); + + const blockers = await fetchBlockersForItem('WL-X'); + + expect(blockers).toEqual([{ id: 'WL-OPEN', title: 'Still blocking', status: 'blocked' }]); + }); + + it('filters out non-terminal edges whose target is at a terminal stage', async () => { + installExecMock( + [ + { id: 'WL-REVIEW', title: 'In review', status: 'in-progress', direction: 'depends-on' }, + { id: 'WL-DONESTAGE', title: 'Done stage', status: 'open', direction: 'depends-on' }, + { id: 'WL-ACTIVE', title: 'Active', status: 'in-progress', direction: 'depends-on' }, + ], + { 'WL-REVIEW': 'in_review', 'WL-DONESTAGE': 'done', 'WL-ACTIVE': 'plan_complete' }, + ); + + const blockers = await fetchBlockersForItem('WL-X'); + + expect(blockers).toEqual([{ id: 'WL-ACTIVE', title: 'Active', status: 'in-progress' }]); + }); + + it('ignores inbound (depended-on-by) edges', async () => { + installExecMock([ + { id: 'WL-UPSTREAM', title: 'Depends on us', status: 'open', direction: 'depended-on-by' }, + { id: 'WL-DOWN', title: 'Blocks us', status: 'open', direction: 'depends-on' }, + ]); + + const blockers = await fetchBlockersForItem('WL-X'); + + expect(blockers).toEqual([{ id: 'WL-DOWN', title: 'Blocks us', status: 'open' }]); + }); + + it('returns an empty array when there are no outbound edges', async () => { + installExecMock([]); + + await expect(fetchBlockersForItem('WL-X')).resolves.toEqual([]); + }); +}); + +describe('fetchBlockersForItem — fail-open (WL-0MV8EZRDJ001TDA8)', () => { + it('returns [] when wl dep list rejects', async () => { + setExecFileAsync((async () => { + throw new Error('wl exploded'); + }) as never); + + await expect(fetchBlockersForItem('WL-X')).resolves.toEqual([]); + }); + + it('returns [] when wl dep list output is unparseable', async () => { + setExecFileAsync((async () => ({ stdout: 'not json at all', stderr: '' })) as never); + + await expect(fetchBlockersForItem('WL-X')).resolves.toEqual([]); + }); + + it('returns [] when the payload has no outbound array', async () => { + setExecFileAsync((async () => ({ + stdout: JSON.stringify({ success: true, inbound: [] }), + stderr: '', + })) as never); + + await expect(fetchBlockersForItem('WL-X')).resolves.toEqual([]); + }); +}); diff --git a/packages/herdr/src/fetcher.ts b/packages/herdr/src/fetcher.ts index 7a8d54171..e2c3bca33 100644 --- a/packages/herdr/src/fetcher.ts +++ b/packages/herdr/src/fetcher.ts @@ -16,6 +16,8 @@ import { regroupWorkItems } from './grouping.js'; import { STAGES } from './worklist.js'; import type { Stage } from './worklist.js'; import type { AgentState } from './agent-tracker.js'; +import { isActiveBlocker } from './hydrator.js'; +import { parseDepListBlockersOutput } from './downtime-worker.js'; import { isAuditFresh, type ParentAuditState } from '@worklog/shared/icons'; // ── Types ──────────────────────────────────────────────────────────── @@ -170,6 +172,18 @@ export interface WorkItem { group?: number; groupLabel?: string; needsProducerReview?: boolean; + /** + * Soft-delete attribution (`wl delete`): a non-empty value marks the item + * as deleted and excludes it from the downtime dispatcher + * (SA-0MUJBLZC60085DI9). + */ + deletedBy?: string; + /** + * Current assignee (empty/absent = unassigned). A non-empty assignee is a + * scheduling hold (WL-0MV85WCJJ0087VMP): the item is held by a + * human/agent and is excluded from downtime dispatch at every tier. + */ + assignee?: string; auditResult?: boolean | null; auditedAt?: string | null; /** Stored content fingerprint from the audit result (WL-0MUBVH5S0008NQ9K). */ @@ -298,6 +312,8 @@ function normalizeItem(raw: any): WorkItem { group: raw?.group !== undefined ? Number(raw.group) : undefined, groupLabel: raw?.groupLabel ? String(raw.groupLabel) : undefined, needsProducerReview: raw?.needsProducerReview !== undefined ? Boolean(raw.needsProducerReview) : undefined, + deletedBy: raw?.deletedBy ? String(raw.deletedBy) : undefined, + assignee: raw?.assignee ? String(raw.assignee) : undefined, auditResult: raw?.auditResult !== undefined ? raw.auditResult : null, auditedAt: raw?.auditedAt == null ? (raw?.auditedAt as null | undefined) : String(raw.auditedAt), fingerprint: raw?.fingerprint == null ? (raw?.fingerprint as null | undefined) : String(raw.fingerprint), @@ -923,6 +939,66 @@ export async function fetchItemDetails(id: string): Promise<WorkItem | null> { } } +/** + * A dependency blocker of a work item, as shown in the metadata panel + * (WL-0MV8EZRDJ001TDA8). Display-only: the id and title are all the + * `Blocked By` rows need; when the `wl dep list` edge carries a `status` + * it is passed through so the row can render the blocker's state + * (icon + text, WL-0MVBMLY2Z0032CYR). The status is optional — an edge + * without one still renders the ID + title alone. + */ +export interface BlockerRef { + id: string; + title: string; + /** The blocker's status as reported by the `wl dep list` edge (absent when the edge has none). */ + status?: string; +} + +/** + * Fetch the ACTIVE dependency blockers of a work item — the outbound + * `depends-on` edges that still block it (WL-0MV8EZRDJ001TDA8). + * + * `wl dep list <id> --json` edges carry only id/title/status/priority, so + * each edge that is not already terminal by status is enriched with its + * target's `stage` via `wl show` (mirroring the hydrator's + * `listOutboundDeps`) before the shared {@link isActiveBlocker} predicate + * decides whether it still blocks: `completed`/`deleted` targets and + * targets at stage `in_review`/`done` are NOT blockers. A failed stage + * lookup leaves the edge's stage undefined and it is classified by status + * alone. + * + * Fail-open (AC): a failed/unparseable `wl dep list` (or any unexpected + * error) yields an empty array rather than throwing — the panel then + * simply omits its `Blocked By` rows. + */ +export async function fetchBlockersForItem(id: string): Promise<BlockerRef[]> { + try { + const output = await runWl(['dep', 'list', id]); + const edges = parseDepListBlockersOutput(output); + if (edges === null) return []; + + const blockers: BlockerRef[] = []; + for (const edge of edges) { + const status = (edge.status ?? '').toLowerCase().trim(); + const stage = + status === 'completed' || status === 'deleted' + ? undefined + : (await fetchItemDetails(edge.id))?.stage; + if (!isActiveBlocker({ id: edge.id, status: edge.status, stage })) continue; + // The edge's `status` is already on the `dep list` edge — pass it + // through (no per-blocker lookup): the panel renders it as an icon + + // text suffix on the `Blocked By` row (WL-0MVBMLY2Z0032CYR). A + // status-less edge yields a ref without the field. + const edgeStatus = edge.status; + blockers.push({ id: edge.id, title: edge.title, ...(edgeStatus ? { status: edgeStatus } : {}) }); + } + return blockers; + } catch { + // Fail-open: no blocker rows on any lookup failure. + return []; + } +} + /** * Fetch the total count of actionable work items. */ @@ -1172,18 +1248,25 @@ export interface ClaimResult { const CLAIM_TIMEOUT_MS = 3000; /** - * Claim a work item before dispatching an agent command: set its status - * to `in_progress` with the given assignee, matching the claim pattern - * documented in AGENTS.md. Targets the configured worklog database via + * Claim a work item before dispatching an agent command. For every tier + * except audit the claim sets its status to `in_progress` with the given + * assignee, matching the claim pattern documented in AGENTS.md. For the + * audit tier (`expected` = `completed`/`in_review`) the claim is + * STATUS-NEUTRAL: it writes only the assignee, guarded by + * `--if-status completed --if-stage in_review --if-assignee ""`, so an + * audited item never enters the `in_progress`/`in_review` hybrid + * (WL-0MV85WCJJ0087VMP). Targets the configured worklog database via * `--worklog-dir` when set. * * With `expected` (status/stage), the claim is a compare-and-swap (RCA * WL-0MSRBFFLN005W3VT design point 1): `--if-status`/`--if-stage` are * passed to `wl update`, so the transition only applies while the item is - * still in the state the tier selected it in. Exactly one concurrent pane - * wins; a loser gets `{success:false, stale:true}` and the caller aborts - * the dispatch. A stale result is detected from the wl CLI's `"error": - * "stale"` JSON payload on stderr. + * still in the state the tier selected it in. The audit tier additionally + * CASes on the empty assignee (`--if-assignee ""`), so assigning an item + * makes the claim fail stale. Exactly one concurrent pane wins; a loser + * gets `{success:false, stale:true}` and the caller aborts the dispatch. A + * stale result is detected from the wl CLI's `"error": "stale"` JSON + * payload on stderr. * * `worklogRoot` (optional, WL-0MTQ14W7L003II5A) targets the update at a * specific worklog root's database via per-call `--worklog-dir` — the @@ -1202,23 +1285,48 @@ export async function claimWorkItem( migrateStage?: string, ): Promise<ClaimResult> { try { - const args = ['update', id, '--status', 'in_progress', '--assignee', assignee]; - if (expected?.status) { - args.push('--if-status', expected.status); - } - if (expected?.stage) { - args.push('--if-stage', expected.stage); - } - // Retired-stage recovery (WL-0MTYL7DX9000MZOH): an item stuck on a stage - // that is no longer valid (e.g. the removed `in_progress` stage) cannot - // satisfy the tier's normalised stage CAS. In that case the caller passes - // the tier's target stage as an atomic MIGRATION — the CAS guard still - // matches the item's ACTUAL (retired) stage for race safety, while - // `--stage` advances the stored value to a valid stage in the same write. - // Without this, the claim is rejected by the status/stage validator and - // counted as a hard wl-error strike. - if (migrateStage !== undefined) { - args.push('--stage', migrateStage); + // Status-neutral audit-tier claim (WL-0MV85WCJJ0087VMP): an audited + // `completed`/`in_review` item must never enter the `in_progress`/ + // `in_review` hybrid the stranding autocracies were built on. The claim + // therefore writes ONLY the assignee — no `--status`/`--stage` — guarded + // by the tier's status/stage expectation AND the empty-assignee CAS. A + // failed audit restores by clearing the assignee, so the item never + // leaves the review queue. Every other tier keeps the status-flip claim. + const isAuditTier = expected?.status === 'completed' && expected?.stage === 'in_review'; + const args = isAuditTier + ? [ + 'update', + id, + '--assignee', + assignee, + '--if-status', + expected.status as string, + '--if-stage', + expected.stage as string, + // `""` is a meaningful expected value: only an UNASSIGNED item may + // be claimed, so a concurrent/human assignee makes the claim stale. + '--if-assignee', + '', + ] + : ['update', id, '--status', 'in_progress', '--assignee', assignee]; + if (!isAuditTier) { + if (expected?.status) { + args.push('--if-status', expected.status); + } + if (expected?.stage) { + args.push('--if-stage', expected.stage); + } + // Retired-stage recovery (WL-0MTYL7DX9000MZOH): an item stuck on a stage + // that is no longer valid (e.g. the removed `in_progress` stage) cannot + // satisfy the tier's normalised stage CAS. In that case the caller passes + // the tier's target stage as an atomic MIGRATION — the CAS guard still + // matches the item's ACTUAL (retired) stage for race safety, while + // `--stage` advances the stored value to a valid stage in the same write. + // Without this, the claim is rejected by the status/stage validator and + // counted as a hard wl-error strike. + if (migrateStage !== undefined) { + args.push('--stage', migrateStage); + } } // Per-call --worklog-dir targeting (WL-0MTQ14W7L003II5A): the downtime // leader claims coordination offers in the OFFER's own worklog root, @@ -1258,34 +1366,59 @@ export async function claimWorkItem( * * `original.status` defaults to `open` (the plan/intake/implement/risk-effort * tiers); the audit tier passes `completed` so a failed audit dispatch - * returns to the audit queue rather than the open backlog. `worklogRoot` - * targets that root's database via per-call `--worklog-dir` (the same - * cross-root convention as `claimWorkItem`). + * returns to the audit queue rather than the open backlog. When + * `original.assignee` is a non-empty string (the audit tier, + * WL-0MV85WCJJ0087VMP) the rollback is STATUS-NEUTRAL: it clears only the + * claiming agent's assignee, guarded by `--if-assignee <agent>`, and never + * rewrites status/stage. `worklogRoot` targets that root's database via + * per-call `--worklog-dir` (the same cross-root convention as + * `claimWorkItem`). * * Never throws — a failure resolves `false` so the caller can fall back to * the fail-closed abort outcome. */ export async function rollbackClaimWorkItem( id: string, - original?: { status?: string; stage?: string }, + original?: { status?: string; stage?: string; assignee?: string }, worklogRoot?: string, ): Promise<boolean> { try { - const args = [ - 'update', - id, - '--status', - original?.status ?? 'open', - '--if-status', - 'in_progress', - ]; - // Restore the stage the tier claimed at. The claim itself does not change - // the stage (except the retired-stage migration, which advances it to the - // tier's target); passing the tier's expectation is therefore a no-op in - // the migration path and the correct restore everywhere else. - if (original?.stage) { - args.push('--if-stage', original.stage); - args.push('--stage', original.stage); + // The audit tier's claim is STATUS-NEUTRAL (WL-0MV85WCJJ0087VMP): it + // writes only the assignee and never flips status/stage, so its rollback + // must likewise not rewrite them — it clears only the claiming agent's + // assignee, guarded by `--if-assignee <agent>` so a concurrent assignee + // change makes the rollback stale (no clobber). Every OTHER tier keeps + // the legacy status/stage restore but ALSO clears THIS claim's assignee + // (guarded by `--if-assignee <agent>`) so a failed dispatch never leaves a + // non-empty assignee behind. A non-empty assignee is a scheduling hold + // (WL-0MV85WDOM007BKF8): residue would make the item permanently + // undispatchable after a marker-write/spawn failure + // (WL-0MV85WD400091OZ6). When the claiming agent is unknown the legacy + // restore is applied as before (fail-safe: no stricter claim than the + // original). + const isAuditTier = + original?.status === 'completed' && original?.stage === 'in_review'; + const claimedAgent = original?.assignee; + const hasClaimedAgent = typeof claimedAgent === 'string' && claimedAgent.length > 0; + let args: string[]; + if (isAuditTier && hasClaimedAgent) { + // Status-neutral, assignee-guarded clear (Option A). + args = ['update', id, '--assignee', '', '--if-assignee', claimedAgent as string]; + } else if (isAuditTier) { + // No claimed agent known: best-effort restore of the review-queue state + // (never the plan_complete coercion that stranded items). + args = ['update', id, '--status', 'completed', '--stage', original?.stage ?? 'in_review']; + } else { + // Legacy status/stage restore + clear THIS claim's assignee (hold). + args = ['update', id, '--status', original?.status ?? 'open', '--if-status', 'in_progress']; + if (original?.stage) { + args.push('--if-stage', original.stage); + args.push('--stage', original.stage); + } + if (hasClaimedAgent) { + args.push('--assignee', ''); + args.push('--if-assignee', claimedAgent as string); + } } const dirOverride = worklogRoot !== undefined ? join(worklogRoot, '.worklog') : undefined; await runWl(args, true, CLAIM_TIMEOUT_MS, dirOverride); diff --git a/packages/herdr/src/hydrator-responsiveness.test.ts b/packages/herdr/src/hydrator-responsiveness.test.ts index f6bcdb0a9..c377663c1 100644 --- a/packages/herdr/src/hydrator-responsiveness.test.ts +++ b/packages/herdr/src/hydrator-responsiveness.test.ts @@ -7,7 +7,7 @@ * spawns with no per-step bound and no per-tick cap, so a slow or hung CLI * could leave the run in flight for the whole 20 s scheduler watchdog while * the HERDR pane felt unresponsive to keyboard input. This suite drives a - * worst-case hydrate (60 tabs, 25 in-progress items) with deliberately slow + * worst-case hydrate (60 panes, 25 in-progress items) with deliberately slow * fake seams and samples event-loop delay — the deterministic proxy for * command-input lag — while the run is in flight. * @@ -24,9 +24,9 @@ import { runHydrationOnce, type HydratorDeps, type HydratorItem, - type HydratorTab, + type HydratorPane, } from './hydrator.js'; -import { makeWorstCaseItems, makeWorstCaseTabs } from './hydrator.fixtures.js'; +import { makeWorstCaseItems, makeWorstCasePanes } from './hydrator.fixtures.js'; import { stateCounterFilePath, watchDbChanges, @@ -48,7 +48,7 @@ function percentile(samples: number[], p: number): number { describe('hydrate UI responsiveness (WL-0MUY1CZES0099A04)', () => { it('keeps event-loop delay under 100 ms p95 during a worst-case hydrate', async () => { - const tabs = makeWorstCaseTabs(60); + const panes = makeWorstCasePanes(60); const items = makeWorstCaseItems(25); const deps: HydratorDeps = { // Deliberately slow seams: each CLI-shaped lookup yields the event @@ -57,9 +57,9 @@ describe('hydrate UI responsiveness (WL-0MUY1CZES0099A04)', () => { await delay(4); return items; }, - listTabs: async () => { + listActivePanes: async () => { await delay(4); - return tabs; + return panes; }, listOutboundDeps: async () => { await delay(4); @@ -101,7 +101,7 @@ describe('hydrate UI responsiveness (WL-0MUY1CZES0099A04)', () => { // wall-clock bound well under the 20 s scheduler watchdog. const deps: HydratorDeps = { listInProgressItems: () => new Promise<HydratorItem[]>(() => {}), - listTabs: async () => [], + listActivePanes: async () => [], listOutboundDeps: async () => [], applyDemotion: async () => true, stepTimeoutMs: 50, @@ -124,7 +124,7 @@ describe('hydrate UI responsiveness (WL-0MUY1CZES0099A04)', () => { const touched: string[] = []; const deps: HydratorDeps = { listInProgressItems: async () => items, - listTabs: async () => [] as HydratorTab[], + listActivePanes: async () => [] as HydratorPane[], listOutboundDeps: async (id: string) => { touched.push(id); return []; diff --git a/packages/herdr/src/hydrator.fixtures.ts b/packages/herdr/src/hydrator.fixtures.ts index 3155477d9..848c9e94b 100644 --- a/packages/herdr/src/hydrator.fixtures.ts +++ b/packages/herdr/src/hydrator.fixtures.ts @@ -9,15 +9,15 @@ * hydrator CLI shape with minimal valid JSON and records the exact * `(bin, args, options)` of every spawn, so tests can assert the stdio * and argument contract. - * • `makeWorstCaseTabs()` / `makeWorstCaseItems()` build the load profile - * the fix must survive (50+ tabs, 20+ in-progress items). + * • `makeWorstCasePanes()` / `makeWorstCaseItems()` build the load profile + * the fix must survive (50+ panes, 20+ in-progress items). * * No `vitest` import: these are plain builders consumed by the test file (and * by the implementation children's tests), so they stay usable outside a * vitest runtime. */ -import type { HydratorItem, HydratorTab } from './hydrator.js'; +import type { HydratorItem, HydratorPane } from './hydrator.js'; // ── Recording exec seam ─────────────────────────────────────────────── @@ -44,8 +44,8 @@ export interface HydratorExecHarness { /** * Build a recording exec seam that answers the four hydrator CLI shapes with - * minimal valid JSON and records every call. The tab-listing shape answers - * `herdr tab list` (the hydrator's active-detection input). + * minimal valid JSON and records every call. The pane-listing shape answers + * `herdr pane list` (the hydrator's active-detection input). */ export function hydratorExecRouter(): HydratorExecHarness { const calls: RecordedSpawn[] = []; @@ -57,8 +57,8 @@ export function hydratorExecRouter(): HydratorExecHarness { options: (options ?? {}) as Record<string, unknown>, }); - if (bin === 'herdr' && args.includes('tab') && args.includes('list')) { - return { stdout: JSON.stringify({ result: { tabs: [] } }), stderr: '' }; + if (bin === 'herdr' && args.includes('pane') && args.includes('list')) { + return { stdout: JSON.stringify({ result: { panes: [] } }), stderr: '' }; } if (args.includes('dep')) { return { stdout: JSON.stringify({ outbound: [] }), stderr: '' }; @@ -83,20 +83,21 @@ function padded(n: number, width: number): string { } /** - * Worst-case tab volume: `count` tabs (default 60) split across the current - * workspace (`wCurrent`, even indices) and another workspace, each labelled - * with an exact work-item ID as a real per-item tab would be. + * Worst-case pane volume: `count` panes (default 60) split across the current + * workspace (`wCurrent`, even indices) and another workspace, each titled + * with a work-item ID (a real dispatched pane title carries the ID as a + * preserved suffix, so the fixture embeds the ID in the label). */ -export function makeWorstCaseTabs(count = 60): HydratorTab[] { +export function makeWorstCasePanes(count = 60): HydratorPane[] { return Array.from({ length: count }, (_, i) => ({ - tab_id: `tab-${i}`, - label: `WL-TAB${padded(i, 7)}`, + pane_id: `pane-${i}`, + label: `Downtime triggered implement fixture item - WL-PANE${padded(i, 7)}`, workspace_id: i % 2 === 0 ? 'wCurrent' : 'wOther', })); } /** - * Worst-case in-progress volume: `count` (default 25) tab-less candidates, + * Worst-case in-progress volume: `count` (default 25) pane-less candidates, * all at `plan_complete` so every one is eligible for demotion. */ export function makeWorstCaseItems(count = 25): HydratorItem[] { diff --git a/packages/herdr/src/hydrator.test.ts b/packages/herdr/src/hydrator.test.ts index 928a0fe2f..cfc6c29f9 100644 --- a/packages/herdr/src/hydrator.test.ts +++ b/packages/herdr/src/hydrator.test.ts @@ -12,12 +12,12 @@ import { resetExecFileAsync, setExecFileAsync } from './fetcher.js'; import { hydratorExecRouter, makeWorstCaseItems, - makeWorstCaseTabs, + makeWorstCasePanes, } from './hydrator.fixtures.js'; import { ALLOWED_STAGES_BY_STATUS, - applyActiveTabMarkers, - collectTabWorkItemIds, + applyActivePaneMarkers, + collectPaneWorkItemIds, compatibleStage, createHydratorFileLog, createHydratorRunner, @@ -27,14 +27,13 @@ import { extractWorkItemIdsFromText, isActiveBlocker, isActivePaneMatch, - hasItemTab, - markItemsActiveByTab, + markItemsActiveByPane, runHydrationOnce, type HydrationResult, type HydratorDepTarget, type HydratorDeps, type HydratorItem, - type HydratorTab, + type HydratorPane, } from './hydrator.js'; import { mkdtempSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -71,7 +70,7 @@ function makeDeps(overrides: Partial<ContractDeps> = {}): { const applied: AppliedDemotion[] = []; const deps: ContractDeps = { listInProgressItems: async () => [], - listTabs: async () => [], + listActivePanes: async () => [], listOutboundDeps: async () => [], applyDemotion: async (id, status, stage) => { applied.push({ id, status, stage }); @@ -112,66 +111,101 @@ describe('extractWorkItemIdsFromText', () => { }); }); -// ── Active-tab matching ─────────────────────────────────────────────── +// ── Active-pane matching (WL-0MV1BWETT005GSUJ) ─────────────────────── -describe('active-tab matching', () => { - const tab = (label: string, workspace_id?: string): HydratorTab => ({ - tab_id: `t-${label.length}-${workspace_id ?? 'x'}`, +describe('active-pane matching', () => { + const pane = (label: string, workspace_id?: string): HydratorPane => ({ + pane_id: `p-${label.length}-${workspace_id ?? 'x'}`, label, workspace_id, }); - it('matches an item whose ID is the exact label of a tab', () => { - const tabs = [tab('Work Items'), tab(WL)]; - expect(hasItemTab(WL, tabs)).toBe(true); - expect(collectTabWorkItemIds(tabs).has(WL)).toBe(true); + it('matches an item whose ID appears in a pane title', () => { + const panes = [pane('Work Items'), pane(`Downtime triggered implement Foo - ${WL}`)]; + expect(isActivePaneMatch(WL, panes)).toBe(true); + expect(collectPaneWorkItemIds(panes).has(WL)).toBe(true); }); - it('rejects a tab label that merely contains the item ID (exact match only)', () => { - const tabs = [tab(`implement ${WL}`), tab(`plan ${WL} - extra`)]; - expect(hasItemTab(WL, tabs)).toBe(false); - expect(collectTabWorkItemIds(tabs).has(WL)).toBe(false); + it('matches when the pane title equals the item ID', () => { + const panes = [pane(WL)]; + expect(isActivePaneMatch(WL, panes)).toBe(true); }); - it('ignores tabs whose label is not a work-item ID', () => { - const tabs = [tab('Work Items'), tab('Downtime')]; - expect(collectTabWorkItemIds(tabs).size).toBe(0); - expect(hasItemTab(WL, tabs)).toBe(false); + it('ignores panes whose title carries no work-item ID', () => { + const panes = [pane('Work Items'), pane('Downtime')]; + expect(collectPaneWorkItemIds(panes).size).toBe(0); + expect(isActivePaneMatch(WL, panes)).toBe(false); }); - it('only considers tabs in the given workspace', () => { - const tabs = [tab(WL, 'wOther')]; - expect(hasItemTab(WL, tabs, 'wCurrent')).toBe(false); - expect(hasItemTab(WL, tabs, 'wOther')).toBe(true); + it('only considers panes in the given workspace', () => { + const panes = [pane(`Downtime - ${WL}`, 'wOther')]; + expect(isActivePaneMatch(WL, panes, 'wCurrent')).toBe(false); + expect(isActivePaneMatch(WL, panes, 'wOther')).toBe(true); }); - it('includes tabs with an unknown workspace when filtering (fail-open)', () => { - const tabs = [tab(WL)]; - expect(hasItemTab(WL, tabs, 'wCurrent')).toBe(true); + it('includes panes with an unknown workspace when filtering (fail-open)', () => { + const panes = [pane(`Downtime - ${WL}`)]; + expect(isActivePaneMatch(WL, panes, 'wCurrent')).toBe(true); }); }); -// ── Tab-based active marker for the selection list (WL-0MV1BWETT005GSUJ) ── +// ── Pane-based active marker for the selection list (WL-0MV1BWETT005GSUJ) ── -describe('tab-based active marker (selection list)', () => { +describe('pane-based active marker (selection list)', () => { const OTHER = 'WL-0MV24T2VJ007Q49J'; - it('marks an item with a matching tab as in-progress even when the worklog status is open', () => { + it('marks an item with a matching pane title as in-progress even when the worklog status is open', () => { const items = [{ id: WL, status: 'open', stage: 'plan_complete' }]; - markItemsActiveByTab(items, new Set([WL])); + markItemsActiveByPane(items, new Set([WL])); expect(items[0].status).toBe('in-progress'); }); - it('leaves an item without a matching tab unchanged', () => { + it('stamps the green-circle agentState when a matching pane exists and clears it otherwise', () => { + const items = [ + { id: WL, status: 'open', stage: 'plan_complete' }, + { id: OTHER, status: 'open', stage: 'plan_complete' }, + ]; + markItemsActiveByPane(items, new Set([WL])); + // Matching pane → green circle (agentState 'working'). + expect(items[0].agentState).toBe('working'); + // No matching pane → no green circle. + expect(items[1].agentState).toBeUndefined(); + }); + + it('clears a stale green circle when the pane closes and restores it when it reopens (producer rule)', () => { + const items = [{ id: WL, status: 'open', stage: 'plan_complete', agentState: 'working' }]; + // Pane closed: a matching pane no longer exists → the green circle clears + // even though a stale agentState was previously stamped. + markItemsActiveByPane(items, new Set()); + expect(items[0].agentState).toBeUndefined(); + // Pane reopened → green circle returns. + markItemsActiveByPane(items, new Set([WL])); + expect(items[0].agentState).toBe('working'); + }); + + it('stamps the green circle regardless of the item stage (open pane = worked on)', () => { + // Terminal in_review / completed items keep their review STATUS marker, + // but an OPEN pane still means the item is being worked → green circle. + const items = [ + { id: WL, status: 'completed', stage: 'in_review' }, + { id: OTHER, status: 'completed', stage: 'done' }, + ]; + markItemsActiveByPane(items, new Set([WL])); // only WL has a matching pane + expect(items[0].status).toBe('completed'); // review marker untouched + expect(items[0].agentState).toBe('working'); // but green circle present + expect(items[1].agentState).toBeUndefined(); // no matching pane in this workspace + }); + + it('leaves an item without a matching pane unchanged', () => { const items = [{ id: WL, status: 'open', stage: 'plan_complete' }]; - markItemsActiveByTab(items, new Set([OTHER])); + markItemsActiveByPane(items, new Set([OTHER])); expect(items[0].status).toBe('open'); }); it('marks matching children recursively', () => { const child = { id: WL, status: 'open', stage: 'plan_complete' }; const parent = { id: 'WL-PARENT000000', status: 'open', stage: 'idea', children: [child] }; - markItemsActiveByTab([parent], new Set([WL])); + markItemsActiveByPane([parent], new Set([WL])); expect(parent.status).toBe('open'); expect(child.status).toBe('in-progress'); }); @@ -181,42 +215,44 @@ describe('tab-based active marker (selection list)', () => { { id: WL, status: 'open', stage: 'in_review' }, { id: OTHER, status: 'completed', stage: 'done' }, ]; - markItemsActiveByTab(items, new Set([WL, OTHER])); + markItemsActiveByPane(items, new Set([WL, OTHER])); expect(items[0].status).toBe('open'); expect(items[1].status).toBe('completed'); }); - it('is idempotent: a still-open tab keeps the marker and a closed tab clears it', () => { + it('is idempotent: a still-open pane keeps the marker and a closed pane clears it', () => { const items = [{ id: WL, status: 'open', stage: 'plan_complete' }]; - markItemsActiveByTab(items, new Set([WL])); + markItemsActiveByPane(items, new Set([WL])); expect(items[0].status).toBe('in-progress'); - // Tab still open — a repeated pass must not mistake the marker for the + // Pane still open — a repeated pass must not mistake the marker for the // true status and clobber the base bookkeeping. - markItemsActiveByTab(items, new Set([WL])); + markItemsActiveByPane(items, new Set([WL])); expect(items[0].status).toBe('in-progress'); - // Tab closed — the marker clears back to the true worklog status without + // Pane closed — the marker clears back to the true worklog status without // any `wl` re-fetch. - markItemsActiveByTab(items, new Set()); + markItemsActiveByPane(items, new Set()); expect(items[0].status).toBe('open'); - // Tab re-opened — the marker comes back. - markItemsActiveByTab(items, new Set([WL])); + // Pane re-opened — the marker comes back. + markItemsActiveByPane(items, new Set([WL])); expect(items[0].status).toBe('in-progress'); }); it('keeps the bookkeeping status out of the serialized item shape', () => { const items = [{ id: WL, status: 'open', stage: 'plan_complete' }]; - markItemsActiveByTab(items, new Set([WL])); - expect(Object.keys(items[0])).toEqual(['id', 'status', 'stage']); + markItemsActiveByPane(items, new Set([WL])); + // Only real fields (id/status/stage + the green-circle agentState) are + // enumerable; the base-status bookkeeping Symbol is not. + expect(Object.keys(items[0]).sort()).toEqual(['agentState', 'id', 'stage', 'status']); }); - it('applyActiveTabMarkers scopes the tab lookup to the current workspace', async () => { + it('applyActivePaneMarkers scopes the pane lookup to the current workspace', async () => { const items = [{ id: WL, status: 'open', stage: 'plan_complete' }]; const seen: Array<string | undefined> = []; - await applyActiveTabMarkers( + await applyActivePaneMarkers( items, async (workspaceId) => { seen.push(workspaceId); - return [{ tab_id: 't1', label: WL, workspace_id: 'wCurrent' }]; + return [{ pane_id: 'wC:p1', label: `Downtime triggered - ${WL}`, workspace_id: 'wCurrent' }]; }, 'wCurrent', ); @@ -224,19 +260,19 @@ describe('tab-based active marker (selection list)', () => { expect(items[0].status).toBe('in-progress'); }); - it('does not mark an item whose only matching tab is in another workspace', async () => { + it('does not mark an item whose only matching pane is in another workspace', async () => { const items = [{ id: WL, status: 'open', stage: 'plan_complete' }]; - await applyActiveTabMarkers( + await applyActivePaneMarkers( items, - async () => [{ tab_id: 't1', label: WL, workspace_id: 'wOther' }], + async () => [{ pane_id: 'wO:p1', label: `Downtime - ${WL}`, workspace_id: 'wOther' }], 'wCurrent', ); expect(items[0].status).toBe('open'); }); - it('fails open when the tab lookup throws or returns null (no marker cleared)', async () => { + it('fails open when the pane lookup throws or returns null (no marker cleared)', async () => { const thrown = [{ id: WL, status: 'open', stage: 'plan_complete' }]; - await applyActiveTabMarkers( + await applyActivePaneMarkers( thrown, async () => { throw new Error('herdr unavailable'); @@ -246,7 +282,7 @@ describe('tab-based active marker (selection list)', () => { expect(thrown[0].status).toBe('open'); const nulled = [{ id: WL, status: 'open', stage: 'plan_complete' }]; - await applyActiveTabMarkers(nulled, async () => null, 'wCurrent'); + await applyActivePaneMarkers(nulled, async () => null, 'wCurrent'); expect(nulled[0].status).toBe('open'); }); }); @@ -330,44 +366,40 @@ describe('decideDemotion', () => { }); }); -// ── Running-pane liveness (AC2 / F1) ────────────────────────────────── +// ── Pane-title active detection (AC2 / WL-0MV1BWETT005GSUJ) ────────── +// A pane in the current workspace whose title contains the item ID → active; +// otherwise not. No agent-liveness checks: pane-title existence is the signal. -describe('running-pane liveness (AC2)', () => { - const tab = (label: string, agent_status?: string, workspace_id?: string): HydratorTab => ({ - tab_id: `t-${label}-${agent_status ?? 'none'}`, +describe('pane-title active detection (AC2)', () => { + const pane = (label: string, agent_status?: string, workspace_id?: string): HydratorPane => ({ + pane_id: `p-${label.length}-${agent_status ?? 'none'}`, label, ...(workspace_id !== undefined ? { workspace_id } : {}), ...(agent_status !== undefined ? { agent_status } : {}), }); - it('isActivePaneMatch: matches an exact-label tab hosting a working agent', () => { - const panes = [tab('Work Items'), tab(WL, 'working')]; - expect(isActivePaneMatch(WL, panes)).toBe(true); - }); - - it('isActivePaneMatch: does NOT match a done (finished) pane (F1 repro)', () => { - const panes = [tab(WL, 'done')]; - expect(isActivePaneMatch(WL, panes)).toBe(false); - }); - - it('isActivePaneMatch: does NOT match an exited pane', () => { - const panes = [tab(WL, 'exited')]; - expect(isActivePaneMatch(WL, panes)).toBe(false); + it('isActivePaneMatch: matches a pane whose title carries the ID regardless of agent_status', () => { + // Pane-title existence is the signal — agent_status is irrelevant. + const titled = `Downtime triggered implement Foo - ${WL}`; + expect(isActivePaneMatch(WL, [pane(titled, 'working')])).toBe(true); + expect(isActivePaneMatch(WL, [pane(titled, 'done')])).toBe(true); + expect(isActivePaneMatch(WL, [pane(titled, 'exited')])).toBe(true); + expect(isActivePaneMatch(WL, [pane(titled)])).toBe(true); // absent status also counts }); - it('isActivePaneMatch: fail-open — an un-reported agent_status still counts as active', () => { - const panes = [tab(WL)]; - expect(isActivePaneMatch(WL, panes)).toBe(true); + it('isActivePaneMatch: matches a pane whose title IS exactly the item ID', () => { + expect(isActivePaneMatch(WL, [pane(WL)])).toBe(true); }); - it('isActivePaneMatch: exact label only (a containing label is not a match)', () => { - const panes = [tab(`implement ${WL}`, 'working')]; - expect(isActivePaneMatch(WL, panes)).toBe(false); + it('isActivePaneMatch: scopes to the current workspace', () => { + const titled = `Downtime - ${WL}`; + expect(isActivePaneMatch(WL, [pane(titled, 'working', 'wOther')], 'wCurrent')).toBe(false); + expect(isActivePaneMatch(WL, [pane(titled, 'working', 'wCurrent')], 'wCurrent')).toBe(true); }); - it('isActivePaneMatch: scopes to the current workspace', () => { - expect(isActivePaneMatch(WL, [tab(WL, 'working', 'wOther')], 'wCurrent')).toBe(false); - expect(isActivePaneMatch(WL, [tab(WL, 'working', 'wCurrent')], 'wCurrent')).toBe(true); + it('isActivePaneMatch: no match when the pane title carries no work-item ID', () => { + expect(isActivePaneMatch(WL, [pane('Work Items')])).toBe(false); + expect(isActivePaneMatch(WL, [pane('A general shell')])).toBe(false); }); }); @@ -376,14 +408,14 @@ describe('running-pane liveness (AC2)', () => { describe('extractWorkItemIdsFromPaneTitles (AC1)', () => { it('extracts every work-item ID found in pane labels', () => { const panes = [ - { tab_id: 't1', label: `implement ${WL} --focused` }, - { tab_id: 't2', label: 'Work Items' }, + { pane_id: 'p1', label: `implement ${WL} --focused` }, + { pane_id: 'p2', label: 'Work Items' }, ]; expect(extractWorkItemIdsFromPaneTitles(panes)).toEqual([WL]); }); it('returns an empty array when no pane label carries an ID', () => { - expect(extractWorkItemIdsFromPaneTitles([{ tab_id: 't1', label: 'Nothing here' }])).toEqual([]); + expect(extractWorkItemIdsFromPaneTitles([{ pane_id: 'p1', label: 'Nothing here' }])).toEqual([]); expect(extractWorkItemIdsFromPaneTitles([])).toEqual([]); }); }); @@ -391,42 +423,31 @@ describe('extractWorkItemIdsFromPaneTitles (AC1)', () => { // ── Orchestration ───────────────────────────────────────────────────── describe('runHydrationOnce', () => { - it('demotes a tab-less in_review item to completed', async () => { + it('demotes a pane-less in_review item to completed', async () => { const { deps, applied } = makeDeps({ listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'in_review' }], - listTabs: async () => [], + listActivePanes: async () => [], }); const result = await runHydrationOnce(deps); expect(result).toMatchObject({ ok: true, considered: 1, demoted: 1 }); expect(applied).toEqual([{ id: WL, status: 'completed', stage: 'in_review' }]); }); - it('demotes a tab-less plain item to open at intake_complete (AC3 else-branch)', async () => { + it('demotes a pane-less plain item to open at intake_complete (AC3 else-branch)', async () => { const { deps, applied } = makeDeps({ listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'plan_complete' }], - listTabs: async () => [], + listActivePanes: async () => [], }); await runHydrationOnce(deps); expect(applied).toEqual([{ id: WL, status: 'open', stage: 'intake_complete' }]); }); - it('demotes an item whose only matching tab hosts a done agent (F1)', async () => { - const { deps, applied } = makeDeps({ - listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'plan_complete' }], - listTabs: async () => [ - { tab_id: 't1', label: WL, agent_status: 'done', workspace_id: 'wCurrent' }, - ], - }); - const result = await runHydrationOnce(deps, 'wCurrent'); - expect(result.demoted).toBe(1); - expect(applied).toEqual([{ id: WL, status: 'open', stage: 'intake_complete' }]); - }); - - it('keeps an item whose matching tab hosts a working agent', async () => { + it('keeps an item whose pane title carries the item ID (agent_status irrelevant)', async () => { + // Pane-title existence is the sole signal — agent_status (done/working/absent) is irrelevant. const { deps, applied } = makeDeps({ listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'plan_complete' }], - listTabs: async () => [ - { tab_id: 't1', label: WL, agent_status: 'working', workspace_id: 'wCurrent' }, + listActivePanes: async () => [ + { pane_id: 'wC:p1', label: `Downtime triggered - ${WL}`, agent_status: 'done', workspace_id: 'wCurrent' }, ], }); const result = await runHydrationOnce(deps, 'wCurrent'); @@ -437,18 +458,18 @@ describe('runHydrationOnce', () => { it('demotes to blocked when an outbound dependency is active', async () => { const { deps, applied } = makeDeps({ listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'in_progress' }], - listTabs: async () => [], + listActivePanes: async () => [], listOutboundDeps: async () => [{ id: 'WL-OTHER', status: 'open', stage: 'plan_complete' }], }); await runHydrationOnce(deps); expect(applied).toEqual([{ id: WL, status: 'blocked', stage: 'plan_complete' }]); }); - it('does not query dependencies when a matching tab exists', async () => { + it('does not query dependencies when a matching pane exists', async () => { const listOutboundDeps = vi.fn(async () => []) as unknown as HydratorDeps['listOutboundDeps']; const { deps, applied } = makeDeps({ listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'plan_complete' }], - listTabs: async () => [{ tab_id: 't1', label: WL, workspace_id: 'w1' }], + listActivePanes: async () => [{ pane_id: 'w1:p1', label: `Downtime triggered - ${WL}`, workspace_id: 'w1' }], listOutboundDeps, }); const result = await runHydrationOnce(deps, 'w1'); @@ -457,26 +478,26 @@ describe('runHydrationOnce', () => { expect(listOutboundDeps).not.toHaveBeenCalled(); }); - it('leaves a tab-matched item untouched even when the dep list would block', async () => { + it('leaves a pane-matched item untouched even when the dep list would block', async () => { const { deps, applied } = makeDeps({ listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'in_review' }], - listTabs: async () => [{ tab_id: 't1', label: WL }], + listActivePanes: async () => [{ pane_id: 'p1', label: `Downtime triggered - ${WL}` }], listOutboundDeps: async () => [{ id: 'WL-OTHER', status: 'open' }], }); await runHydrationOnce(deps); expect(applied).toEqual([]); }); - it('fails open (no demotion) when the tab list is unavailable', async () => { + it('fails open (no demotion) when the pane list is unavailable', async () => { const { deps, applied } = makeDeps({ listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'plan_complete' }], - listTabs: async () => { + listActivePanes: async () => { throw new Error('herdr missing'); }, }); const result = await runHydrationOnce(deps); expect(result.ok).toBe(false); - expect(result.reason).toContain('tab list'); + expect(result.reason).toContain('pane list'); expect(applied).toEqual([]); }); @@ -485,7 +506,7 @@ describe('runHydrationOnce', () => { listInProgressItems: async () => { throw new Error('wl missing'); }, - listTabs: async () => [], + listActivePanes: async () => [], }); const result = await runHydrationOnce(deps); expect(result.ok).toBe(false); @@ -495,7 +516,7 @@ describe('runHydrationOnce', () => { it('counts a failed demotion as skipped, not demoted', async () => { const { deps } = makeDeps({ listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'plan_complete' }], - listTabs: async () => [], + listActivePanes: async () => [], applyDemotion: async () => false, }); const result = await runHydrationOnce(deps); @@ -505,7 +526,7 @@ describe('runHydrationOnce', () => { it('treats a throwing dependency lookup as not-blocked and still releases the item', async () => { const { deps, applied } = makeDeps({ listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'plan_complete' }], - listTabs: async () => [], + listActivePanes: async () => [], listOutboundDeps: async () => { throw new Error('dep lookup failed'); }, @@ -522,7 +543,7 @@ describe('runHydrationOnce', () => { { id: WL, status: 'in-progress', stage: 'in_review' }, { id: other, status: 'in-progress', stage: 'plan_complete' }, ], - listTabs: async () => [], + listActivePanes: async () => [], }); const result = await runHydrationOnce(deps); expect(result.demoted).toBe(2); @@ -549,7 +570,7 @@ describe('createHydratorRunner', () => { it('runs the hydration cycle when visible', async () => { const { deps, applied } = makeDeps({ listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'plan_complete' }], - listTabs: async () => [], + listActivePanes: async () => [], }); const run = createHydratorRunner(deps, { isVisible: async () => true }); const result = await run(); @@ -564,7 +585,7 @@ describe('createHydratorRunner', () => { ]); const { deps, applied } = makeDeps({ listInProgressItems, - listTabs: async () => [], + listActivePanes: async () => [], }); const run = createHydratorRunner(deps, { isVisible: async () => visible }); @@ -586,7 +607,7 @@ describe('createProductionHydratorDeps', () => { it('exposes the four injectable seams', () => { const deps = createProductionHydratorDeps(); expect(typeof deps.listInProgressItems).toBe('function'); - expect(typeof deps.listTabs).toBe('function'); + expect(typeof deps.listActivePanes).toBe('function'); expect(typeof deps.listOutboundDeps).toBe('function'); expect(typeof deps.applyDemotion).toBe('function'); }); @@ -602,30 +623,47 @@ describe('createProductionHydratorDeps', () => { // // Contract pinned here (implement to this): // • every hydrator CLI spawn passes `{ stdin: 'ignore', stderr: 'pipe' }`; -// • `runHydrationOnce` passes the workspace id to `listTabs`, which -// the production deps translate to `herdr tab list [--workspace <id>]`; +// • `runHydrationOnce` passes the workspace id to `listActivePanes`, which +// the production deps translate to `herdr pane list [--workspace <id>]`; // • each step runs under a per-step timeout (`HydratorDeps.stepTimeoutMs`, // default below the 20 s watchdog); a timeout aborts the run with // `ok: false` and a `reason` containing the step label; // • each step logs `... <label> ... elapsed <n>ms ...` measured with the // injected `HydratorDeps.now` clock; // • at most `HydratorDeps.maxItemsPerTick` candidates are processed per tick -// (default 50), each checked against its tab/dependency before demotion. -// Step labels: `in-progress-list`, `tab-list`, `dep-list`, `demotion-apply`. +// (default 50), each checked against its pane/dependency before demotion. +// Step labels: `in-progress-list`, `pane-list`, `dep-list`, `demotion-apply`. // ── Verification edge cases (WL-0MV24T2VJ007Q49J) ────────────────────── -describe('pane-title-only ID no longer counts (verification)', () => { - it('demotes an in-progress item whose ID appears only in a tab label that contains (not equals) the ID', async () => { +describe('pane-title matching (verification)', () => { + it('keeps an in-progress item whose ID appears in a pane title (producer rule)', async () => { + const { deps, applied } = makeDeps({ + listInProgressItems: async () => [ + { id: WL, status: 'in-progress', stage: 'plan_complete' }, + ], + // A dispatched pane is titled e.g. "Downtime triggered implement Foo - + // WL-0MSOJLZD9004P8PI" — the ID appears in the title, which is the + // active signal (WL-0MV1BWETT005GSUJ). + listActivePanes: async () => [ + { pane_id: 'wC:p1', label: `Downtime triggered implement Foo - ${WL}`, workspace_id: 'wCurrent' }, + ], + }); + + const result = await runHydrationOnce(deps, 'wCurrent'); + + expect(result.ok).toBe(true); + expect(result.demoted).toBe(0); + expect(applied).toEqual([]); + }); + + it('demotes when no pane title carries the ID', async () => { const { deps, applied } = makeDeps({ listInProgressItems: async () => [ { id: WL, status: 'in-progress', stage: 'plan_complete' }, ], - // Simulates the old pane-title behaviour: a tab whose label *contains* - // the ID but is not the exact ID (e.g. "implement WL-0MSOJLZD9004P8PI"). - // Under the tab-matching contract this does NOT count as active. - listTabs: async () => [ - { tab_id: 't1', label: `implement ${WL}`, workspace_id: 'wCurrent' }, + listActivePanes: async () => [ + { pane_id: 'wC:p1', label: 'A general shell session', workspace_id: 'wCurrent' }, ], }); @@ -633,101 +671,98 @@ describe('pane-title-only ID no longer counts (verification)', () => { expect(result.ok).toBe(true); expect(result.demoted).toBe(1); - // The item is demoted because its ID is not an *exact* tab label match. expect(applied).toEqual([{ id: WL, status: 'open', stage: 'intake_complete' }]); }); }); describe('workspace scoping at orchestration level', () => { - it('demotes when the only matching tab is in a different workspace', async () => { + it('demotes when the only matching pane is in a different workspace', async () => { const { deps, applied } = makeDeps({ listInProgressItems: async () => [ { id: WL, status: 'in-progress', stage: 'plan_complete' }, ], - listTabs: async () => [ - { tab_id: 't1', label: WL, workspace_id: 'wOther' }, + listActivePanes: async () => [ + { pane_id: 'wO:p1', label: `Downtime triggered - ${WL}`, workspace_id: 'wOther' }, ], }); await runHydrationOnce(deps, 'wCurrent'); - // Tab exists but in the wrong workspace → item demoted. + // Pane exists but in the wrong workspace → item demoted. expect(applied).toEqual([{ id: WL, status: 'open', stage: 'intake_complete' }]); }); - it('keeps an item when a matching tab is in the current workspace', async () => { + it('keeps an item when a matching pane is in the current workspace', async () => { const { deps, applied } = makeDeps({ listInProgressItems: async () => [ { id: WL, status: 'in-progress', stage: 'plan_complete' }, ], - listTabs: async () => [ - { tab_id: 't1', label: 'Work Items' }, - { tab_id: 't2', label: WL, workspace_id: 'wCurrent' }, + listActivePanes: async () => [ + { pane_id: 'wC:p1', label: 'Work Items' }, + { pane_id: 'wC:p2', label: `Downtime triggered - ${WL}`, workspace_id: 'wCurrent' }, ], }); await runHydrationOnce(deps, 'wCurrent'); - // Exact tab in current workspace → item not demoted. + // Pane in current workspace → item not demoted. expect(applied).toEqual([]); }); }); -describe('unparseable tab list output fails open', () => { - it('does not demote when parseTabListOutput returns null (unparseable output)', async () => { +describe('unparseable pane list output fails open', () => { + it('does not demote when the pane seam returns null (unparseable output)', async () => { const { deps, applied } = makeDeps({ listInProgressItems: async () => [ { id: WL, status: 'in-progress', stage: 'plan_complete' }, ], - // Simulates parseTabListOutput returning null (e.g. garbled JSON output). - // This is the unparseable path of the tab-list seam. - listTabs: async () => null, + // Simulates the pane-list seam returning null (e.g. garbled JSON output). + listActivePanes: async () => null, }); const result = await runHydrationOnce(deps, 'wCurrent'); expect(result.ok).toBe(false); - expect(result.reason).toContain('tab list'); + expect(result.reason).toContain('pane list'); expect(applied).toEqual([]); }); }); -describe('no pane list in production hydrator spawns', () => { - it('asserts that every hydrator CLI spawn uses tab list, never pane list', async () => { +describe('production active-detection spawns (pane list)', () => { + it('asserts that every hydrator CLI spawn uses pane list, never tab list', async () => { const { calls, exec } = hydratorExecRouter(); setExecFileAsync(exec as never); const deps = createProductionHydratorDeps(); await deps.listInProgressItems(); - await deps.listTabs(); + await deps.listActivePanes(); await deps.listOutboundDeps(WL); await deps.applyDemotion(WL, 'open', 'plan_complete'); - // Every recorded spawn must not include `pane`. + // Every recorded spawn must not include `tab` for the active-detection + // lookup (pane titles are the signal). expect(calls).toHaveLength(4); - for (const call of calls) { - expect(call.args.join(' ')).not.toContain('pane'); - } - // The second call (listTabs) must use `herdr tab list` (bin may be a - // full path when HERDR_BIN_PATH is set). - const tabCall = calls.find((call) => call.args.includes('tab')); - expect(tabCall).toBeDefined(); - expect(tabCall!.bin.endsWith('herdr')).toBe(true); - expect(tabCall!.args).toEqual(['tab', 'list']); + // The seam uses `herdr pane list` (bin may be a full path when + // HERDR_BIN_PATH is set). + const paneCall = calls.find( + (call) => call.bin.endsWith('herdr') && call.args.includes('pane'), + ); + expect(paneCall).toBeDefined(); + expect(paneCall!.args).toEqual(['pane', 'list']); }); }); -describe('closed tab clears the in-progress marker (cadence re-evaluation)', () => { - it('a tab present keeps the item; removing the tab demotes on next run', async () => { - let visibleTab: string | undefined = WL; +describe('closed pane clears the in-progress marker (cadence re-evaluation)', () => { + it('a pane present keeps the item; removing the pane demotes on next run', async () => { + let visiblePane: string | undefined = `Downtime triggered - ${WL}`; const applied: AppliedDemotion[] = []; const { deps } = makeDeps({ listInProgressItems: async () => [ { id: WL, status: 'in-progress', stage: 'plan_complete' }, ], - listTabs: async () => - visibleTab - ? [{ tab_id: 't1', label: visibleTab, workspace_id: 'wCurrent' }] + listActivePanes: async () => + visiblePane + ? [{ pane_id: 'wC:p1', label: visiblePane, workspace_id: 'wCurrent' }] : [], applyDemotion: async (id, status, stage) => { applied.push({ id, status, stage }); @@ -738,16 +773,16 @@ describe('closed tab clears the in-progress marker (cadence re-evaluation)', () isVisible: async () => true, }); - // Tab is present — no demotion. + // Pane is present — no demotion. const result1 = await run(); expect(result1?.ok).toBe(true); expect(result1?.demoted).toBe(0); expect(applied).toHaveLength(0); - // Simulate the user closing the work-item tab. - visibleTab = undefined; + // Simulate the user closing the work-item pane. + visiblePane = undefined; - // Next cadence run — tab gone → demotion. + // Next cadence run — pane gone → demotion. const result2 = await run(); expect(result2?.ok).toBe(true); expect(result2?.demoted).toBe(1); @@ -839,14 +874,14 @@ describe('production log sink (AC5)', () => { listInProgressItems: async () => [ { id: WL, status: 'in-progress', stage: 'plan_complete' }, ], - listTabs: async () => { + listActivePanes: async () => { throw new Error('herdr missing'); }, }); const result = await runHydrationOnce(deps); expect(result.ok).toBe(false); expect(logs.length).toBeGreaterThan(0); - expect(logs.some((l) => l.includes('tab-list'))).toBe(true); + expect(logs.some((l) => l.includes('pane-list'))).toBe(true); }); it('createHydratorFileLog appends bounded lines to <cwd>/.worklog/logs/hydrator.log', async () => { @@ -878,14 +913,14 @@ describe('hydrator CLI stdio isolation (AC1)', () => { const deps = createProductionHydratorDeps(); await deps.listInProgressItems(); - await deps.listTabs(); + await deps.listActivePanes(); await deps.listOutboundDeps(WL); await deps.applyDemotion(WL, 'open', 'plan_complete'); - // All four hydrator spawn shapes were exercised (in-progress list, tab + // All four hydrator spawn shapes were exercised (in-progress list, pane // list, per-item dep list, demotion apply). expect(calls).toHaveLength(4); - expect(calls.some((call) => call.args.includes('tab'))).toBe(true); + expect(calls.some((call) => call.args.includes('pane'))).toBe(true); expect(calls.some((call) => call.args.includes('dep'))).toBe(true); expect(calls.some((call) => call.args.includes('update'))).toBe(true); for (const call of calls) { @@ -894,17 +929,17 @@ describe('hydrator CLI stdio isolation (AC1)', () => { }); }); -// ── AC4: workspace-scoped tab listing ───────────────────────────────── +// ── AC4: workspace-scoped pane listing ──────────────────────────────── -describe('workspace-scoped tab listing (AC4)', () => { - it('passes the current workspace id to the tab-list seam', async () => { +describe('workspace-scoped pane listing (AC4)', () => { + it('passes the current workspace id to the pane-list seam', async () => { const seen: Array<string | undefined> = []; const { deps } = makeDeps({ listInProgressItems: async () => [], - listTabs: (async (workspaceId?: string) => { + listActivePanes: (async (workspaceId?: string) => { seen.push(workspaceId); return []; - }) as HydratorDeps['listTabs'], + }) as HydratorDeps['listActivePanes'], }); await runHydrationOnce(deps, 'wCurrent'); @@ -912,21 +947,21 @@ describe('workspace-scoped tab listing (AC4)', () => { expect(seen).toEqual(['wCurrent']); }); - it('runs "herdr tab list --workspace <id>" when the workspace id is known', async () => { + it('runs "herdr pane list --workspace <id>" when the workspace id is known', async () => { const { calls, exec } = hydratorExecRouter(); setExecFileAsync(exec as never); const deps = createProductionHydratorDeps(); - const listTabs = deps.listTabs as ( + const listActivePanes = deps.listActivePanes as ( workspaceId?: string, - ) => Promise<HydratorTab[] | null>; + ) => Promise<HydratorPane[] | null>; - await listTabs('wCurrent'); + await listActivePanes('wCurrent'); - const tabCall = calls.find((call) => call.args.includes('tab')); - expect(tabCall).toBeDefined(); - const index = tabCall!.args.indexOf('--workspace'); + const paneCall = calls.find((call) => call.args.includes('pane')); + expect(paneCall).toBeDefined(); + const index = paneCall!.args.indexOf('--workspace'); expect(index).toBeGreaterThanOrEqual(0); - expect(tabCall!.args[index + 1]).toBe('wCurrent'); + expect(paneCall!.args[index + 1]).toBe('wCurrent'); }); it('omits the --workspace filter when the workspace id is unknown', async () => { @@ -934,11 +969,11 @@ describe('workspace-scoped tab listing (AC4)', () => { setExecFileAsync(exec as never); const deps = createProductionHydratorDeps(); - await deps.listTabs(); + await deps.listActivePanes(); - const tabCall = calls.find((call) => call.args.includes('tab')); - expect(tabCall).toBeDefined(); - expect(tabCall!.args).not.toContain('--workspace'); + const paneCall = calls.find((call) => call.args.includes('pane')); + expect(paneCall).toBeDefined(); + expect(paneCall!.args).not.toContain('--workspace'); }); }); @@ -959,7 +994,7 @@ describe('per-step timeouts (AC2)', () => { const { deps } = makeDeps({ // The first lookup wedges like a hung `wl`/`herdr` child. listInProgressItems: () => new Promise<HydratorItem[]>(() => {}), - listTabs: async () => [], + listActivePanes: async () => [], listOutboundDeps, applyDemotion, stepTimeoutMs: 1_000, @@ -992,7 +1027,7 @@ describe('per-step timeouts (AC2)', () => { const applyDemotion = vi.fn(async () => true); const { deps } = makeDeps({ listInProgressItems: async () => items, - listTabs: async () => [], + listActivePanes: async () => [], listOutboundDeps, applyDemotion, stepTimeoutMs: 500, @@ -1030,7 +1065,7 @@ describe('per-step timing logs (AC3)', () => { clock += 7; return [{ id: WL, status: 'in-progress', stage: 'plan_complete' }]; }, - listTabs: async () => { + listActivePanes: async () => { clock += 3; return []; }, @@ -1050,7 +1085,7 @@ describe('per-step timing logs (AC3)', () => { const lineFor = (label: string): string | undefined => logs.find((line) => line.includes(label)); expect(lineFor('in-progress-list')).toMatch(/elapsed 7ms/); - expect(lineFor('tab-list')).toMatch(/elapsed 3ms/); + expect(lineFor('pane-list')).toMatch(/elapsed 3ms/); expect(lineFor('dep-list')).toMatch(/elapsed 5ms/); expect(lineFor('demotion-apply')).toMatch(/elapsed 12ms/); }); @@ -1065,7 +1100,7 @@ describe('per-tick processing cap (AC5)', () => { const demoted: string[] = []; const { deps } = makeDeps({ listInProgressItems: async () => items, - listTabs: async () => [], + listActivePanes: async () => [], listOutboundDeps: async (id) => { depChecked.push(id); return []; @@ -1091,13 +1126,13 @@ describe('per-tick processing cap (AC5)', () => { expect(result.demoted).toBe(10); }); - it('honours the cap under a 60-tab / 25-item worst case', async () => { - const tabs = makeWorstCaseTabs(60); + it('honours the cap under a 60-pane / 25-item worst case', async () => { + const panes = makeWorstCasePanes(60); const items = makeWorstCaseItems(25); const depChecked: string[] = []; const { deps } = makeDeps({ listInProgressItems: async () => items, - listTabs: async () => tabs, + listActivePanes: async () => panes, listOutboundDeps: async (id) => { depChecked.push(id); return []; @@ -1116,7 +1151,7 @@ describe('per-tick processing cap (AC5)', () => { const items = makeWorstCaseItems(60); const { deps } = makeDeps({ listInProgressItems: async () => items, - listTabs: async () => [], + listActivePanes: async () => [], listOutboundDeps: async () => [], applyDemotion: async () => true, }); diff --git a/packages/herdr/src/hydrator.ts b/packages/herdr/src/hydrator.ts index 1ca952ace..eb13ef57e 100644 --- a/packages/herdr/src/hydrator.ts +++ b/packages/herdr/src/hydrator.ts @@ -2,55 +2,70 @@ * packages/herdr/src/hydrator.ts — Herdr hydrator (WL-0MSOJLZD9004P8PI) * * Self-heals the work queue: periodically fetches every `in-progress` work - * item, matches each against the tabs of the current workspace (each - * dispatched item gets a tab labelled with its exact work-item ID), and - * DEMOTES any item with no matching tab so it re-enters the dispatchable - * pool instead of lingering in `in-progress` forever. + * item, matches each against the panes of the current workspace (every + * dispatched item gets an agent pane whose TITLE carries its work-item ID + * as a preserved suffix), and DEMOTES any item with no matching pane so it + * re-enters the dispatchable pool instead of lingering in `in-progress` + * forever. * * Demotion decision (signal-based, mirroring `dispatch` eligibility): * - item is not `in-progress` → untouched (not in scope) - * - a RUNNING pane's tab label EXACTLY → untouched (genuinely being worked) - * matches the ID (agent not `done`/`exited`) + * - a pane TITLE containing the item ID → untouched (genuinely being worked) * - stage `in_review` → `status=completed`, stage `in_review` * - an active outbound dependency blocker→ `status=blocked` (stage made compatible) * - otherwise → `status=open`, stage `intake_complete` * - * Running-pane liveness (F1/WL-0MTF5WO2Q008B8US, AC2): a tab whose agent has - * finished (`agent_status` `done`/`exited`) is NOT an active match, so its - * item is released instead of lingering in `in-progress` while a stale pane - * remains listed. Absent/unknown `agent_status` fails open toward active — - * never demote on untrustworthy evidence (AC5). + * Active detection (WL-0MV1BWETT005GSUJ): a single simple rule — if there + * is a pane in the current workspace whose title contains the work-item ID + * (e.g. `Downtime triggered implement … - WL-0MSOJLZD9004P8PI`) the item is + * actively being worked on; if there is no such pane, the item is not. + * + * The producer rejected an earlier tab-label formulation as unreliable + * (workspace review, 2026-10-16): "It should be simple. Look for a pane in + * this workspace with the work item id as the title. If it exists then work + * item is busy." Tabs are created by the dispatcher-ANCHOR as a placement + * side effect and can be renamed/re-parented by the user or the tab may not + * exist yet while the item is being dispatched (a false-demotion race). Pane + * titles, by contrast, are set at spawn time by the shared `pane-title.ts` + * builders, always preserve the item ID as a trailing suffix under truncation + * (`truncatePaneTitlePreservingSuffix`), and survive pane re-parenting — so a + * pane-title match is the reliable "someone is working this item" signal + * (WL-0MV1BWETT005GSUJ rework). * * Folded-in no-activity/claim-age timeout (WL-0MTTSWE0G008M1VA, 2026-09-13): - * the tab-absent signal is the primary "stuck" signal. A claim with no - * matching running tab is stale regardless of activity age, so it is released + * the pane-absent signal is the primary "stuck" signal. A claim with no + * matching pane is stale regardless of activity age, so it is released * (reset to `open`/`intake_complete`) — the 2 h dispatch stale window * (`DOWNTIME_AUDIT_STALE_WINDOW_MS`) is the activity bound an operator can * use to reason about abandoned claims. Every release is logged (who/why/age * is carried by the work-item audit trail from the `wl` mutation itself). * - * Fail-open by design: an unavailable/unparseable `wl list` or `herdr tab + * Fail-open by design: an unavailable/unparseable `wl list` or `herdr pane * list` aborts the cycle WITHOUT demoting — a transient CLI error must never * trigger a false demotion. A production log sink * (`createHydratorFileLog` → `<worklog root>/.worklog/logs/hydrator.log`) is * wired by default (F2/AC5) so such a cycle is operator-observable rather - * than failing silently. Matching tabs are created by the shared - * dispatcher-anchor builders (`getItemTabAnchor`), which label the tab with - * the exact work-item ID (`findTabByLabel` matches exact labels only). + * than failing silently. Matching panes are produced by the shared + * `pane-title.ts` builders, which preserve the work-item ID suffix under + * truncation so every spawned agent pane carries its ID in the title. * - * Selection-list marker (WL-0MV1BWETT005GSUJ): the same tab set drives the - * selection list's in-progress marker via `markItemsActiveByTab` / - * `applyActiveTabMarkers` — an item with an exact-match tab is rendered + * Selection-list marker (WL-0MV1BWETT005GSUJ): the same pane set drives the + * selection list's in-progress marker via `markItemsActiveByPane` / + * `applyActivePaneMarkers` — an item with a matching pane title is rendered * `in-progress` (marker + Group N) even when its worklog status is still - * `open`, so the marker reflects tab existence rather than only the status - * the demotion loop maintains. The production wiring injects this through - * the worklist's `applyActiveTabMarkers` seam (`index.ts`). + * `open`, so the marker reflects pane existence rather than only the status + * the demotion loop maintains. The marker ALSO stamps the row's green-circle + * icon (`agentState='working'`) for pane-matched items and clears it for + * unmatched ones — the producer's rule that a green circle appears exactly + * when a work-item pane is open in this workspace and never otherwise. The + * production wiring injects this through the worklist's + * `applyActivePaneMarkers` seam (`index.ts`). * - * Opening/closing a work-item tab does NOT touch the worklog DB, so the + * Opening/closing a work-item pane does NOT touch the worklog DB, so the * DB-change-gated refresh tick can never update this marker for it. The * worklist therefore re-applies the marker on the hydrate cadence and on - * focus-resume (see `refreshTabMarkers`), and `markItemsActiveByTab` restores - * each item's pre-marker status first so a CLOSED tab clears the marker + * focus-resume (see `refreshPaneMarkers`), and `markItemsActiveByPane` restores + * each item's pre-marker status first so a CLOSED pane clears the marker * without a `wl` re-fetch. * * Bounded, responsive runs (WL-0MUY1CXSV008F8TZ, WL-0MUY1CYV6008BQWX, @@ -77,7 +92,7 @@ import { extractJson, getWorklogDir, } from './fetcher.js'; -import { parseTabListOutput } from './dispatcher-anchor.js'; +import { parseMachinePaneListOutput } from './dispatcher-anchor.js'; // ── Constants ───────────────────────────────────────────────────────── @@ -89,7 +104,7 @@ export const HYDRATOR_RUN_TIMEOUT_MS = 20_000; /** * Soft budget for a single hydrate step (ms). Each step (in-progress list, - * tab list, per-item dep list, demotion apply) races its lookup against + * pane list, per-item dep list, demotion apply) races its lookup against * this timer so a hung `wl`/`herdr` child cannot wedge the run until the * 20 s scheduler watchdog (which abandons the awaited promise but cannot * cancel the child). Kept well below `HYDRATOR_RUN_TIMEOUT_MS` so the run @@ -119,16 +134,6 @@ export const HYDRATOR_MAX_ITEMS_PER_TICK = 50; * `CG-0MT5Y1X5T001M4S6`). */ export const WORK_ITEM_ID_REGEX = /[A-Z][A-Z0-9]*-[0-9A-Z]{6,}/g; - -/** - * Anchored matcher for a label that IS exactly one work-item ID. - * - * Tab matching requires exact equality (`findTabByLabel` semantics): a tab - * labelled `implement WL-…` is not that item's tab. Anchoring the whole - * string (rather than scanning with `WORK_ITEM_ID_REGEX`) keeps that rule - * explicit and lets `collectTabWorkItemIds` ignore non-ID labels entirely. - */ -const EXACT_WORK_ITEM_ID_REGEX = /^[A-Z][A-Z0-9]*-[0-9A-Z]{6,}$/; /** * Status → allowed stages, mirroring the project's status/stage * compatibility rules (`src/config.ts`, `deriveStageStatusCompatibility`). @@ -147,14 +152,6 @@ const TERMINAL_DEP_STATUSES = new Set(['completed', 'deleted']); /** Stages considered terminal for an outbound dependency target. */ const TERMINAL_DEP_STAGES = new Set(['in_review', 'done']); -/** - * `agent_status` values that mean the pane's agent has finished and left - * (mirrors `countRunningDowntimePanes` in `downtime-worker.ts` and the - * agent-liveness gate in `pane-close-herdr.ts`). A tab reporting a terminal - * status is NOT a running pane, so its item is eligible for demotion. - */ -const TERMINAL_AGENT_STATUSES = new Set(['done', 'exited']); - /** File name of the hydrator diagnostic log inside `.worklog/logs/`. */ export const HYDRATOR_LOG_FILE = 'hydrator.log'; /** Rolling bound: keep at most this many lines in the hydrator log. */ @@ -170,22 +167,23 @@ export interface HydratorItem { stage?: string; } -/** The subset of a `herdr tab list` entry the hydrator needs. */ -export interface HydratorTab { - tab_id: string; +/** The subset of a `herdr pane list` entry the hydrator needs. */ +export interface HydratorPane { + pane_id: string; + /** The pane's title/label (e.g. `Downtime triggered implement … - WL-…`). */ label: string; /** - * Workspace the tab belongs to when known. `herdr tab list --workspace` + * Workspace the pane belongs to when known. `herdr pane list --workspace` * already scopes production output, so the field is normally omitted; it * lets the pure helpers enforce (and tests exercise) the same scoping * contract on an unscoped payload. */ workspace_id?: string; /** - * Raw `agent_status` reported for the tab, when herdr provides it. Only a - * terminal value (`done`/`exited`) makes the tab NOT a running pane - * (F1/WL-0MTF5WO2Q008B8US); absent/unknown status is fail-open toward - * active (never demote on untrustworthy evidence, AC5). + * Raw `agent_status` reported for the pane, when herdr provides it. + * Retained in the shape for forward compatibility but no longer used + * by the hydrator's active detection (pane-title existence alone is the + * signal). */ agent_status?: string; } @@ -210,7 +208,7 @@ export interface HydrationResult { considered: number; /** Number of items actually demoted. */ demoted: number; - /** Number of items left untouched (matching tab, non-candidate, or failure). */ + /** Number of items left untouched (matching pane, non-candidate, or failure). */ skipped: number; /** Human-readable failure reason when `ok` is false. */ reason?: string; @@ -224,11 +222,11 @@ export interface HydratorDeps { /** `wl list --status in-progress` → items, or null when unavailable. */ listInProgressItems: () => Promise<HydratorItem[] | null>; /** - * `herdr tab list [--workspace <id>]` → tabs, or null when unavailable. + * `herdr pane list [--workspace <id>]` → panes, or null when unavailable. * The current workspace id is passed so the payload does not scale with - * tabs outside it (WL-0MUY1CYV6008BQWX). + * panes outside it (WL-0MUY1CYV6008BQWX). */ - listTabs: (workspaceId?: string) => Promise<HydratorTab[] | null>; + listActivePanes: (workspaceId?: string) => Promise<HydratorPane[] | null>; /** `wl dep list <id>` → outbound targets, or null when unavailable. */ listOutboundDeps: (itemId: string) => Promise<HydratorDepTarget[] | null>; /** Apply a demotion via `wl update`; true when it was persisted. */ @@ -246,7 +244,7 @@ export interface HydratorDeps { /** Stable labels for the bounded hydrate steps (logs / diagnostics). */ export type HydratorStepLabel = | 'in-progress-list' - | 'tab-list' + | 'pane-list' | 'dep-list' | 'demotion-apply'; @@ -256,8 +254,8 @@ export type HydratorStepLabel = * Extract every work-item ID appearing in `text` (e.g. a shell command line * or tab/window title). Always returns a fresh array; no shared regex state. * - * Retained for `ship-guard.ts`; it is NOT used for active-tab matching, which - * requires exact label equality (see `collectTabWorkItemIds`). + * Retained for `ship-guard.ts`; also used for active-pane-title matching + * (see `collectPaneWorkItemIds`). */ export function extractWorkItemIdsFromText(text: string): string[] { if (typeof text !== 'string' || text.length === 0) return []; @@ -265,87 +263,56 @@ export function extractWorkItemIdsFromText(text: string): string[] { } /** - * Collect the set of work-item IDs whose tab label is *exactly* an ID. + * Collect the set of work-item IDs whose pane title carries the ID + * (WL-0MV1BWETT005GSUJ rework: a pane with the work item id as the title). * - * Exact equality (never substring/prefix) mirrors `findTabByLabel`: a tab - * labelled `implement WL-…` is not the item's tab and contributes nothing. - * Tabs whose label is not a work-item ID (e.g. `Work Items`, `Downtime`) are - * ignored. + * Every pane spawned for an item is titled by the shared `pane-title.ts` + * builders (`Downtime triggered implement … - WL-…`, `Manually triggered … + * - WL-…`, `Shell: … WL-…`), and the truncation helper preserves the ID as a + * trailing suffix (`truncatePaneTitlePreservingSuffix`), so a pane whose + * title CONTAINS the exact item ID is that item's pane. The scan uses the + * shared `WORK_ITEM_ID_REGEX` matcher, so only tokens shaped exactly like a + * work-item ID count — ordinary words in the title cannot produce a false + * match. * - * When `workspaceId` is provided, only tabs in that workspace are considered - * (a tab in another workspace must not make an item look active). Tabs + * When `workspaceId` is provided, only panes in that workspace are considered + * (a pane in another workspace must not make an item look active). Panes * without a `workspace_id` are always included (fail-open toward "active" — - * safer against false demotion); the production `herdr tab list --workspace` + * safer against false demotion); the production `herdr pane list --workspace` * payload omits the field because the CLI already scoped it. */ -export function collectTabWorkItemIds( - tabs: readonly HydratorTab[], +export function collectPaneWorkItemIds( + panes: readonly HydratorPane[], workspaceId?: string, ): Set<string> { const ids = new Set<string>(); - for (const tab of tabs) { + for (const pane of panes) { if ( workspaceId && - typeof tab.workspace_id === 'string' && - tab.workspace_id.length > 0 && - tab.workspace_id !== workspaceId + typeof pane.workspace_id === 'string' && + pane.workspace_id.length > 0 && + pane.workspace_id !== workspaceId ) { continue; } - const label = typeof tab.label === 'string' ? tab.label : ''; - if (EXACT_WORK_ITEM_ID_REGEX.test(label)) ids.add(label); + const label = typeof pane.label === 'string' ? pane.label : ''; + for (const id of extractWorkItemIdsFromText(label)) ids.add(id); } return ids; } -/** True when a tab in the workspace is labelled exactly the item's ID. */ -export function hasItemTab( - itemId: string, - tabs: readonly HydratorTab[], - workspaceId?: string, -): boolean { - return collectTabWorkItemIds(tabs, workspaceId).has(itemId); -} - /** - * True when a tab is a RUNNING pane: its agent has not reported a finished - * status (`done`/`exited`). Absent or unknown `agent_status` is fail-open - * toward active — the hydrator must never demote on untrustworthy evidence - * (AC5), and only a provably-finished agent makes a pane stale (F1). - */ -export function isRunningTab(tab: HydratorTab): boolean { - const status = (tab.agent_status ?? '').trim().toLowerCase(); - return !TERMINAL_AGENT_STATUSES.has(status); -} - -/** - * AC2: true iff the item ID appears in a RUNNING pane's label in any tab of - * the current workspace (exact-label matching per WL-0MV24RM9I0051GEF). A - * tab whose agent has finished (`agent_status` `done`/`exited`) is not a - * running pane, so its item IS eligible for demotion (F1). Absent/unknown - * `agent_status` fails open toward active (no demotion on untrustworthy - * evidence). + * True when a pane in the workspace is titled with the item's ID — the title + * contains the exact ID token (never a partial match). A simple rule: if there + * is a pane with the work item id in its title the item is actively being + * worked on; if there is not, it is not (WL-0MV1BWETT005GSUJ rework). */ export function isActivePaneMatch( itemId: string, - tabs: readonly HydratorTab[], + panes: readonly HydratorPane[], workspaceId?: string, ): boolean { - for (const tab of tabs) { - if (!isRunningTab(tab)) continue; - if (typeof tab.label !== 'string') continue; - if (tab.label !== itemId) continue; - if ( - workspaceId && - typeof tab.workspace_id === 'string' && - tab.workspace_id.length > 0 && - tab.workspace_id !== workspaceId - ) { - continue; - } - return true; - } - return false; + return collectPaneWorkItemIds(panes, workspaceId).has(itemId); } /** @@ -353,7 +320,7 @@ export function isActivePaneMatch( * of the given panes. Returns a fresh array; deduplicates across panes. */ export function extractWorkItemIdsFromPaneTitles( - panes: readonly HydratorTab[], + panes: readonly HydratorPane[], ): string[] { const ids = new Set<string>(); for (const pane of panes) { @@ -364,61 +331,74 @@ export function extractWorkItemIdsFromPaneTitles( } /** - * Minimal item shape for tab-based active marking. `WorkItem` structurally - * satisfies it (id + optional status/stage/children), so the worklist can - * pass live items without this dependency-light module importing a worklog - * type. + * Minimal item shape for pane-based active marking. `WorkItem` structurally + * satisfies it (id + optional status/stage/children/agentState), so the + * worklist can pass live items without this dependency-light module + * importing a worklog type. */ -export interface ActiveTabItem { +export interface ActivePaneItem { id: string; status?: string; stage?: string; - children?: ActiveTabItem[]; + children?: ActivePaneItem[]; + /** Agent-slot state stamped by the pane marker (`working` = pane open). */ + agentState?: string; } /** * Non-enumerable per-item record of the work-item's true worklog status - * before the tab marker overrode it. A `Symbol` keeps the bookkeeping + * before the pane marker overrode it. A `Symbol` keeps the bookkeeping * invisible to `JSON.stringify`/deep-equal assertions over the work-item * shape; a fresh `wl` fetch yields objects without it, so the true status is * (re)captured on every refresh. */ -const TAB_MARKER_BASE_STATUS = Symbol('tabMarkerBaseStatus'); +const PANE_MARKER_BASE_STATUS = Symbol('paneMarkerBaseStatus'); -/** `ActiveTabItem` carrying the tab-marker bookkeeping symbol. */ -type TabMarkedActiveTabItem = ActiveTabItem & { - [TAB_MARKER_BASE_STATUS]?: string; +/** `ActivePaneItem` carrying the pane-marker bookkeeping symbol. */ +type PaneMarkedActivePaneItem = ActivePaneItem & { + [PANE_MARKER_BASE_STATUS]?: string; }; /** - * Mark every item that has an exact-match workspace tab as `in-progress` so + * Mark every item that has a matching workspace pane as `in-progress` so * the selection list's in-progress marker (and Group N placement) reflects - * tab existence, not only the worklog status the demotion loop maintains - * (WL-0MV1BWETT005GSUJ). Recurses into children. + * pane-title existence, not only the worklog status the demotion loop + * maintains (WL-0MV1BWETT005GSUJ). Recurses into children. + * + * The same pane membership also drives the row's green-circle (agent-slot) + * icon: an item with a matching pane gets `agentState='working'` (the green + * circle), an item without one gets it cleared — the producer's rule that a + * green circle appears exactly when a work-item pane is open in this + * workspace and never otherwise. The icon is therefore independent of the + * event-driven agent-status tracker, which could not honour that invariant + * (a freshly-spawned/`unknown` pane is absent from `herdr agent list`, an + * agent `done` state prunes the icon even with the pane still open, and a + * closed pane's icon could linger until an event/TTL refresh landed). * * Idempotent: each item's pre-marker status is captured on first sight and - * restored on every later pass, so calling this again after a tab closes + * restored on every later pass, so calling this again after a pane closes * clears the marker (back to the true worklog status) without a `wl` - * re-fetch — the marker refresh spawns only `herdr tab list`. Terminal - * `in_review`/`completed` items are left untouched: they already carry the - * review/audit marker and belong in the In Review group, and a lingering tab - * on a finished item must not re-open it in the list. + * re-fetch — the marker refresh spawns only `herdr pane list`. Terminal + * `in_review`/`completed` items are left untouched by the STATUS marker + * (they already carry the review/audit marker and belong in the In Review + * group), but the GREEN CIRCLE still reflects pane existence for them — an + * open pane means the item is being worked, regardless of its stage. * - * Pure and synchronous — the caller owns tab fetching (see - * `applyActiveTabMarkers`). + * Pure and synchronous — the caller owns pane fetching (see + * `applyActivePaneMarkers`). */ -export function markItemsActiveByTab( - items: readonly ActiveTabItem[], +export function markItemsActiveByPane( + items: readonly ActivePaneItem[], activeIds: ReadonlySet<string>, ): void { for (const item of items) { - const marked = item as TabMarkedActiveTabItem; - if (TAB_MARKER_BASE_STATUS in marked) { - // Restore the true worklog status before re-evaluating, so a tab that - // has since closed clears its marker and a still-open tab keeps it. - item.status = marked[TAB_MARKER_BASE_STATUS]; + const marked = item as PaneMarkedActivePaneItem; + if (PANE_MARKER_BASE_STATUS in marked) { + // Restore the true worklog status before re-evaluating, so a pane that + // has since closed clears its marker and a still-open pane keeps it. + item.status = marked[PANE_MARKER_BASE_STATUS]; } else { - Object.defineProperty(marked, TAB_MARKER_BASE_STATUS, { + Object.defineProperty(marked, PANE_MARKER_BASE_STATUS, { value: item.status, writable: true, enumerable: false, @@ -431,33 +411,39 @@ export function markItemsActiveByTab( if (status !== 'completed' && stage !== 'in_review') { item.status = 'in-progress'; } + // Green circle: a pane in this workspace whose title carries the item + // ID exists ⇒ the item is being worked, regardless of agent state. + item.agentState = 'working'; + } else { + // No matching pane ⇒ no green circle (clear any stale stamp). + delete item.agentState; } const children = item.children; if (Array.isArray(children) && children.length > 0) { - markItemsActiveByTab(children, activeIds); + markItemsActiveByPane(children, activeIds); } } } /** - * Fetch `herdr tab list` for `workspaceId` and mark every item with an - * exact-match tab as `in-progress` (see `markItemsActiveByTab`). Fail-open: - * an unavailable/unparseable tab list leaves the items unchanged, so a - * transient CLI error never clears a marker. + * Fetch `herdr pane list` for `workspaceId` and mark every item with a + * matching pane title as `in-progress` (see `markItemsActiveByPane`). + * Fail-open: an unavailable/unparseable pane list leaves the items unchanged, + * so a transient CLI error never clears a marker. */ -export async function applyActiveTabMarkers( - items: readonly ActiveTabItem[], - listTabs: HydratorDeps['listTabs'], +export async function applyActivePaneMarkers( + items: readonly ActivePaneItem[], + listActivePanes: HydratorDeps['listActivePanes'], workspaceId?: string, ): Promise<void> { - let tabs: HydratorTab[] | null; + let panes: HydratorPane[] | null; try { - tabs = await listTabs(workspaceId); + panes = await listActivePanes(workspaceId); } catch { return; } - if (tabs === null) return; - markItemsActiveByTab(items, collectTabWorkItemIds(tabs, workspaceId)); + if (panes === null) return; + markItemsActiveByPane(items, collectPaneWorkItemIds(panes, workspaceId)); } /** @@ -490,10 +476,10 @@ export function compatibleStage(stage: string, status: string): string { /** * Pure demotion decision. Returns `null` when the item must be left alone - * (not `in-progress`, or a matching RUNNING pane exists — F1). + * (not `in-progress`, or a matching ACTIVE pane exists — F1). * * The `hasActivePane` context key is retained for source compatibility; its - * value now comes from the running-pane check `isActivePaneMatch`. + * value now comes from the pane-title check `isActivePaneMatch`. */ export function decideDemotion( item: HydratorItem, @@ -575,14 +561,14 @@ async function runStep<T>( /** * Build a visibility-gated runner used by BOTH the 30 s scheduler task and * the hidden→visible resume hook (AC4). While the worklist pane is hidden - * the runner is a no-op, so hiding the tab spawns zero `wl`/`herdr` + * the runner is a no-op, so hiding it spawns zero `wl`/`herdr` * processes (AC5). * * The same runner is invoked immediately on focus-resume, so regaining * focus re-checks without waiting for the next 30 s tick. * * @param deps Injectable seams (production or test fakes). - * @param workspaceId Current herdr workspace id (undefined → all tabs). + * @param workspaceId Current herdr workspace id (undefined → all panes). * @param isVisible Visibility probe (normally `paneGate.visible`). */ export function createHydratorRunner( @@ -596,12 +582,12 @@ export function createHydratorRunner( } /** - * Run one hydration cycle: fetch in-progress items + workspace tabs, decide a + * Run one hydration cycle: fetch in-progress items + workspace panes, decide a * demotion per item, and apply it. Fail-open at every boundary — an * unavailable lookup aborts the cycle without demoting. * * @param deps Injectable seams (production or test fakes). - * @param workspaceId Current herdr workspace id (undefined → all tabs). + * @param workspaceId Current herdr workspace id (undefined → all panes). */ export async function runHydrationOnce( deps: HydratorDeps, @@ -641,11 +627,11 @@ export async function runHydrationOnce( const considered = Math.min(items.length, Math.max(0, maxItemsPerTick)); const tabStep = await runStep( - 'tab-list', + 'pane-list', stepTimeoutMs, now, log, - () => deps.listTabs(workspaceId), + () => deps.listActivePanes(workspaceId), ); if (tabStep.status === 'timeout') { return { @@ -653,7 +639,7 @@ export async function runHydrationOnce( considered, demoted: 0, skipped: 0, - reason: 'tab-list step timeout (no demotion on ambiguous evidence)', + reason: 'pane-list step timeout (no demotion on ambiguous evidence)', }; } if (tabStep.status === 'error' || tabStep.value === null) { @@ -662,10 +648,10 @@ export async function runHydrationOnce( considered, demoted: 0, skipped: 0, - reason: 'tab list unavailable (no demotion on ambiguous evidence)', + reason: 'pane list unavailable (no demotion on ambiguous evidence)', }; } - const tabs = tabStep.value; + const panes = tabStep.value; let demoted = 0; let skipped = 0; @@ -675,12 +661,12 @@ export async function runHydrationOnce( if (!item || typeof item.id !== 'string' || item.id.length === 0) continue; processed += 1; - // AC2 (F1): only a RUNNING pane blocks demotion — a tab whose agent has - // finished (`agent_status` done/exited) is not an active match, so the - // item is released instead of lingering in `in-progress` forever. - const hasActiveTab = isActivePaneMatch(item.id, tabs, workspaceId); + // Pane title is the active signal: a pane in the current workspace whose + // title contains the item ID means the item is being worked on; otherwise + // it is released (WL-0MV1BWETT005GSUJ). + const hasActivePane = isActivePaneMatch(item.id, panes, workspaceId); let blocked = false; - if (!hasActiveTab) { + if (!hasActivePane) { const depStep = await runStep( 'dep-list', stepTimeoutMs, @@ -708,7 +694,7 @@ export async function runHydrationOnce( depStep.value.some(isActiveBlocker); } - const decision = decideDemotion(item, { hasActivePane: hasActiveTab, blocked }); + const decision = decideDemotion(item, { hasActivePane: hasActivePane, blocked }); if (decision === null) { skipped += 1; continue; @@ -733,7 +719,7 @@ export async function runHydrationOnce( if (applyStep.status === 'ok' && applyStep.value === true) { demoted += 1; log( - `[hydrator] released ${item.id}: in-progress/${item.stage ?? '?'} → ${decision.status}/${decision.stage} (no matching tab)`, + `[hydrator] released ${item.id}: in-progress/${item.stage ?? '?'} → ${decision.status}/${decision.stage} (no matching pane)`, ); } else { skipped += 1; @@ -880,10 +866,10 @@ export function createProductionHydratorDeps( .filter((item): item is HydratorItem => item !== null); }, - listTabs: async (workspaceId?: string) => { - // Scope the tab payload to the current workspace when known so it - // does not scale with tabs elsewhere (WL-0MUY1CYV6008BQWX). - const args = ['tab', 'list']; + listActivePanes: async (workspaceId?: string) => { + // Scope the pane payload to the current workspace when known so it + // does not scale with panes elsewhere (WL-0MUY1CYV6008BQWX). + const args = ['pane', 'list']; if (typeof workspaceId === 'string' && workspaceId.length > 0) { args.push('--workspace', workspaceId); } @@ -892,17 +878,17 @@ export function createProductionHydratorDeps( args, hydratorExecOptions(8 * 1024 * 1024), ); - // Reuse the shared dispatcher-anchor parser (no second parser). The - // parser now carries `agentStatus` through so the running-pane check - // (F1) can distinguish a live agent pane from a finished one. - const parsed = parseTabListOutput(stdout); + // Reuse the shared dispatcher-anchor pane parser (no second parser): + // `parseMachinePaneListOutput` keeps each record's `label` (the pane + // title), which is the hydrator's active signal. Tolerates the + // `{ result: { panes: [...] } }` envelope and `pane_id`/`paneId` key + // variants. Fail-open: unparseable output → null (no demotion). + const parsed = parseMachinePaneListOutput(stdout); if (parsed === null) return null; - return parsed.map((tab) => ({ - tab_id: tab.tabId, - label: tab.label, - ...(tab.agentStatus !== undefined - ? { agent_status: tab.agentStatus } - : {}), + return parsed.map((pane) => ({ + pane_id: pane.paneId, + label: pane.label ?? '', + workspace_id: pane.workspaceId, })); }, diff --git a/packages/herdr/src/index.ts b/packages/herdr/src/index.ts index 9f8ef69b9..de7975253 100644 --- a/packages/herdr/src/index.ts +++ b/packages/herdr/src/index.ts @@ -41,7 +41,7 @@ import { extractJson, extractItems, } from './fetcher.js'; -import { AgentTracker, AGENT_PANES_FILE, mergeAgentStates } from './agent-tracker.js'; +import { AgentTracker, AGENT_PANES_FILE } from './agent-tracker.js'; import { isAgentCommand, buildManuallyTriggeredPaneTitle, @@ -50,7 +50,7 @@ import { stripAgentPromptPrefix, } from './pane-title.js'; import { showToast } from './notify.js'; -import { applyActiveTabMarkers, createProductionHydratorDeps } from './hydrator.js'; +import { applyActivePaneMarkers, createProductionHydratorDeps } from './hydrator.js'; import { HerdrEventSubscriber, resolveSocketPath } from './events.js'; import { runWorklistTui, getTermSize } from './worklist.js'; import { loadShortcutConfig } from './shortcut-config.js'; @@ -2094,7 +2094,19 @@ export function createDowntimeDeps( // a CAS claim whose dispatch never completed — restore the item to its // pre-claim status+stage so a future idle period can re-select it. // Race-safe (--if-status in_progress) and fail-closed (never throws). - return rollbackClaimWorkItem(itemId, original, cwd); + // + // Status-neutral audit-tier rollback (WL-0MV85WCJJ0087VMP): the audit + // claim never flips status/stage, so the rollback must not rewrite + // them either — it clears only the claiming agent's assignee, guarded + // by `--if-assignee <agent>`. Every other tier keeps the legacy + // status/stage restore and ALSO clears this claim's assignee + // (WL-0MV85WD400091OZ6), so a failed dispatch never leaves a non-empty + // assignee (a scheduling hold) behind. + return rollbackClaimWorkItem( + itemId, + { ...original, assignee }, + cwd, + ); }, // Producer-review flag (WL-0MUKYEXMK0033MFK AC3): set // `needsProducerReview = true` when an item exhausts its automatic @@ -2198,7 +2210,7 @@ export function uninitializedReport(startDir: string): string { ].join('\n') + '\n'; } -async function main(): Promise<void> { +export async function main(): Promise<void> { // Check if wl is available const wlAvailable = await checkWlAvailable(); if (!wlAvailable) { @@ -2285,9 +2297,10 @@ async function main(): Promise<void> { const currentSettings = loadSettings(); const count = clampBrowseItemCount(currentSettings.browseItemCount ?? defaultSettings.browseItemCount); const items = await fetchNextItems(count); - // Merge agent-status state into the fetched items (fail-open: no herdr - // CLI → no icons, list still works). Also covers the initial load. - await mergeAgentStates(items, agentTracker); + // The pane-existence marker (in-progress status + green-circle icon) + // is applied by the worklist's `applyPaneMarkers` seam on the initial + // load and every refresh (WL-0MV1BWETT005GSUJ); no separate + // agent-state merge runs here. return items; } catch { return []; @@ -2443,15 +2456,16 @@ async function main(): Promise<void> { // Re-read on every render so a showIcons change applies on the next // refresh (no plugin restart needed), matching showHelpText behavior. getShowIcons: () => loadSettings().showIcons ?? true, - // Merge agent-status state into freshly fetched items (top-level + - // expanded children) on every refresh cycle (WL-0MSBQUJQX005RAT9). - // Fail-open: herdr errors yield no icons; the list keeps working. - mergeAgentStates: (items) => mergeAgentStates(items, agentTracker), - // Tab-based active marker (WL-0MV1BWETT005GSUJ): stamp `in-progress` on - // items whose exact-ID tab is open in the current workspace so the - // selection list's marker reflects tab existence. Fail-open. - applyActiveTabMarkers: (items) => - applyActiveTabMarkers(items, hydratorDeps.listTabs, hydratorWorkspaceId), + // Pane-based active marker (WL-0MV1BWETT005GSUJ): stamp `in-progress` + // AND the green-circle icon on items whose work-item ID appears in a + // current-workspace pane title, so the selection list's marker and + // green circle both reflect pane existence (the producer's rule: a + // pane with the work item id as the title means the item is being + // worked; the green circle appears exactly when such a pane is open + // and never otherwise). The event-driven agent-state merge + // (WL-0MSBQUJQX005RAT9) is no longer used for row icons. Fail-open. + applyActivePaneMarkers: (items) => + applyActivePaneMarkers(items, hydratorDeps.listActivePanes, hydratorWorkspaceId), subscriber: eventSubscriber, agentTracker, modeSwitchWorker, diff --git a/packages/herdr/src/notify-dispatch.test.ts b/packages/herdr/src/notify-dispatch.test.ts index f3c0fa7b5..7461bc406 100644 --- a/packages/herdr/src/notify-dispatch.test.ts +++ b/packages/herdr/src/notify-dispatch.test.ts @@ -17,6 +17,7 @@ import type { Mock } from 'vitest'; vi.mock('./fetcher.js', () => ({ fetchActionableCount: vi.fn().mockResolvedValue(5), fetchChildrenForItem: vi.fn().mockResolvedValue([]), + fetchBlockersForItem: vi.fn().mockResolvedValue([]), getWorklogDir: vi.fn().mockReturnValue(undefined), })); diff --git a/packages/herdr/src/pane-title.ts b/packages/herdr/src/pane-title.ts index 07ca2c930..b75deff73 100644 --- a/packages/herdr/src/pane-title.ts +++ b/packages/herdr/src/pane-title.ts @@ -26,11 +26,15 @@ export function truncatePaneTitle(title: string): string { * {@link truncatePaneTitle} cuts the ID off long titles, so preserving the * suffix keeps every spawned pane's ID visible. * - * Historically this was also load-bearing for hydrator matching: the hydrator - * used to match an `in_progress` work item to a live pane by the ID embedded - * in the pane title (WL-0MSOJLZD9004P8PI). It now matches the **exact** label - * of a workspace tab (WL-0MV24RM9I0051GEF), so the pane-title suffix is no - * longer a correctness signal for demotion. + * This suffix preservation is ALSO load-bearing for the hydrator's active-pane + * detection and the selection list's green-circle icon (WL-0MV1BWETT005GSUJ): + * both match an `in_progress` work item to a live workspace pane by extracting + * the work-item ID from the pane title (the producer's rule — a pane with the + * work item id as the title means the item is being worked). The ID must + * therefore survive title truncation for the match to be reliable, which this + * helper guarantees. The same rule gives the row its green circle: an open + * pane whose title carries the ID means "being worked", and no such pane + * means no green circle. * * When the suffix alone does not fit the bound, this falls back to plain * truncation (never exceeds {@link MAX_PANE_TITLE_LENGTH}). diff --git a/packages/herdr/src/plugin-startup.test.ts b/packages/herdr/src/plugin-startup.test.ts index 2f1f30777..4cfc2380f 100644 --- a/packages/herdr/src/plugin-startup.test.ts +++ b/packages/herdr/src/plugin-startup.test.ts @@ -7,9 +7,10 @@ * `modeSwitchWorker` const that is declared AFTER the downtime worker — so * the whole plugin died before the TUI ever rendered. * - * This is an end-to-end guard: it runs the real entry point (the same command - * herdr's pane manifest uses, `npx tsx src/index.ts`) and asserts it reaches - * the TUI instead of crashing during wiring. + * This is an end-to-end guard: it runs the real entry point (the command + * herdr's pane manifest uses — `npx tsx src/start.ts`, the resilient + * launcher that wraps `src/index.ts`) and asserts it reaches the TUI + * instead of crashing during wiring. */ import { describe, it, expect } from 'vitest'; @@ -24,7 +25,7 @@ interface StartupResult { /** Run the plugin entry point and resolve with its combined output. */ function runPluginStartup(cwd: string, timeoutMs: number): Promise<StartupResult> { return new Promise((resolve) => { - const child = spawn('npx', ['tsx', 'src/index.ts'], { + const child = spawn('npx', ['tsx', 'src/start.ts'], { cwd, // stdin is closed so the TUI tears down instead of blocking on input. stdio: ['ignore', 'pipe', 'pipe'], diff --git a/packages/herdr/src/ship-guard-tui.test.ts b/packages/herdr/src/ship-guard-tui.test.ts index 787806944..b4f4c1a9b 100644 --- a/packages/herdr/src/ship-guard-tui.test.ts +++ b/packages/herdr/src/ship-guard-tui.test.ts @@ -41,6 +41,7 @@ vi.mock('./fetcher.js', async (importOriginal) => { ...actual, fetchActionableCount: vi.fn().mockResolvedValue(0), fetchChildrenForItem: vi.fn().mockResolvedValue([]), + fetchBlockersForItem: vi.fn().mockResolvedValue([]), }; }); diff --git a/packages/herdr/src/ship-it-dialog-tui.test.ts b/packages/herdr/src/ship-it-dialog-tui.test.ts index ccf3b1846..54e6d0037 100644 --- a/packages/herdr/src/ship-it-dialog-tui.test.ts +++ b/packages/herdr/src/ship-it-dialog-tui.test.ts @@ -33,6 +33,7 @@ vi.mock('./fetcher.js', async (importOriginal) => { ...actual, fetchActionableCount: vi.fn().mockResolvedValue(0), fetchChildrenForItem: vi.fn().mockResolvedValue([]), + fetchBlockersForItem: vi.fn().mockResolvedValue([]), }; }); diff --git a/packages/herdr/src/shortcut-config.test.ts b/packages/herdr/src/shortcut-config.test.ts index 942440c43..d83d4e22a 100644 --- a/packages/herdr/src/shortcut-config.test.ts +++ b/packages/herdr/src/shortcut-config.test.ts @@ -254,11 +254,13 @@ describe('loadShortcutConfig — production shortcuts.json', () => { it('marks the quiet state-change shortcuts with open_pane false (WL-0MSJLD1I70045ZUL)', () => { const registry = loadShortcutConfig(); // Chords deliberately configured to run in the background (no pane): - // audit approve/reject, priority updates, close/delete. Every other - // bundled shortcut omits the flag (default opens a pane). + // audit approve/reject, priority updates, close/delete, and quick + // create (WL-0MV6J5JXL002D8C4). Every other bundled shortcut omits the + // flag (default opens a pane). const expectedNoPane = new Set([ 'a,y', // audit approve 'a,r', // audit reject + 'c,q', // create quick 'u,p,l', // update priority low 'u,p,m', // update priority medium 'u,p,h', // update priority high diff --git a/packages/herdr/src/shortcuts.json b/packages/herdr/src/shortcuts.json index 336855d4a..568d93e82 100644 --- a/packages/herdr/src/shortcuts.json +++ b/packages/herdr/src/shortcuts.json @@ -28,7 +28,8 @@ "command": "!!wl create -t <title> -d <description> -p <priority default='medium'>", "view": "both", "label": "create quick", - "description": "Quickly create a new work item with title, description, and priority — no issue-type gating (c q)." + "description": "Quickly create a new work item with title, description, and priority — no issue-type gating (c q).", + "open_pane": false }, { "chord": [ diff --git a/packages/herdr/src/start.ts b/packages/herdr/src/start.ts new file mode 100644 index 000000000..e50d4ff03 --- /dev/null +++ b/packages/herdr/src/start.ts @@ -0,0 +1,119 @@ +/** + * packages/herdr/src/start.ts — resilient pane entry point + * (WL-0MVAY631M004RZ3Q). + * + * herdr launches the Worklog pane with `npx tsx src/start.ts` (see + * herdr-plugin.toml). This launcher dynamically imports the real plugin + * entry (`index.ts`, via its compiled `./index.js` form) so that + * module-LOAD failures are catchable. + * + * Why this exists: the plugin imports named exports from the shared + * `@worklog/shared/icons` package. When that package's compiled output is + * STALE (built before the current source — or a vendored copy from before + * an export was added), the ESM linker throws at instantiation: + * + * SyntaxError: The requested module '@worklog/shared/icons' does not + * provide an export named 'needsProducerReviewIcon' + * + * A static top-level import crashes the whole pane before `main()` (and its + * `.catch`) ever runs, so herdr closed the pane with no explanation. By + * importing the entry dynamically we can catch that failure, print an + * actionable diagnostic ("run `npm run build`"), and keep the pane open for + * the user to read when stdin is a TTY. + * + * The launcher is transparent on the happy path: it just awaits the real + * `main()` exactly as index.ts would. + */ + +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Real plugin entry, resolved relative to this file (index.ts under tsx, index.js when compiled). */ +const DEFAULT_ENTRY = './index.js'; + +/** + * Format a startup failure into an actionable diagnostic. + * + * The ESM missing-export case gets an explanation plus the exact rebuild + * command; every other failure keeps its original message plus the same + * rebuild hint, so nothing is ever swallowed silently. + */ +export function formatStartupError(err: unknown): string { + const detail = err instanceof Error ? err.message : String(err); + const isMissingExport = /does not provide an export named/i.test(detail); + const lines: string[] = ['', ' ⚠ Worklog plugin failed to start.']; + if (isMissingExport) { + lines.push( + '', + ' The plugin loaded a STALE copy of the shared Worklog code:', + ` ${detail}`, + '', + ' This usually means `packages/shared/dist` was built before the', + ' current source. Rebuild and re-link from the repository root:', + '', + ' npm run build', + '', + ' Then reopen the Worklog tab.', + ); + } else { + lines.push('', ` ${detail}`); + if (!/npm run build/.test(detail)) { + lines.push( + '', + ' Rebuild from the repository root with `npm run build` and reopen', + ' the Worklog tab. If the problem persists, refresh the plugin', + ' dependencies with `npm install --prefix packages/herdr`.', + ); + } + } + lines.push(''); + return lines.join('\n'); +} + +/** When attached to a real terminal, hold the pane open until dismissed. */ +async function waitForDismiss(): Promise<void> { + if (!process.stdin.isTTY) return; + process.stderr.write('\n [worklog-plugin] Press Enter to close this pane.\n'); + await new Promise<void>((resolve0) => { + process.stdin.resume(); + process.stdin.once('data', () => resolve0()); + process.stdin.once('end', () => resolve0()); + }); +} + +/** + * Launch the real plugin entry, surfacing module-load errors. + * + * `WORKLOG_PLUGIN_ENTRY` may override the entry (used by tests to point at + * fixtures); production always uses ./index.js. + */ +export async function launch(entry = process.env.WORKLOG_PLUGIN_ENTRY || DEFAULT_ENTRY): Promise<void> { + try { + const mod = (await import(entry)) as { main?: () => Promise<void> | void }; + if (typeof mod.main === 'function') { + await mod.main(); + } else { + throw new Error( + `entry module '${entry}' does not export main() — the plugin build is incomplete; run 'npm run build'.`, + ); + } + } catch (err) { + process.stderr.write(formatStartupError(err)); + process.exitCode = 1; + await waitForDismiss(); + } +} + +// Only auto-run when this module is the entry point (launched directly by +// herdr/tsx), not when imported by tests. +const isEntryPoint = (() => { + try { + return !!process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]); + } catch { + return false; + } +})(); + +if (isEntryPoint) { + void launch(); +} diff --git a/packages/herdr/src/test-utils/mock-herdr-socket.ts b/packages/herdr/src/test-utils/mock-herdr-socket.ts index fa95dcabc..ac0ae2323 100644 --- a/packages/herdr/src/test-utils/mock-herdr-socket.ts +++ b/packages/herdr/src/test-utils/mock-herdr-socket.ts @@ -49,6 +49,13 @@ export interface MockClient { pushEvents(events: HerdrEventFrame[]): void; /** Close the client connection. */ close(): void; + /** + * Every request frame the client sent on THIS connection, in order + * (id, method, params). Used to assert the one-request-per-connection + * discipline: a healthy subscriber sends exactly one `events.subscribe` + * per socket (WL-0MVAS988R008PNH6). + */ + requests: Array<{ id: string | number | null; method: string; params: unknown }>; } /** @@ -112,6 +119,39 @@ export class MockHerdrSocket { */ private handleConnection(socket: net.Socket): void { let buffer = ''; + // Created up front so every request frame (including the initial + // events.subscribe) is recorded on the MockClient.requests array. + const requests: Array<{ id: string | number | null; method: string; params: unknown }> = []; + const client: MockClient = { + sendFrame: (f: string) => { + socket.write(f + '\n'); + }, + pushEvent: (e: HerdrEventFrame) => { + this.sendToClient(socket, { + jsonrpc: '2.0', + id: null, + notification: true, + data: e.data, + event: e.event, + }); + }, + pushEvents: (events: HerdrEventFrame[]) => { + for (const e of events) { + socket.write(JSON.stringify({ + jsonrpc: '2.0', + id: null, + notification: true, + data: e.data, + event: e.event, + }) + '\n'); + } + }, + close: () => { + socket.end(); + }, + requests, + }; + this.clients.add(client); socket.on('data', (chunk: Buffer) => { buffer += chunk.toString(); @@ -132,6 +172,15 @@ export class MockHerdrSocket { continue; } + if (!request || typeof request !== 'object') continue; + const req = request as Record<string, unknown>; + if (typeof req.method !== 'string') continue; + requests.push({ + id: (typeof req.id === 'string' || typeof req.id === 'number') ? req.id : null, + method: req.method, + params: req.params, + }); + if (!this.isSubscribeRequest(request)) { // Not an events.subscribe request — send an error this.sendToClient(socket, { @@ -148,44 +197,13 @@ export class MockHerdrSocket { id: this.extractId(request), result: { type: 'subscription_started' }, }); - - // Register this socket as a connected client - const client: MockClient = { - sendFrame: (f: string) => { - socket.write(f + '\n'); - }, - pushEvent: (e: HerdrEventFrame) => { - this.sendToClient(socket, { - jsonrpc: '2.0', - id: null, - notification: true, - data: e.data, - event: e.event, - }); - }, - pushEvents: (events: HerdrEventFrame[]) => { - for (const e of events) { - socket.write(JSON.stringify({ - jsonrpc: '2.0', - id: null, - notification: true, - data: e.data, - event: e.event, - }) + '\n'); - } - }, - close: () => { - socket.end(); - }, - }; - this.clients.add(client); - - socket.on('close', () => { - this.clients.delete(client); - }); } }); + socket.on('close', () => { + this.clients.delete(client); + }); + socket.on('error', () => { // Socket error — will be cleaned up by close }); diff --git a/packages/herdr/src/worklist-active-tab-marker.test.ts b/packages/herdr/src/worklist-active-pane-marker.test.ts similarity index 58% rename from packages/herdr/src/worklist-active-tab-marker.test.ts rename to packages/herdr/src/worklist-active-pane-marker.test.ts index 81bd69765..bada88843 100644 --- a/packages/herdr/src/worklist-active-tab-marker.test.ts +++ b/packages/herdr/src/worklist-active-pane-marker.test.ts @@ -1,17 +1,18 @@ /** - * packages/herdr/src/worklist-active-tab-marker.test.ts — Integration tests - * for the tab-based in-progress marker in the selection list + * packages/herdr/src/worklist-active-pane-marker.test.ts — Integration tests + * for the pane-title-based in-progress marker in the selection list * (WL-0MV1BWETT005GSUJ). * - * The producer rejected the hydrator-only fix: "I see no markers on active - * items that have open panes." The selection list's in-progress marker must - * therefore reflect **tab existence**, not only the worklog status the - * demotion loop maintains. `runWorklistTui` accepts an `applyActiveTabMarkers` - * seam (injected by `index.ts`; a no-op in tests); the refresh cycle marks - * every item with an exact-match tab in the current workspace as `in-progress` - * before rendering. + * The producer rejected the tab-label fix as unreliable and directed a single + * simple rule: "Look for a pane in this workspace with the work item id as + * the title. If it exists then work item is busy." The selection list's + * in-progress marker must therefore reflect **pane-title existence**, not + * only the worklog status the demotion loop maintains. `runWorklistTui` + * accepts an `applyActivePaneMarkers` seam (injected by `index.ts`; a no-op + * in tests); the refresh cycle marks every item whose work-item ID appears in + * a current-workspace pane title as `in-progress` before rendering. * - * Run: npx vitest run packages/herdr/src/worklist-active-tab-marker.test.ts + * Run: npx vitest run packages/herdr/src/worklist-active-pane-marker.test.ts */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -32,16 +33,19 @@ vi.mock('./notify.js', () => ({ import { runWorklistTui } from './worklist.js'; import { setExecFileAsync, resetExecFileAsync } from './fetcher.js'; import type { WorkItem } from './fetcher.js'; -import { applyActiveTabMarkers as applyTabMarkersToItems, type HydratorTab } from './hydrator.js'; +import { applyActivePaneMarkers as applyPaneMarkersToItems, type HydratorPane } from './hydrator.js'; const IN_PROGRESS_ICON = '\u{1F504}'; // 🔄 const OPEN_ICON = '\u{1F513}'; // 🔓 +const GREEN_CIRCLE = '\u{1F7E2}'; // 🟢 — green circle (work-item pane open) -// Realistic work-item IDs: `collectTabWorkItemIds` only matches labels that -// look exactly like a work-item ID (prefix + dash + 6+ base36 chars). +// Realistic work-item IDs (prefix + dash + 6+ base36 chars). const ITEM = 'WL-0MV1BWETT005GSUJ'; const OTHER_ITEM = 'WL-0MV24T2VJ007Q49J'; +/** A realistic dispatched pane title: the ID survives as a trailing suffix. */ +const titleFor = (id: string): string => `Downtime triggered implement Foo - ${id}`; + let dataHandler: ((chunk: Buffer) => void) | undefined; let writes: string[]; @@ -105,53 +109,57 @@ async function quit(p: Promise<unknown>): Promise<void> { await p; } -describe('worklist — tab-based in-progress marker', () => { - it('shows the in-progress marker for a worklog-open item with a matching workspace tab', async () => { +describe('worklist — pane-title-based in-progress marker', () => { + it('shows the in-progress marker for a worklog-open item with a matching workspace pane title', async () => { vi.useFakeTimers(); process.env.HERDR_PANE_ID = 'w1:pCM'; process.env.HERDR_WORKSPACE_ID = 'w1'; setExecFileAsync(makeExecMock() as any); const fetcher = vi.fn(async (): Promise<WorkItem[]> => [ - { id: ITEM, title: 'Tab is open', status: 'open', stage: 'plan_complete' }, - ]); - const listTabs = vi.fn(async (_workspaceId?: string): Promise<HydratorTab[]> => [ - { tab_id: 'w1:t1', label: ITEM, workspace_id: 'w1' }, + { id: ITEM, title: 'Pane is open', status: 'open', stage: 'plan_complete' }, ]); + const listActivePanes = vi.fn( + async (_workspaceId?: string): Promise<HydratorPane[]> => [ + { pane_id: 'w1:p1', label: titleFor(ITEM), workspace_id: 'w1' }, + ], + ); const p = runWorklistTui(fetcher, [], undefined, { autoRefresh: true, refreshIntervalMs: 30_000, autoSync: false, showHelpText: false, - applyActiveTabMarkers: (items) => applyTabMarkersToItems(items, listTabs, 'w1'), + applyActivePaneMarkers: (items) => applyPaneMarkersToItems(items, listActivePanes, 'w1'), }); await vi.advanceTimersByTimeAsync(0); - // First auto-refresh applies the tab marker before rendering. + // First auto-refresh applies the pane marker before rendering. await vi.advanceTimersByTimeAsync(30_000); const output = writes.join(''); expect(output).toContain(ITEM); expect(output).toContain(IN_PROGRESS_ICON); expect(output).not.toContain(OPEN_ICON); - // The tab lookup is scoped to the current workspace. - expect(listTabs).toHaveBeenCalledWith('w1'); + // The open pane also shows the green-circle icon (agent slot). + expect(output).toContain(GREEN_CIRCLE); + // The pane lookup is scoped to the current workspace. + expect(listActivePanes).toHaveBeenCalledWith('w1'); await quit(p); }); - it('keeps the open marker when no tab matches the item', async () => { + it('keeps the open marker when no pane title matches the item', async () => { vi.useFakeTimers(); process.env.HERDR_PANE_ID = 'w1:pCM'; process.env.HERDR_WORKSPACE_ID = 'w1'; setExecFileAsync(makeExecMock() as any); const fetcher = vi.fn(async (): Promise<WorkItem[]> => [ - { id: OTHER_ITEM, title: 'No tab open', status: 'open', stage: 'plan_complete' }, + { id: OTHER_ITEM, title: 'No pane open', status: 'open', stage: 'plan_complete' }, ]); - const listTabs = vi.fn(async (): Promise<HydratorTab[]> => [ - { tab_id: 'w1:t1', label: ITEM, workspace_id: 'w1' }, + const listActivePanes = vi.fn(async (): Promise<HydratorPane[]> => [ + { pane_id: 'w1:p1', label: titleFor(ITEM), workspace_id: 'w1' }, ]); const p = runWorklistTui(fetcher, [], undefined, { @@ -159,7 +167,7 @@ describe('worklist — tab-based in-progress marker', () => { refreshIntervalMs: 30_000, autoSync: false, showHelpText: false, - applyActiveTabMarkers: (items) => applyTabMarkersToItems(items, listTabs, 'w1'), + applyActivePaneMarkers: (items) => applyPaneMarkersToItems(items, listActivePanes, 'w1'), }); await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(30_000); @@ -168,21 +176,24 @@ describe('worklist — tab-based in-progress marker', () => { expect(output).toContain(OTHER_ITEM); expect(output).toContain(OPEN_ICON); expect(output).not.toContain(IN_PROGRESS_ICON); + // No matching pane → no green circle (never appears without an open pane). + expect(output).not.toContain(GREEN_CIRCLE); await quit(p); }); - it('does not mark an item whose tab is in another workspace', async () => { + it('does not mark an item whose pane is in another workspace', async () => { vi.useFakeTimers(); process.env.HERDR_PANE_ID = 'w1:pCM'; process.env.HERDR_WORKSPACE_ID = 'w1'; setExecFileAsync(makeExecMock() as any); + const OTHER = 'WL-0MV25EE4E0028KDI'; const fetcher = vi.fn(async (): Promise<WorkItem[]> => [ - { id: 'WL-0MV25EE4E0028KDI', title: 'Tab elsewhere', status: 'open', stage: 'plan_complete' }, + { id: OTHER, title: 'Pane elsewhere', status: 'open', stage: 'plan_complete' }, ]); - const listTabs = vi.fn(async (): Promise<HydratorTab[]> => [ - { tab_id: 'w2:t1', label: 'WL-0MV25EE4E0028KDI', workspace_id: 'w2' }, + const listActivePanes = vi.fn(async (): Promise<HydratorPane[]> => [ + { pane_id: 'w2:p1', label: titleFor(OTHER), workspace_id: 'w2' }, ]); const p = runWorklistTui(fetcher, [], undefined, { @@ -190,7 +201,7 @@ describe('worklist — tab-based in-progress marker', () => { refreshIntervalMs: 30_000, autoSync: false, showHelpText: false, - applyActiveTabMarkers: (items) => applyTabMarkersToItems(items, listTabs, 'w1'), + applyActivePaneMarkers: (items) => applyPaneMarkersToItems(items, listActivePanes, 'w1'), }); await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(30_000); @@ -202,42 +213,44 @@ describe('worklist — tab-based in-progress marker', () => { await quit(p); }); - it('clears the marker within one hydrator cadence when the tab closes (no DB change, no wl refetch)', async () => { + it('clears the marker within one hydrator cadence when the pane closes (no DB change, no wl refetch)', async () => { vi.useFakeTimers(); process.env.HERDR_PANE_ID = 'w1:pCM'; process.env.HERDR_WORKSPACE_ID = 'w1'; setExecFileAsync(makeExecMock() as any); const fetcher = vi.fn(async (): Promise<WorkItem[]> => [ - { id: ITEM, title: 'Tab is open', status: 'open', stage: 'plan_complete' }, + { id: ITEM, title: 'Pane is open', status: 'open', stage: 'plan_complete' }, ]); - // Mutable tab set: the item starts with an exact-ID tab, then the tab is - // closed while the worklog DB stays untouched. - let tabs: HydratorTab[] = [{ tab_id: 'w1:t1', label: ITEM, workspace_id: 'w1' }]; - const listTabs = vi.fn(async (): Promise<HydratorTab[]> => tabs); + // Mutable pane set: the item starts with a matching pane title, then the + // pane is closed while the worklog DB stays untouched. + let panes: HydratorPane[] = [{ pane_id: 'w1:p1', label: titleFor(ITEM), workspace_id: 'w1' }]; + const listActivePanes = vi.fn(async (): Promise<HydratorPane[]> => panes); const p = runWorklistTui( fetcher, - [{ id: ITEM, title: 'Tab is open', status: 'open', stage: 'plan_complete' }], + [{ id: ITEM, title: 'Pane is open', status: 'open', stage: 'plan_complete' }], undefined, { autoRefresh: true, - // Longer than the marker cadence so ONLY the new active-tab-marker + // Longer than the marker cadence so ONLY the new active-pane-marker // task (not the DB-gated refresh tick) can update the marker here. refreshIntervalMs: 5 * 60_000, autoSync: false, showHelpText: false, - applyActiveTabMarkers: (items) => applyTabMarkersToItems(items, listTabs, 'w1'), + applyActivePaneMarkers: (items) => applyPaneMarkersToItems(items, listActivePanes, 'w1'), }, ); await vi.advanceTimersByTimeAsync(0); - // Initial load marks the item (exact-ID tab is open). + // Initial load marks the item (matching pane title is open) — in-progress + // marker AND green circle. expect(writes.join('')).toContain(IN_PROGRESS_ICON); + expect(writes.join('')).toContain(GREEN_CIRCLE); - // Close the tab; the next marker cadence must clear the marker even though - // no worklog write (DB change) happened and the refresh tick is disabled - // for the duration of this test. - tabs = []; + // Close the pane; the next marker cadence must clear BOTH the marker and + // the green circle even though no worklog write (DB change) happened and + // the refresh tick is disabled for the duration of this test. + panes = []; writes.length = 0; await vi.advanceTimersByTimeAsync(30_000); @@ -245,7 +258,8 @@ describe('worklist — tab-based in-progress marker', () => { expect(output).toContain(ITEM); expect(output).toContain(OPEN_ICON); expect(output).not.toContain(IN_PROGRESS_ICON); - expect(listTabs.mock.calls.length).toBeGreaterThan(1); + expect(output).not.toContain(GREEN_CIRCLE); + expect(listActivePanes.mock.calls.length).toBeGreaterThan(1); await quit(p); }); diff --git a/packages/herdr/src/worklist-agent-state-event.test.ts b/packages/herdr/src/worklist-agent-state-event.test.ts index 6aea856fd..6c64ab02a 100644 --- a/packages/herdr/src/worklist-agent-state-event.test.ts +++ b/packages/herdr/src/worklist-agent-state-event.test.ts @@ -1,16 +1,26 @@ /** * packages/herdr/src/worklist-agent-state-event.test.ts — Integration tests - * for the EVENT-DRIVEN agent-status + focus/visibility paths - * (WL-0MSHB7DHO004RHBJ F4/F5/F6). + * for the EVENT-DRIVEN pane/agent paths (WL-0MSHB7DHO004RHBJ) under the + * pane-existence green-circle semantics (WL-0MV1BWETT005GSUJ). + * + * The row's green-circle icon (agent slot) is **pane-title existence**: a + * pane in the current workspace whose title carries the work-item ID means + * the item is being worked → 🟢 green circle; otherwise no icon. The + * event-driven agent-status tracker is no longer the icon source (a pane + * absent from `herdr agent list` — freshly spawned/`unknown` — used to be + * pruned and lose its icon permanently; an agent `done` state used to prune + * the icon even with the pane still open). Pane/agent lifecycle events + * merely TRIGGER a pane-marker refresh so the icon tracks open/closed panes + * without waiting for the 30 s cadence. * * Verifies end-to-end that a mocked herdr event socket drives: - * - `pane_agent_status_changed` → immediate icon update (no `herdr agent - * list` exec), coalesced per render cycle. + * - `pane_agent_status_changed` → pane-marker refresh: the green circle + * appears iff a pane title in this workspace carries the item ID, and + * NEVER from the agent status alone (no `herdr agent list` exec). * - `pane_agent_detected` → re-read of the shared `.worklog/agent-panes.json` - * (cross-instance / late-spawned agents) + per-pane subscription sync. - * - `pane_closed` / `pane_exited` → prune associations + icon clear. - * - `pane_focused` → immediate visibility update (no `herdr tab get`) and - * refresh on hidden → visible, clearing the paused indicator. + * (cross-instance / late-spawned agents) + a pane-marker refresh. + * - `pane_closed` / `pane_exited` → pane-marker refresh clears the green + * circle when the pane leaves the pane list. * * Uses the real events.ts + mock-herdr-socket fixture + real fetcher exec * seam. Run: npx vitest run packages/herdr/src/worklist-agent-state-event.test.ts @@ -34,6 +44,10 @@ import { setExecFileAsync, resetExecFileAsync } from './fetcher.js'; import { AgentTracker } from './agent-tracker.js'; import { HerdrEventSubscriber } from './events.js'; import { createMockSocket, type MockHerdrSocket } from './test-utils/mock-herdr-socket.js'; +import { + applyActivePaneMarkers as applyPaneMarkersToItems, + type HydratorPane, +} from './hydrator.js'; import type { WorkItem } from './fetcher.js'; let dataHandler: ((chunk: Buffer) => void) | undefined; @@ -97,6 +111,7 @@ beforeEach(() => { process.env.HERDR_TAB_ID = 'w1:t11'; process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_WORKSPACE_ID = 'w1'; delete process.env.HERDR_BIN_PATH; }); @@ -104,11 +119,12 @@ afterEach(() => { resetExecFileAsync(); delete process.env.HERDR_TAB_ID; delete process.env.HERDR_PANE_ID; + delete process.env.HERDR_WORKSPACE_ID; delete process.env.HERDR_BIN_PATH; vi.restoreAllMocks(); }); -/** Exec mock that reports a visible tab and empty wl results. */ +/** Exec mock that reports a visible tab, empty wl results, and NOTHING else. */ function makeVisibleExecMock(): Mock { return vi.fn(async (bin: string, args: string[]) => { if (bin === 'herdr' && args[0] === 'tab' && args[1] === 'get') { @@ -150,49 +166,142 @@ async function waitForClient( return client; } -const GREEN_CIRCLE = '\u{1F7E2}'; // 🟢 working -const RED_CIRCLE = '\u{26D4}'; // ⛔ blocked -const WHITE_CIRCLE = '\u{26AA}'; // ⚪ idle +/** + * Poll a predicate until it is true or the timeout elapses — never wait a + * fixed interval for async work to land. The event → render round-trip is + * microtask-scheduled after socket data arrives, so a fixed 100 ms sleep is + * flaky under heavy concurrent load. + */ +async function waitFor( + predicate: () => boolean, + timeoutMs = 5_000, + description = 'condition', +): Promise<void> { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error(`timed out after ${timeoutMs}ms waiting for ${description}`); + } + await sleep(10); + } +} + +/** + * Wait until the captured TUI output satisfies `predicate`, polling instead + * of sleeping a fixed interval. Positive checks (`toContain`) must not race + * the microtask-scheduled event render; negative checks must not pass + * before the clearing render has landed. + */ +async function waitForWrites( + predicate: (captured: string[]) => boolean, + timeoutMs = 5_000, + description = 'TUI output condition', +): Promise<void> { + const start = Date.now(); + while (!predicate(writes)) { + if (Date.now() - start > timeoutMs) { + throw new Error( + `timed out after ${timeoutMs}ms waiting for ${description}; last output: ` + + JSON.stringify(writes.join('').slice(-200)), + ); + } + await sleep(10); + } +} -describe('worklist — event-driven agent status (WL-0MSHB7DHO004RHBJ)', () => { - it('a pane_agent_status_changed event updates the icon with no agent-list exec', async () => { +const GREEN_CIRCLE = '\u{1F7E2}'; // 🟢 — green circle (work-item pane open) +const RED_CIRCLE = '\u{26D4}'; // ⛔ — must never appear from a blocked agent + +/** A realistic dispatched pane title carrying the item ID as a suffix. */ +const titleFor = (id: string): string => `Manually triggered implement Foo - ${id}`; + +/** + * Mutable holder for the workspace pane set, so tests can swap the pane list + * and have the injected seam observe the NEW value (a plain `let` captured by + * the closure would freeze the array at option-construction time). + */ +interface PaneSetHolder { + panes: HydratorPane[]; +} + +function markerOptions( + holder: PaneSetHolder, + subscriber: HerdrEventSubscriber | null, + tracker: AgentTracker | null, +): ConstructorParameters<typeof runWorklistTui>[3] { + return { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + subscriber, + agentTracker: tracker, + applyActivePaneMarkers: (items) => applyPaneMarkersToItems(items, async () => holder.panes, 'w1'), + }; +} + +/** + * Build a subscriber exactly like the production wiring (packages/herdr/src/ + * index.ts): the initial tracked pane set is seeded from the agent tracker's + * shared state file BEFORE the first connect, so the initial events.subscribe + * already covers the known panes (WL-0MVAS988R008PNH6 — one request per + * connection: the pane set cannot be changed on a live socket, so anything + * not in the initial subscribe triggers a fresh connection). + */ +function makeSubscriber( + server: MockHerdrSocket, + tracker: AgentTracker, +): HerdrEventSubscriber { + return new HerdrEventSubscriber({ + socketPath: server.getAddress(), + callbacks: {}, + trackedPaneIds: tracker.snapshot().map((e) => e.paneId), + }); +} + +describe('worklist — event paths under pane-existence green circles (WL-0MV1BWETT005GSUJ)', () => { + it('a pane_agent_status_changed event refreshes the pane list: green circle iff a matching pane title exists (no agent-list exec)', async () => { const sf = makeStateFile(); - seedStateFile(sf.path, [{ workItemId: 'WL-1', paneId: 'w3:p99' }]); + seedStateFile(sf.path, [{ workItemId: 'WL-0MV1BWETT005GSUJ', paneId: 'w3:p99' }]); const tracker = new AgentTracker({ stateFile: sf.path, ttlMs: 60_000 }); const mockServer: MockHerdrSocket = await createMockSocket(); - const subscriber = new HerdrEventSubscriber({ socketPath: mockServer.getAddress(), callbacks: {} }); + const subscriber = makeSubscriber(mockServer, tracker); const execMock = makeVisibleExecMock(); setExecFileAsync(execMock as any); const fetcher = vi.fn(async (): Promise<WorkItem[]> => [ - { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea' }, + { id: 'WL-0MV1BWETT005GSUJ', title: 'Task one', status: 'open', stage: 'idea' }, ]); - const p = runWorklistTui(fetcher, undefined, undefined, { - autoRefresh: true, - refreshIntervalMs: 30_000, - autoSync: false, - showHelpText: false, - subscriber, - agentTracker: tracker, - mergeAgentStates: async () => {}, - }); - + // The workspace pane set starts WITHOUT a matching pane: no green circle + // even after a `working` status event (the icon is pane existence, not + // agent status). + const holder: PaneSetHolder = { panes: [] }; + const p = runWorklistTui(fetcher, undefined, undefined, markerOptions(holder, subscriber, tracker)); const client = await waitForClient(mockServer); - // No icon before any state is seeded. expect(writes.join('')).not.toContain(GREEN_CIRCLE); - // Push an agent status change → the icon appears immediately. client!.pushEvent({ event: 'pane_agent_status_changed', data: { pane_id: 'w3:p99', agent_status: 'working' }, }); - await sleep(100); + await sleep(50); + expect(writes.join('')).not.toContain(GREEN_CIRCLE); + expect(writes.join('')).not.toContain(RED_CIRCLE); - const output = writes.join(''); - expect(output).toContain(GREEN_CIRCLE); - // The event path must NOT spawn `herdr agent list` (only tab get + wl). + // A pane whose title carries the item ID appears → the next pane-marker + // refresh (triggered by an event, no 30 s wait) shows the green circle. + holder.panes = [{ pane_id: 'w3:p99', label: titleFor('WL-0MV1BWETT005GSUJ'), workspace_id: 'w1' }]; + client!.pushEvent({ + event: 'pane_agent_status_changed', + data: { pane_id: 'w3:p99', agent_status: 'blocked' }, + }); + await waitForWrites((w) => w.join('').includes(GREEN_CIRCLE), 5_000, 'GREEN_CIRCLE after pane appears'); + expect(writes.join('')).not.toContain(RED_CIRCLE); + + // The icon path must NOT spawn `herdr agent list` (only tab get + pane + // marker refreshes triggered by events). expect( execMock.mock.calls.filter((c) => c[0] === 'herdr' && c[1]?.[0] === 'agent'), ).toHaveLength(0); @@ -203,36 +312,31 @@ describe('worklist — event-driven agent status (WL-0MSHB7DHO004RHBJ)', () => { sf.cleanup(); }); - it('pane_agent_status_changed flips an existing icon to blocked', async () => { + it('a status event alone — without a matching pane title — never shows the green circle', async () => { const sf = makeStateFile(); + seedStateFile(sf.path, [{ workItemId: 'WL-0MV1BWETT005GSUJ', paneId: 'w3:p99' }]); + const tracker = new AgentTracker({ stateFile: sf.path, ttlMs: 60_000 }); const mockServer = await createMockSocket(); - const subscriber = new HerdrEventSubscriber({ socketPath: mockServer.getAddress(), callbacks: {} }); + const subscriber = makeSubscriber(mockServer, tracker); setExecFileAsync(makeVisibleExecMock() as any); const fetcher = vi.fn(async (): Promise<WorkItem[]> => [ - { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea' }, + { id: 'WL-0MV1BWETT005GSUJ', title: 'Task one', status: 'open', stage: 'idea' }, ]); - const p = runWorklistTui(fetcher, undefined, undefined, { - autoRefresh: true, - refreshIntervalMs: 30_000, - autoSync: false, - showHelpText: false, - subscriber, - agentTracker: tracker, - mergeAgentStates: async () => {}, - }); + // The only pane in the workspace carries a DIFFERENT item's ID. + const holder: PaneSetHolder = { + panes: [{ pane_id: 'w3:p99', label: titleFor('WL-0MV24T2VJ007Q49J'), workspace_id: 'w1' }], + }; + const p = runWorklistTui(fetcher, undefined, undefined, markerOptions(holder, subscriber, tracker)); const client = await waitForClient(mockServer); - // Seed the association at runtime (as if dispatch recorded it). - await tracker.recordAgentForWorkItem('WL-1', 'w3:p99'); - await sleep(20); client!.pushEvent({ event: 'pane_agent_status_changed', data: { pane_id: 'w3:p99', agent_status: 'blocked' }, }); - await sleep(100); - - expect(writes.join('')).toContain(RED_CIRCLE); + await sleep(50); + expect(writes.join('')).not.toContain(GREEN_CIRCLE); + expect(writes.join('')).not.toContain(RED_CIRCLE); await subscriber.close(); await mockServer.stop(); @@ -240,42 +344,39 @@ describe('worklist — event-driven agent status (WL-0MSHB7DHO004RHBJ)', () => { sf.cleanup(); }); - it('a pane_closed event prunes the association and clears the icon', async () => { + it('a pane_closed event clears the green circle when the matching pane leaves the pane list', async () => { const sf = makeStateFile(); - seedStateFile(sf.path, [{ workItemId: 'WL-1', paneId: 'w3:p99' }]); + seedStateFile(sf.path, [{ workItemId: 'WL-0MV1BWETT005GSUJ', paneId: 'w3:p99' }]); const tracker = new AgentTracker({ stateFile: sf.path, ttlMs: 60_000 }); const mockServer = await createMockSocket(); - const subscriber = new HerdrEventSubscriber({ socketPath: mockServer.getAddress(), callbacks: {} }); + const subscriber = makeSubscriber(mockServer, tracker); setExecFileAsync(makeVisibleExecMock() as any); const fetcher = vi.fn(async (): Promise<WorkItem[]> => [ - { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea' }, + { id: 'WL-0MV1BWETT005GSUJ', title: 'Task one', status: 'open', stage: 'idea' }, ]); - const p = runWorklistTui(fetcher, undefined, undefined, { - autoRefresh: true, - refreshIntervalMs: 30_000, - autoSync: false, - showHelpText: false, - subscriber, - agentTracker: tracker, - mergeAgentStates: async () => {}, - }); + const holder: PaneSetHolder = { + panes: [{ pane_id: 'w3:p99', label: titleFor('WL-0MV1BWETT005GSUJ'), workspace_id: 'w1' }], + }; + const p = runWorklistTui(fetcher, undefined, undefined, markerOptions(holder, subscriber, tracker)); const client = await waitForClient(mockServer); - // Seed a working icon VIA THE EVENT PATH so the renderer re-applies - // the cached state (a direct tracker call never triggers a render). - client!.pushEvent({ - event: 'pane_agent_status_changed', - data: { pane_id: 'w3:p99', agent_status: 'working' }, - }); - await sleep(100); - expect(writes.join('')).toContain(GREEN_CIRCLE); + await waitForWrites((w) => w.join('').includes(GREEN_CIRCLE), 5_000, 'GREEN_CIRCLE initial'); - // A pane_closed event prunes → icon clears. + // The pane closes (leaves the pane list) → pane_closed triggers a marker + // refresh and the green circle clears — even though the tracker still + // holds the association until pruning. + holder.panes = []; + const paneClosedMark = writes.length; client!.pushEvent({ event: 'pane_closed', data: { pane_id: 'w3:p99' } }); - await sleep(100); - writes.length = 0; // only inspect output produced AFTER the event - expect(writes.join('')).not.toContain(GREEN_CIRCLE); + await waitForWrites( + (w) => w.length > paneClosedMark && !w.slice(paneClosedMark).join('').includes(GREEN_CIRCLE), + 5_000, + 'GREEN_CIRCLE cleared after pane_closed', + ); + expect(writes.slice(paneClosedMark).join('')).not.toContain(GREEN_CIRCLE); + // The closing event still prunes the association bookkeeping. + expect(tracker.getPaneId('WL-0MV1BWETT005GSUJ')).toBeUndefined(); await subscriber.close(); await mockServer.stop(); @@ -287,40 +388,38 @@ describe('worklist — event-driven agent status (WL-0MSHB7DHO004RHBJ)', () => { const sf = makeStateFile(); const tracker = new AgentTracker({ stateFile: sf.path, ttlMs: 60_000 }); const mockServer = await createMockSocket(); - const subscriber = new HerdrEventSubscriber({ socketPath: mockServer.getAddress(), callbacks: {} }); + const subscriber = makeSubscriber(mockServer, tracker); setExecFileAsync(makeVisibleExecMock() as any); const fetcher = vi.fn(async (): Promise<WorkItem[]> => [ - { id: 'WL-LATE', title: 'Late task', status: 'open', stage: 'idea' }, + { id: 'WL-0MV25EE4E0028KDI', title: 'Late task', status: 'open', stage: 'idea' }, ]); - const p = runWorklistTui(fetcher, undefined, undefined, { - autoRefresh: true, - refreshIntervalMs: 30_000, - autoSync: false, - showHelpText: false, - subscriber, - agentTracker: tracker, - mergeAgentStates: async () => {}, - }); + const holder: PaneSetHolder = { panes: [] }; + const p = runWorklistTui(fetcher, undefined, undefined, markerOptions(holder, subscriber, tracker)); const client = await waitForClient(mockServer); // Another instance records a late association to the shared file. - seedStateFile(sf.path, [{ workItemId: 'WL-LATE', paneId: 'w5:p55' }]); + seedStateFile(sf.path, [{ workItemId: 'WL-0MV25EE4E0028KDI', paneId: 'w5:p55' }]); await sleep(20); - // A pane_agent_detected event triggers the re-read. + // A pane_agent_detected event triggers the re-read. Subscribing the + // late pane re-establishes the subscription on a FRESH connection + // (WL-0MVAS988R008PNH6: herdr accepts one events.subscribe per + // connection), so the status event is pushed on the CURRENT client. client!.pushEvent({ event: 'pane_agent_detected', data: { pane_id: 'w5:p55' } }); - await sleep(100); - - // The association is now visible to this instance. - expect(tracker.getPaneId('WL-LATE')).toBe('w5:p55'); - - // And a status event for that late pane updates the icon. - client!.pushEvent({ + // Poll, never fixed-sleep: the re-read is async (event → tracker). + await waitFor(() => tracker.getPaneId('WL-0MV25EE4E0028KDI') === 'w5:p55', 5_000, 'late association re-read'); + expect(tracker.getPaneId('WL-0MV25EE4E0028KDI')).toBe('w5:p55'); + + // With the late pane now in the pane list, the green circle appears for + // the item (regardless of the agent's status value). + holder.panes = [{ pane_id: 'w5:p55', label: titleFor('WL-0MV25EE4E0028KDI'), workspace_id: 'w1' }]; + const liveClient = mockServer.getFirstClient(); + expect(liveClient).toBeDefined(); + liveClient!.pushEvent({ event: 'pane_agent_status_changed', - data: { pane_id: 'w5:p55', agent_status: 'idle' }, + data: { pane_id: 'w5:p55', agent_status: 'done' }, }); - await sleep(100); - expect(writes.join('')).toContain(WHITE_CIRCLE); + await waitForWrites((w) => w.join('').includes(GREEN_CIRCLE), 5_000, 'GREEN_CIRCLE after late pane appears'); await subscriber.close(); await mockServer.stop(); @@ -328,40 +427,34 @@ describe('worklist — event-driven agent status (WL-0MSHB7DHO004RHBJ)', () => { sf.cleanup(); }); - it('a pane_exited event clears the icon (treated like closed)', async () => { + it('a pane_exited event clears the green circle (treated like closed)', async () => { const sf = makeStateFile(); - seedStateFile(sf.path, [{ workItemId: 'WL-1', paneId: 'w3:p99' }]); + seedStateFile(sf.path, [{ workItemId: 'WL-0MV1BWETT005GSUJ', paneId: 'w3:p99' }]); const tracker = new AgentTracker({ stateFile: sf.path, ttlMs: 60_000 }); const mockServer = await createMockSocket(); - const subscriber = new HerdrEventSubscriber({ socketPath: mockServer.getAddress(), callbacks: {} }); + const subscriber = makeSubscriber(mockServer, tracker); setExecFileAsync(makeVisibleExecMock() as any); const fetcher = vi.fn(async (): Promise<WorkItem[]> => [ - { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea' }, + { id: 'WL-0MV1BWETT005GSUJ', title: 'Task one', status: 'open', stage: 'idea' }, ]); - const p = runWorklistTui(fetcher, undefined, undefined, { - autoRefresh: true, - refreshIntervalMs: 30_000, - autoSync: false, - showHelpText: false, - subscriber, - agentTracker: tracker, - mergeAgentStates: async () => {}, - }); + const holder: PaneSetHolder = { + panes: [{ pane_id: 'w3:p99', label: titleFor('WL-0MV1BWETT005GSUJ'), workspace_id: 'w1' }], + }; + const p = runWorklistTui(fetcher, undefined, undefined, markerOptions(holder, subscriber, tracker)); const client = await waitForClient(mockServer); - // Seed a working icon via the event path. - client!.pushEvent({ - event: 'pane_agent_status_changed', - data: { pane_id: 'w3:p99', agent_status: 'working' }, - }); - await sleep(100); - expect(writes.join('')).toContain(GREEN_CIRCLE); + await waitForWrites((w) => w.join('').includes(GREEN_CIRCLE), 5_000, 'GREEN_CIRCLE initial'); + holder.panes = []; + const paneExitedMark = writes.length; client!.pushEvent({ event: 'pane_exited', data: { pane_id: 'w3:p99' } }); - await sleep(100); - writes.length = 0; // only inspect output produced AFTER the event - expect(writes.join('')).not.toContain(GREEN_CIRCLE); + await waitForWrites( + (w) => w.length > paneExitedMark && !w.slice(paneExitedMark).join('').includes(GREEN_CIRCLE), + 5_000, + 'GREEN_CIRCLE cleared after pane_exited', + ); + expect(writes.slice(paneExitedMark).join('')).not.toContain(GREEN_CIRCLE); await subscriber.close(); await mockServer.stop(); diff --git a/packages/herdr/src/worklist-agent-state.test.ts b/packages/herdr/src/worklist-agent-state.test.ts index 1bb90c2bb..b44c0e0a7 100644 --- a/packages/herdr/src/worklist-agent-state.test.ts +++ b/packages/herdr/src/worklist-agent-state.test.ts @@ -1,12 +1,15 @@ /** - * packages/herdr/src/worklist-agent-state.test.ts — Integration tests for - * merging agent-status state into fetched work items (WL-0MSBQUJQX005RAT9). + * packages/herdr/src/worklist-agent-state.test.ts — Integration tests for the + * green-circle (agent slot) icon rendering in the selection list + * (WL-0MSBQUJQX005RAT9 + WL-0MV1BWETT005GSUJ). * - * runWorklistTui accepts a `mergeAgentStates` callback that is applied to - * freshly fetched items (top-level + expanded children) on every refresh - * cycle. This verifies the plumbing: when the callback stamps an - * `agentState` on an item, the row renders the agent-status icon; without - * it, the row renders no agent icon. + * The row's green-circle icon is authored from `item.agentState`: the + * pane-existence marker (`markItemsActiveByPane` / `applyActivePaneMarkers`) + * stamps `'working'` on an item when a pane in the current workspace carries + * the item's ID in its title and clears it otherwise. These tests verify the + * RENDERER contract directly: an item carrying `agentState='working'` renders + * the green circle, an item without it renders none, and the fixed-width + * agent slot keeps the item-ID column aligned across rows. * * Run: npx vitest run packages/herdr/src/worklist-agent-state.test.ts */ @@ -93,34 +96,26 @@ async function quit(p: Promise<unknown>): Promise<void> { await p; } -const GREEN_CIRCLE = '\u{1F7E2}'; // 🟢 — the agent working icon +const GREEN_CIRCLE = '\u{1F7E2}'; // 🟢 — the green circle (work-item pane open) -describe('worklist — agent-state merge into fetched items', () => { - it('renders the agent-status icon when mergeAgentStates stamps an agentState', async () => { +describe('worklist — green-circle agent slot rendering', () => { + it('renders the green circle when the item carries agentState (an open work-item pane)', async () => { vi.useFakeTimers(); process.env.HERDR_PANE_ID = 'w1:pCM'; setExecFileAsync(makeExecMock() as any); - const fetcher = vi.fn(async (): Promise<WorkItem[]> => [ - { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea' }, - ]); + const items: WorkItem[] = [ + { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea', agentState: 'working' }, + ]; + const fetcher = vi.fn(async (): Promise<WorkItem[]> => items); - const p = runWorklistTui(fetcher, [], undefined, { + const p = runWorklistTui(fetcher, items, undefined, { autoRefresh: true, refreshIntervalMs: 30_000, autoSync: false, showHelpText: false, - mergeAgentStates: async (items: WorkItem[]) => { - for (const it of items) { - if (it.id === 'WL-1') it.agentState = 'working'; - } - }, }); await vi.advanceTimersByTimeAsync(0); - fetcher.mockClear(); - - // Trigger the first auto-refresh cycle, which applies mergeAgentStates. - await vi.advanceTimersByTimeAsync(30_000); const output = writes.join(''); expect(output).toContain(GREEN_CIRCLE); @@ -129,7 +124,7 @@ describe('worklist — agent-state merge into fetched items', () => { await quit(p); }); - it('renders no agent icon when no agentState is stamped', async () => { + it('renders no green circle when no agentState is present', async () => { vi.useFakeTimers(); process.env.HERDR_PANE_ID = 'w1:pCM'; setExecFileAsync(makeExecMock() as any); @@ -156,26 +151,22 @@ describe('worklist — agent-state merge into fetched items', () => { await quit(p); }); - it('keeps rows aligned: item IDs share a column with and without the agent icon', async () => { + it('keeps rows aligned: item IDs share a column with and without the green circle', async () => { vi.useFakeTimers(); process.env.HERDR_PANE_ID = 'w1:pCM'; setExecFileAsync(makeExecMock() as any); - const fetcher = vi.fn(async (): Promise<WorkItem[]> => [ - { id: 'WL-AGENT', title: 'Has agent', status: 'open', stage: 'idea' }, - { id: 'WL-PLAIN', title: 'No agent', status: 'open', stage: 'idea' }, - ]); + const items: WorkItem[] = [ + { id: 'WL-AGENT', title: 'Has pane', status: 'open', stage: 'idea', agentState: 'working' }, + { id: 'WL-PLAIN', title: 'No pane', status: 'open', stage: 'idea' }, + ]; + const fetcher = vi.fn(async (): Promise<WorkItem[]> => items); - const p = runWorklistTui(fetcher, [], undefined, { + const p = runWorklistTui(fetcher, items, undefined, { autoRefresh: true, refreshIntervalMs: 30_000, autoSync: false, showHelpText: false, - mergeAgentStates: async (items: WorkItem[]) => { - for (const it of items) { - if (it.id === 'WL-AGENT') it.agentState = 'working'; - } - }, }); await vi.advanceTimersByTimeAsync(0); fetcher.mockClear(); diff --git a/packages/herdr/src/worklist-blocked-by-cache.test.ts b/packages/herdr/src/worklist-blocked-by-cache.test.ts new file mode 100644 index 000000000..d5c14df3b --- /dev/null +++ b/packages/herdr/src/worklist-blocked-by-cache.test.ts @@ -0,0 +1,416 @@ +/** + * packages/herdr/src/worklist-blocked-by-cache.test.ts — Regression tests + * for the `Blocked By` metadata-line flicker fix (WL-0MV8Q5X8U004KKAP). + * + * Root cause: `resolveMetaBlockers` implemented two live render states for a + * blocked item (async `wl dep list` lookup pending → no `Blocked By` rows; + * resolved → rows), and `doRefresh` cleared the per-item `blockerCache` on + * EVERY list refresh. Every refresh therefore re-entered the unresolved + * frame for a full lookup (~0.5–2 s) before the `.finally(render())` flipped + * it back — an endless on/off flicker under the frequent refresh traffic of + * a live workspace (DB-change watch, auto-sync counter bumps, `wl next` + * re-sorts). + * + * The fix: the blocker cache SURVIVES refreshes. Entries are reused while the + * item's payload signature (status/stage/updatedAt) is unchanged, the + * in-flight lookup is memoised per item (single-flight), and while a + * re-resolve is in flight the last known value is still served + * (stale-while-revalidate) — so a stable selection shows exactly ONE + * unresolved→resolved transition, and refresh cycles never spawn a duplicate + * `wl dep list` nor blank the panel. + * + * These tests drive the real TUI (`runWorklistTui`) with a mock that replaces + * `fetchBlockersForItem` and a fake DB-change watch (the same event source + * the RCA identifies as the flicker cadence driver), then inspect the + * rendered frames + spawn count. + * + * Run: npx vitest run packages/herdr/src/worklist-blocked-by-cache.test.ts + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Mock } from 'vitest'; + +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(), +})); + +// Replace the fetcher's blocker/aux lookups with controllable mocks; keep the +// rest of the fetcher real (setWorklogDir / exec seam stay functional). +vi.mock('./fetcher.js', async (importOriginal) => { + const actual = await importOriginal<typeof import('./fetcher.js')>(); + return { + ...actual, + fetchActionableCount: vi.fn().mockResolvedValue(0), + fetchReviewQueueState: vi.fn().mockResolvedValue(undefined), + fetchChildrenForItem: vi.fn().mockResolvedValue([]), + fetchChildrenForItems: vi.fn().mockResolvedValue(new Map()), + fetchBlockersForItem: vi.fn(), + }; +}); + +import { runWorklistTui, formatDetailContent, formatTooltipLines, ANSI } from './worklist.js'; +import { fetchBlockersForItem, setWorklogDir, resetWorklogDir, type WorkItem } from './fetcher.js'; +import { setLogPath, resetLogPath } from './command-log.js'; +import { loadShortcutConfig } from './shortcut-config.js'; + +const blockerMock = vi.mocked(fetchBlockersForItem); + +// --------------------------------------------------------------------------- +// Fake stdin/stdout harness (same pattern as worklist-integration.test.ts) +// --------------------------------------------------------------------------- + +let dataHandler: ((chunk: Buffer) => void) | undefined; +let writes: string[]; +let tmpDir: string; +/** Captured DB-change onChange callback (the flicker cadence source). */ +let watchChange: (() => void) | undefined; + +/** Fake DB-change watch: captures the onChange callback the TUI registers. */ +function fakeDbWatch( + _cacheDir: string, + _worklogDir: string, + onChange: () => void, +): { active: boolean; dispose: Mock; close: Mock } { + watchChange = onChange; + return { active: true, dispose: vi.fn(), close: vi.fn() }; +} + +beforeEach(() => { + vi.clearAllMocks(); + blockerMock.mockReset(); + watchChange = undefined; + dataHandler = undefined; + writes = []; + setLogPath(join(tmpdir(), `herdr-blkcache-cmdlog-${process.pid}-${Date.now()}.json`)); + tmpDir = mkdtempSync(join(tmpdir(), 'herdr-blkcache-')); + setWorklogDir(tmpDir); + + 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 settle(ms = 15): Promise<void> { + return new Promise((r) => setTimeout(r, ms)); +} + +function makeItem(id: string, over: Partial<WorkItem> = {}): WorkItem { + return { id, title: `Item ${id}`, status: 'open', updatedAt: '2026-01-01T00:00:00.000Z', ...over }; +} + +/** The blocked-by fixture: a dependency-blocked item, selected by default. */ +function blockedItem(over: Partial<WorkItem> = {}): WorkItem { + return makeItem('WL-BLOCKED', { status: 'blocked', ...over }); +} + +/** + * Start the real TUI with a mutable item feed and the fake DB-change watch. + * `onWatch()` (below) triggers one `doRefresh` the same way the production + * `db-change.ts` watch does. + */ +function startTui(items: WorkItem[]): { tui: Promise<WorkItem | undefined>; feed: { items: WorkItem[] } } { + const feed = { items }; + const tui = runWorklistTui( + async () => feed.items, + feed.items, + loadShortcutConfig(), + { + autoRefresh: true, + refreshIntervalMs: 3_600_000, // effectively never fires; dbWatch drives refreshes + autoSync: false, + showHelpText: false, + dbWatch: fakeDbWatch as any, + }, + ); + return { tui, feed }; +} + +/** Trigger one DB-change-watch refresh (the flicker cadence source). */ +async function watchRefresh(): Promise<void> { + expect(watchChange).toBeDefined(); + watchChange!(); + await settle(); +} + +/** Quit the TUI and await its promise (cleans up timers/listeners). */ +async function quit(tui: Promise<WorkItem | undefined>): Promise<void> { + dataHandler?.(Buffer.from('q')); + await tui.catch(() => undefined); +} + +function stripAnsi(s: string): string { + return s.replace(/\x1b\[[0-9;]*m/g, ''); +} + +/** + * Reconstruct complete render frames from the raw stdout writes. Each render + * writes `ANSI.clear` + `ANSI.cursorHome` + body; splitting the concatenated + * stream on `ANSI.clear` yields one segment per render. Only list-mode frames + * with the `Work Items` header are kept (excludes, e.g., form overlays). + */ +function frames(): string[] { + return writes + .join('') + .split(ANSI.clear) + .map((seg) => stripAnsi(seg.replace(/^\x1b\[H/, ''))) + .filter((seg) => seg.includes('Work Items')); +} + +// --------------------------------------------------------------------------- +// AC1/AC2/AC3: stable frames, single spawn, one transition per selection +// --------------------------------------------------------------------------- + +describe('Blocked By flicker fix (WL-0MV8Q5X8U004KKAP)', () => { + it('keeps the Blocked By line present across repeated refresh triggers, spawning the lookup once', async () => { + blockerMock.mockResolvedValue([{ id: 'WL-B1', title: 'First blocker' }]); + const { tui, feed } = startTui([blockedItem(), makeItem('WL-OTHER')]); + try { + // Initial render + resolution (one unresolved→resolved transition). + await settle(); + await vi.waitFor(() => expect(blockerMock).toHaveBeenCalledTimes(1)); + expect(blockerMock).toHaveBeenCalledWith('WL-BLOCKED'); + + // Capture the frame set once resolved. + await vi.waitFor(() => expect(frames().some((f) => f.includes('Blocked By'))).toBe(true)); + const allFrames = frames(); + const resolvedIdx = allFrames.findIndex((f) => f.includes('Blocked By')); + expect(resolvedIdx).toBeGreaterThan(-1); + + // Three consecutive refresh cycles (DB-change watch) with the DB + // otherwise idle: the line must stay put and the lookup must NOT re-run. + for (let i = 0; i < 3; i += 1) { + await watchRefresh(); + } + expect(blockerMock).toHaveBeenCalledTimes(1); + + // Every frame AFTER the resolved one still carries the line: once + // resolved, no refresh drops it (exactly one transition, never an + // oscillating pair). + const post = frames(); + for (const f of post.slice(resolvedIdx)) { + expect(f).toMatch(/Blocked By\s+WL-B1/); + } + expect(post.length).toBeGreaterThan(resolvedIdx); + + // The selection survived the refreshes (same item still selected). + const last = post[post.length - 1]; + expect(last).toContain('WL-BLOCKED'); + expect(feed.items).toHaveLength(2); + } finally { + await quit(tui); + } + }); + + it('single-flights the lookup: concurrent refreshes while a lookup is pending never duplicate the spawn', async () => { + let resolveLookup!: (v: { id: string; title: string }[]) => void; + blockerMock.mockImplementationOnce( + () => new Promise((r) => { resolveLookup = r; }), + ); + const { tui } = startTui([blockedItem(), makeItem('WL-OTHER')]); + try { + await settle(); + // First render started the lookup; it is still pending. + expect(blockerMock).toHaveBeenCalledTimes(1); + + // Refresh storm while pending: none may spawn a second lookup. + await watchRefresh(); + await watchRefresh(); + await watchRefresh(); + expect(blockerMock).toHaveBeenCalledTimes(1); + + // Resolve — the cached rows land and the follow-up render shows them + // (one unresolved→resolved transition). + resolveLookup([{ id: 'WL-B1', title: 'First blocker' }]); + await settle(); + await vi.waitFor(() => expect(frames()[frames().length - 1]).toMatch(/Blocked By\s+WL-B1/)); + expect(blockerMock).toHaveBeenCalledTimes(1); + } finally { + await quit(tui); + } + }); + + it('re-resolves only when the item’s own signature changes, serving the stale line while re-resolving', async () => { + blockerMock + .mockResolvedValueOnce([{ id: 'WL-B1', title: 'First blocker' }]) + .mockResolvedValueOnce([{ id: 'WL-B2', title: 'Second blocker' }]); + const { tui, feed } = startTui([blockedItem(), makeItem('WL-OTHER')]); + try { + await settle(); + await vi.waitFor(() => expect(blockerMock).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(frames()[frames().length - 1]).toMatch(/Blocked By\s+WL-B1/)); + + // A refresh that leaves the payload untouched (same status/stage/ + // updatedAt) reuses the cache — no new spawn. + await watchRefresh(); + expect(blockerMock).toHaveBeenCalledTimes(1); + + // A refresh where the item CHANGED (e.g. a dependency write bumped + // updatedAt) invalidates only that entry and re-resolves once. + feed.items = [ + blockedItem({ updatedAt: '2026-01-02T00:00:00.000Z' }), + makeItem('WL-OTHER'), + ]; + await watchRefresh(); + await vi.waitFor(() => expect(blockerMock).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(frames()[frames().length - 1]).toMatch(/Blocked By\s+WL-B2/)); + } finally { + await quit(tui); + } + }); + + it('fail-open: a rejected lookup yields no rows and never breaks rendering', async () => { + blockerMock.mockRejectedValue(new Error('wl exploded')); + const { tui } = startTui([blockedItem(), makeItem('WL-OTHER')]); + try { + await settle(); + await watchRefresh(); + await vi.waitFor(() => { + const post = frames(); + expect(post[post.length - 1]).toContain('WL-BLOCKED'); + }); + const post = frames(); + expect(post[post.length - 1]).not.toContain('Blocked By'); + } finally { + await quit(tui); + } + }); + + it('unblocked selections render byte-for-byte without Blocked By rows across refreshes', async () => { + blockerMock.mockResolvedValue([]); + const { tui } = startTui([makeItem('WL-PLAIN'), makeItem('WL-OTHER')]); + try { + await settle(); + await watchRefresh(); + await watchRefresh(); + const post = frames(); + for (const f of post) { + expect(f).not.toContain('Blocked By'); + } + } finally { + await quit(tui); + } + }); +}); + +// --------------------------------------------------------------------------- +// Detail-view parity (WL-0MVBMLY2Z0032CYR, reverses WL-0MV8Q5X8U004KKAP AC-5) +// --------------------------------------------------------------------------- + +/** Every rendered frame (list AND detail), simplest form for detail-mode asserts. */ +function allSegments(): string[] { + return writes + .join('') + .split(ANSI.clear) + .map((seg) => stripAnsi(seg.replace(/^\x1b\[H/, ''))); +} + +describe('detail-view Blocked By parity (WL-0MVBMLY2Z0032CYR)', () => { + it('formatDetailContent renders the active Blocked By rows with blocker status', () => { + const item = blockedItem(); + const lines = formatDetailContent(item, 80, undefined, false, 0, true, 0, [ + { id: 'WL-B1', title: 'First blocker', status: 'in-progress' }, + ]).join('\n'); + expect(lines).toContain('Blocked By'); + expect(lines).toContain('WL-B1 — First blocker'); + expect(lines).toContain('in-progress'); + }); + + it('formatDetailContent omits Blocked By when no blockers are passed (no-versus-some preserved)', () => { + const item = blockedItem(); + const lines = formatDetailContent(item, 80).join('\n'); + expect(lines).not.toContain('Blocked By'); + }); + + it('the detail view renders Blocked By rows with status through the shared single-flight lookup', async () => { + blockerMock.mockResolvedValue([{ id: 'WL-B1', title: 'First blocker', status: 'in-progress' }]); + const { tui } = startTui([blockedItem(), makeItem('WL-OTHER')]); + try { + // Enter detail mode BEFORE the list-mode lookup resolves: the detail + // render shares the same in-flight/single-flight blocker lookup, so a + // mode switch never spawns a second `wl dep list`. + dataHandler?.(Buffer.from('\r')); + await settle(); + await vi.waitFor(() => expect(blockerMock).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => { + const last = allSegments()[allSegments().length - 1]; + expect(last).toContain('WL-BLOCKED'); // detail header + expect(last).toMatch(/Blocked By\s+\|\s+WL-B1 — First blocker/); + }); + const resolved = allSegments()[allSegments().length - 1]; + expect(blockerMock).toHaveBeenCalledTimes(1); + + // Refresh cycles while still in detail mode reuse the cache: the rows + // stay put (no flicker) and the lookup never re-runs. + await watchRefresh(); + await watchRefresh(); + const after = allSegments()[allSegments().length - 1]; + expect(after).toMatch(/Blocked By\s+\|\s+WL-B1 — First blocker/); + expect(blockerMock).toHaveBeenCalledTimes(1); + expect(resolved).toMatch(/WL-B1 — First blocker \u{1F504} in-progress/u); // icon + text + } finally { + await quit(tui); + } + }); + + it('fail-open in the detail view: a rejected lookup yields no rows and never breaks rendering', async () => { + blockerMock.mockRejectedValue(new Error('wl exploded')); + const { tui } = startTui([blockedItem(), makeItem('WL-OTHER')]); + try { + dataHandler?.(Buffer.from('\r')); + await settle(); + await watchRefresh(); + await vi.waitFor(() => { + const last = allSegments()[allSegments().length - 1]; + expect(last).toContain('WL-BLOCKED'); + }); + const last = allSegments()[allSegments().length - 1]; + expect(last).not.toContain('Blocked By'); + } finally { + await quit(tui); + } + }); + + it('formatTooltipLines omits Blocked By (documented divergence)', () => { + const lines = formatTooltipLines(blockedItem(), '!!wl dep add', undefined, 80); + expect(lines.join('\n')).not.toContain('Blocked By'); + }); +}); diff --git a/packages/herdr/src/worklist-help.test.ts b/packages/herdr/src/worklist-help.test.ts new file mode 100644 index 000000000..b4be0e2eb --- /dev/null +++ b/packages/herdr/src/worklist-help.test.ts @@ -0,0 +1,544 @@ +/** + * packages/herdr/src/worklist-help.test.ts — Test-first pins for the `?` + * help-screen overlay (parent WL-0MTV97GV20088CS7, AC3/AC4/AC6; test-first + * child WL-0MV85R0JD004CHAE; implementation child WL-0MV85R14Y003RII9). + * + * The help screen does not exist yet, so every body here asserts the + * The overlay landed in the implementation child WL-0MV85R14Y003RII9, so + * every body here asserts the IMPLEMENTED behaviour with a plain `it` (the + * red-witness `it.fails` form was flipped on landing). + * + * Everything is pinned through the REAL `runWorklistTui` render loop over a + * fake TTY (the same technique as the composed-footer tests in + * worklist.test.ts). That keeps the contract at the observable-behaviour + * level: an implementation may add a `'help'` ViewMode branch, a dedicated + * formatter, or anything else, as long as `?` opens the overlay and the + * rendered frame satisfies these assertions. + * + * AC map (this item): + * AC1 — `?` opens help; `?`, `Esc` and `q` each return to list mode. + * AC2 — dismissing help leaves selection (and the rest of list state) intact. + * AC3 — the rendered help enumerates the live `ShortcutRegistry` commands, + * grouped by chord leader, with chord/label/description. + * AC4 — the help stays within the `rows - 1` pane budget on a short pane and + * keeps the dismiss keys discoverable. + * AC5 — the help is not offered when `showHelpText: false`. + * + * Run through the test skill (`/skill:test`); do not invoke vitest directly. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { runWorklistTui, ANSI } from './worklist.js'; +import { loadShortcutConfig, ShortcutRegistry, type ShortcutEntry } from './shortcut-config.js'; +import { setExecFileAsync, resetExecFileAsync, type WorkItem } from './fetcher.js'; + +// ── Fixtures ─────────────────────────────────────────────────────────── + +/** Minimal WorkItem with the fields the list renderer reads. */ +function makeItem(id: string, overrides: Partial<WorkItem> = {}): WorkItem { + return { + id, + title: `Item ${id}`, + status: 'open', + stage: 'plan_complete', + ...overrides, + }; +} + +/** + * A label that appears ONLY in the full help enumeration — the list-mode + * footer collapses the `c` leader family to `c:create...`, so `create quick` + * is a reliable "the help overlay is open" signal. + */ +const HELP_MARKER = 'create quick'; +const DISMISS_KEYS_RE = /Esc|Escape/; + +/** Strip all ANSI escape sequences for text assertions. */ +function stripAnsi(s: string): string { + return s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/\x1b[^[]/g, ''); +} + +function delay(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +type StdinListener = (chunk: Buffer) => void; + +interface Harness { + /** Send a single decoded key to the TUI. */ + press(key: string): void; + /** The raw, unstripped last frame (ANSI included). */ + rawFrame(): string; + /** The stripped last frame used for text assertions. */ + frame(): string; + /** Poll until the stripped frame satisfies `predicate`. */ + waitForFrame(predicate: (frame: string) => boolean, label: string, timeoutMs?: number): Promise<void>; + /** The visible shortcut entries the footer path uses for the selected item. */ + visibleEntries(): ShortcutEntry[]; +} + +interface TuiOptions { + showHelpText?: boolean; + rows?: number; + cols?: number; + registry?: ShortcutRegistry; + onCommand?: (...args: unknown[]) => void; + onDowntimeToggle?: () => void; +} + +/** Force `getTermSize()` to report a deterministic pane size. */ +function overrideStdoutSize(rows: number, cols: number): () => void { + const hadRows = Object.prototype.hasOwnProperty.call(process.stdout, 'rows'); + const hadCols = Object.prototype.hasOwnProperty.call(process.stdout, 'columns'); + const prevRows = (process.stdout as unknown as { rows?: number }).rows; + const prevCols = (process.stdout as unknown as { columns?: number }).columns; + Object.defineProperty(process.stdout, 'rows', { value: rows, configurable: true, writable: true }); + Object.defineProperty(process.stdout, 'columns', { value: cols, configurable: true, writable: true }); + return () => { + if (hadRows) Object.defineProperty(process.stdout, 'rows', { value: prevRows, configurable: true, writable: true }); + else delete (process.stdout as unknown as { rows?: number }).rows; + if (hadCols) Object.defineProperty(process.stdout, 'columns', { value: prevCols, configurable: true, writable: true }); + else delete (process.stdout as unknown as { columns?: number }).columns; + }; +} + +/** + * Drive the real TUI in a fake TTY for the duration of `body`. + * + * Installation mirrors the composed-footer harness in worklist.test.ts: a + * fake TTY stdin records data listeners, `process.stdout.write` is captured + * so individual frames can be reconstructed, and the `execFileAsync` seam is + * stubbed so no real `wl`/`herdr` process is spawned. Cleanup ALWAYS runs: + * the TUI is quit (repeatedly sending `q` covers the help→list→quit path), + * the promise is drained, and every global is restored — a failing assertion + * never leaks the event loop. + */ +async function runWithTui( + items: WorkItem[], + body: (h: Harness) => Promise<void>, + options: TuiOptions = {}, +): Promise<void> { + const restoreSize = overrideStdoutSize(options.rows ?? 24, options.cols ?? 100); + const dataListeners: StdinListener[] = []; + const writes: string[] = []; + const fakeStdin = { + isTTY: true, + setRawMode: vi.fn(), + resume: vi.fn(), + pause: vi.fn(), + on: (ev: string, cb: StdinListener) => { + if (ev === 'data') dataListeners.push(cb); + }, + removeListener: (ev: string, cb: StdinListener) => { + if (ev === 'data') { + const i = dataListeners.indexOf(cb); + if (i >= 0) dataListeners.splice(i, 1); + } + }, + }; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => { + writes.push(String(chunk)); + return true; + }) as any); + const origStdin = (process as unknown as { stdin: unknown }).stdin; + Object.defineProperty(process, 'stdin', { value: fakeStdin, configurable: true, writable: true }); + setExecFileAsync((async () => ({ stdout: '{"count": 0}' })) as any); + + const rawFrame = (): string => { + const all = writes.join(''); + const idx = all.lastIndexOf(ANSI.clear); + return idx >= 0 ? all.slice(idx + ANSI.clear.length) : all; + }; + const frame = (): string => stripAnsi(rawFrame()); + + const harness: Harness = { + press: (key: string) => { + dataListeners.forEach((cb) => cb(Buffer.from(key))); + }, + rawFrame, + frame, + waitForFrame: async (predicate, label, timeoutMs = 5000) => { + try { + await vi.waitFor( + () => { + if (!predicate(frame())) throw new Error(`frame did not satisfy: ${label}`); + }, + { timeout: timeoutMs, interval: 20 }, + ); + } catch { + throw new Error(`waitForFrame(${label}) failed. Last frame:\n${frame()}`); + } + }, + visibleEntries: () => + loadShortcutConfig() + .getEntriesForStage('plan_complete', false, undefined) + .filter((e) => e.view === 'list' || e.view === 'both'), + }; + + let tui: Promise<WorkItem | undefined> | undefined; + try { + tui = runWorklistTui( + async () => items, + items, + options.registry ?? loadShortcutConfig(), + { + autoRefresh: false, + autoSync: false, + showHelpText: options.showHelpText ?? true, + onCommand: options.onCommand as any, + onDowntimeToggle: options.onDowntimeToggle, + }, + ); + await vi.waitFor(() => { + expect(writes.length).toBeGreaterThan(0); + }); + await body(harness); + } finally { + // Quit the TUI. `q` first dismisses help (if open), a second `q` quits. + if (tui) { + for (let attempt = 0; attempt < 4; attempt++) { + dataListeners.forEach((cb) => cb(Buffer.from('q'))); + const settled = await Promise.race([ + tui.then(() => true, () => true), + delay(60).then(() => false), + ]); + if (settled) break; + } + await tui.catch(() => undefined); + } + Object.defineProperty(process, 'stdin', { value: origStdin, configurable: true, writable: true }); + writeSpy.mockRestore(); + resetExecFileAsync(); + restoreSize(); + } +} + +const isHelpOpen = (f: string): boolean => f.includes(HELP_MARKER) || DISMISS_KEYS_RE.test(f); +const isListMode = (f: string): boolean => f.includes('Work Items') && !f.includes(HELP_MARKER); + +// ── AC1: `?` opens help; `?`/Esc/`q` each return to list ─────────────── + +describe('help screen overlay — open/dismiss (WL-0MV85R0JD004CHAE)', () => { + it('opens on ? and dismisses with ?, Esc and q back to list mode', async () => { + const items = [makeItem('A'), makeItem('B'), makeItem('C')]; + await runWithTui(items, async (h) => { + // ? opens the overlay. + h.press('?'); + await h.waitForFrame(isHelpOpen, 'help open after ?'); + + // ? dismisses it. + h.press('?'); + await h.waitForFrame(isListMode, 'list after ? dismiss'); + + // Esc dismisses it. + h.press('?'); + await h.waitForFrame(isHelpOpen, 'help open again after ?'); + h.press('\x1b'); + await h.waitForFrame(isListMode, 'list after Esc dismiss'); + + // q dismisses it WITHOUT quitting the TUI (a further ? still works). + h.press('?'); + await h.waitForFrame(isHelpOpen, 'help open again before q'); + h.press('q'); + await h.waitForFrame(isListMode, 'list after q dismiss'); + h.press('?'); + await h.waitForFrame(isHelpOpen, 'help still reachable after q (did not quit)'); + }); + }); + + // ── AC2: dismissing help mutates no list state ───────────────────── + + it('leaves the selection unchanged after help is dismissed', async () => { + const items = [makeItem('A'), makeItem('B'), makeItem('C')]; + await runWithTui(items, async (h) => { + // Move the cursor onto item B, then capture the exact list frame. + h.press('j'); + await h.waitForFrame((f) => /▸[^\n]*B Item B/.test(f), 'selection on Item B'); + const listFrameBefore = h.rawFrame(); + + // Open then dismiss help. + h.press('?'); + await h.waitForFrame(isHelpOpen, 'help open'); + h.press('q'); + await h.waitForFrame(isListMode, 'list after dismiss'); + + // The whole frame is byte-identical — selection, scroll, filters and + // navigation stack are untouched (a full-pane overlay is display-only). + expect(h.rawFrame()).toBe(listFrameBefore); + expect(h.frame()).toMatch(/▸[^\n]*B Item B/); + }); + }); + + it('is display-only — keys pressed while help is open dispatch nothing', async () => { + const items = [makeItem('A')]; + const onCommand = vi.fn(); + const onDowntimeToggle = vi.fn(); + await runWithTui( + items, + async (h) => { + h.press('?'); + await h.waitForFrame(isHelpOpen, 'help open'); + + // Keys that WOULD dispatch in list mode must be inert while help is + // open (the `d` downtime toggle and the `i` implement shortcut). + h.press('d'); + h.press('i'); + await delay(80); + + expect(onDowntimeToggle).not.toHaveBeenCalled(); + expect(onCommand).not.toHaveBeenCalled(); + // Still in help mode (no command ran, no mode change). + expect(isHelpOpen(h.frame())).toBe(true); + }, + { onCommand: onCommand as any, onDowntimeToggle }, + ); + }); +}); + +// ── AC3: command enumeration from the live registry ───────────────────── + +describe('help screen overlay — command enumeration (WL-0MV85R0JD004CHAE)', () => { + it('enumerates every visible registry shortcut, grouped by chord leader', async () => { + const items = [makeItem('A')]; + await runWithTui( + items, + async (h) => { + h.press('?'); + await h.waitForFrame(isHelpOpen, 'help open'); + + const visible = h.visibleEntries(); + expect(visible.length).toBeGreaterThan(10); + const frame = h.frame(); + + // Every visible shortcut's label is present (enumeration is complete). + for (const entry of visible) { + const label = entry.label ?? entry.command; + expect(frame, `help should list label "${label}"`).toContain(label); + } + + // Multi-key families are grouped by their shared leader: both `c` + // sub-chords are enumerated, not collapsed to a single hint. + expect(frame).toContain('create intake'); + expect(frame).toContain('create quick'); + + // Each entry shows a short description (not just a bare label). + expect(frame).toContain('Toggle downtime dispatch'); + expect(frame).toContain('Create a new work item'); + expect(frame).toContain('Run the implement workflow'); + }, + { cols: 200, rows: 200 }, + ); + }); + + it('derives the command list from the live ShortcutRegistry', async () => { + const items = [makeItem('A')]; + const sentinel: ShortcutEntry = { + command: '/skill:audit <id>', + view: 'list', + chord: ['Z'], + label: 'sentinel help command', + description: 'Sentinel description visible only in the help overlay.', + }; + const registry = new ShortcutRegistry([sentinel]); + await runWithTui( + items, + async (h) => { + h.press('?'); + await h.waitForFrame(isHelpOpen, 'help open from live registry'); + const frame = h.frame(); + expect(frame).toContain('sentinel help command'); + expect(frame).toContain('Sentinel description'); + // The chord itself is rendered (a future shortcut appears automatically). + expect(frame).toContain('Z'); + }, + { registry, cols: 120 }, + ); + }); +}); + +// ── AC4: pane-height budget + discoverable dismiss keys ───────────────── + +describe('help screen overlay — small-pane behaviour (WL-0MV85R0JD004CHAE)', () => { + it('stays within the rows - 1 budget on a short pane and keeps dismiss keys discoverable', async () => { + const rows = 12; + const items = [makeItem('A'), makeItem('B'), makeItem('C')]; + await runWithTui( + items, + async (h) => { + h.press('?'); + await h.waitForFrame(isHelpOpen, 'help open on short pane'); + + const lines = h.frame().split('\n'); + expect(lines.length).toBeLessThanOrEqual(rows - 1); + + // The dismiss keys remain discoverable even when content is clipped. + expect(h.frame()).toMatch(DISMISS_KEYS_RE); + expect(h.frame()).toContain('?'); + }, + { rows, cols: 100 }, + ); + }); +}); + +// ── 2026-10-16 audit: the help text must be scrollable ────────────────── +// The producer audit of the `?` overlay required blank-line separated +// sections, coloured headings and SCROLLABLE text. The reworked overlay +// opens at the top of the document and takes j/k/arrow/PageUp/PageDown and +// g/G to move the content window; the footer advertises the scroll +// capability whenever the full content exceeds the pane height, and every +// scroll key is consumed by the overlay (nothing is dispatched). + +describe('help screen overlay — scrolling (WL-0MTV97GV20088CS7 2026-10-16 audit)', () => { + it('scrolls the content window with j/k and g/G on a short pane', async () => { + const rows = 12; + const cols = 100; + const items = [makeItem('A')]; + await runWithTui( + items, + async (h) => { + h.press('?'); + await h.waitForFrame(isHelpOpen, 'help open on short pane'); + + // The footer advertises scrolling once content overflows the pane. + await h.waitForFrame( + (f) => f.includes('j:k scroll') && f.includes('g/G'), + 'footer shows scroll hint', + ); + + // Top of the document: the early command families are visible and + // the last icon category (`Coverage`) is not. + const topFrame = h.frame(); + expect(topFrame).toContain('d — Downtime'); + expect(topFrame).not.toContain('Coverage'); + + // j scrolls down one line (the window moves away from the top). + h.press('j'); + await h.waitForFrame((f) => f !== topFrame, 'j scrolls the window down'); + + // PageDown jumps by a page; repeated PageDown reveals the legend. + for (let i = 0; i < 40; i += 1) h.press('\x1b[6~'); + await h.waitForFrame( + (f) => f.includes('Coverage'), + 'scrolling down reveals the last icon category', + ); + + // G jumps to the very bottom of the document. + h.press('G'); + await h.waitForFrame( + (f) => f.includes('Coverage') && f.includes('j:k scroll'), + 'G jumps to the bottom of the help text', + ); + + // g returns to the top of the document. + h.press('g'); + await h.waitForFrame( + (f) => f.includes('d — Downtime') && f.includes('Commands'), + 'g returns to the top of the help text', + ); + + // Esc still dismisses the scrolled overlay back to the list. + h.press('\x1b'); + await h.waitForFrame(isListMode, 'list after Esc from scrolled help'); + + // The list frame is untouched by the scrolling session: opening + // help again starts fresh at the top, not at the previous offset. + h.press('?'); + await h.waitForFrame(isHelpOpen, 'help reopened after dismiss'); + expect(h.frame()).toContain('d — Downtime'); + expect(h.frame()).not.toContain('Coverage'); + }, + { rows, cols }, + ); + }); + + it('scroll keys are consumed — no command is dispatched while scrolling', async () => { + const items = [makeItem('A')]; + const onCommand = vi.fn(); + await runWithTui( + items, + async (h) => { + h.press('?'); + await h.waitForFrame(isHelpOpen, 'help open'); + + // j/k/g/G are scroll keys inside the overlay — they must never + // dispatch the list-view commands bound to those single keys. + h.press('j'); + h.press('k'); + h.press('g'); + h.press('G'); + await delay(80); + + expect(onCommand).not.toHaveBeenCalled(); + expect(isHelpOpen(h.frame())).toBe(true); + }, + { onCommand: onCommand as any, rows: 12 }, + ); + }); +}); + +// ── AC5: showHelpText gate ────────────────────────────────────────────── + +describe('help screen overlay — showHelpText gate (WL-0MV85R0JD004CHAE)', () => { + it('honours showHelpText: false while offering help when enabled', async () => { + const items = [makeItem('A')]; + + // Enabled: `?` opens the help overlay. + await runWithTui( + items, + async (h) => { + h.press('?'); + await h.waitForFrame(isHelpOpen, 'help open when enabled'); + expect(h.frame()).toMatch(DISMISS_KEYS_RE); + }, + { showHelpText: true }, + ); + + // Disabled: `?` is a no-op — the list stays and no help is offered. + await runWithTui( + items, + async (h) => { + h.press('?'); + await delay(120); + expect(h.frame()).not.toContain(HELP_MARKER); + expect(h.frame()).toContain('Work Items'); + }, + { showHelpText: false }, + ); + }); +}); + +// ── AC5 (parent): the overlay explains every row glyph ───────────────── +// Icon-legend tests (test-first child WL-0MV85R1V6000IL4E; implementation +// child WL-0MV85R2G6000G7BU landed the legend). +describe('help screen overlay — icon legend (WL-0MV85R1V6000IL4E)', () => { + it('renders an icon legend that identifies the row glyphs', async () => { + const items = [makeItem('A')]; + await runWithTui( + items, + async (h) => { + h.press('?'); + await h.waitForFrame(isHelpOpen, 'help open'); + const frame = h.frame(); + // A dedicated legend section exists. + expect(frame).toContain('Icons'); + // Every required category is explained in prose the user can read. + // 'epic' is intentionally absent: the ⊙ indicator was removed + // (WL-0MVAQ873D007LNWD), so the legend no longer has an epic section. + const lower = frame.toLowerCase(); + for (const word of [ + 'priority', + 'status', + 'stage', + 'risk', + 'effort', + 'audit', + 'review', + 'agent', + ]) { + expect(lower, `legend should explain the '${word}' icon`).toContain(word); + } + }, + { rows: 260, cols: 200 }, + ); + }); +}); diff --git a/packages/herdr/src/worklist-integration.test.ts b/packages/herdr/src/worklist-integration.test.ts index 9cea4de17..81c2186ee 100644 --- a/packages/herdr/src/worklist-integration.test.ts +++ b/packages/herdr/src/worklist-integration.test.ts @@ -25,6 +25,7 @@ vi.mock('./fetcher.js', async (importOriginal) => { ...actual, fetchActionableCount: vi.fn().mockResolvedValue(0), fetchChildrenForItem: vi.fn().mockResolvedValue([]), + fetchBlockersForItem: vi.fn().mockResolvedValue([]), }; }); diff --git a/packages/herdr/src/worklist-mouse.test.ts b/packages/herdr/src/worklist-mouse.test.ts index a67006484..e3e9199cd 100644 --- a/packages/herdr/src/worklist-mouse.test.ts +++ b/packages/herdr/src/worklist-mouse.test.ts @@ -2,18 +2,7 @@ * Unit tests for mouse/touch input handling in the Herdr worklist selection * list (parent WL-0MSGHM5BQ0096BNJ, child WL-0MSI720DX002E9WC). * - * TDD RED phase (C1): the mouse path is NOT implemented yet, so this module - * fails to load with "does not provide an export named 'parseMouseEvent'" / - * "'mapMouseToAction'" (etc.) from src/worklist.ts. This is the expected, - * documented red state — the tests go green when the implementation children - * land: - * - C2 (WL-0MSI7278X0008XGM): ANSI mouse constants, parseMouseEvent + - * split-chunk buffering, lifecycle emission in runWorklistTui. - * - C3 (WL-0MSI72EYW00050N2): row mapping, mapMouseToAction, dispatch - * (double-click window, wheel/touch scroll, filter-tap, drag-motion - * guard). - * - * ── Contract under test (implemented by C2/C3) ────────────────────────── + * ── Contract under test ──────────────────────────────────────────── * * export interface ParsedMouseEvent { * button: number; // raw SGR button code (0=left, 1=middle, 2=right, @@ -52,6 +41,11 @@ * | { type: 'scroll-detail-up' } // detail mode: wheel up (j/k-equiv) * | { type: 'scroll-detail-down' } // detail mode: wheel down (j/k-equiv) * | { type: 'filter-stage'; index: number }// filter mode: tap a stage option + * | { type: 'hover-row'; index: number } // list: button-less motion over an item row + * | { type: 'hover-none' } // list: button-less motion over chrome rows + * | { type: 'drag-list'; rows: number } // list: left-held motion past threshold + * | { type: 'drag-detail'; lines: number } // detail: left-held motion past threshold + * | { type: 'drag-start' } // bookkeeping: left press captured (internal) * | null; // inert: chrome rows, motion, unknown * * export function mapMouseToAction( @@ -61,6 +55,28 @@ * clickState?: MouseClickState, // absent → treated as a plain single click * ): MouseAction | null; * + * export const DRAG_THRESHOLD_CELLS = 1; + * // Drag dead-zone (parent Q2 correction): 1 terminal cell/row. + * + * export interface MouseDragState { + * origin: { x: number; y: number } | null; + * accumulator: number; // fractional scroll accumulator + * active: boolean; // true once the threshold was crossed + * prePressSelection: { selectedIndex: number; scrollOffset: number } | null; + * restored: boolean; // pre-press restore performed exactly once + * } + * + * export function mapMouseDrag( + * state: WorkItemListState, + * ev: ParsedMouseEvent, + * dragState: MouseDragState, + * termSize: TermSize, + * ): MouseAction | null; + * // PURE drag mapper: left press → drag-start; left-held motion past + * // DRAG_THRESHOLD_CELLS → drag-list (whole rows) / drag-detail (lines); + * // sub-threshold motion, releases and filter mode → null. Never emits + * // select-row / open-detail (WL-0MSI72S0Y0060BRK). + * * // ANSI.mouseEnable / ANSI.mouseDisable added to the ANSI object: * ANSI.mouseEnable === '\x1b[?1000h\x1b[?1002h\x1b[?1006h' * ANSI.mouseDisable === '\x1b[?1000l\x1b[?1002l\x1b[?1006l' @@ -86,11 +102,24 @@ * (AC5) filter mode: tapping the stage-options row (y=3 of the filter * prompt layout) maps the tapped column to a stage index * ('[i] name' marker spans); other rows are inert. - * (AC6) motion events (button & 32) NEVER return a navigation action - * (drag-motion guard); release events are consumed but inert; truly - * unknown sequences parse to null and the keyboard path is + * (AC6) motion events NEVER return a navigation action (drag-motion + * guard): button-less motion (button 3 | 32) maps to hover, left- + * held motion (button 32) is a drag candidate consumed by + * handleMouseInput, and click/select actions never fire from motion + * (WL-0MSI72S0Y0060BRK). Release events are consumed but inert; + * truly unknown sequences parse to null and the keyboard path is * untouched ('\x1b[A' arrows etc. still work). * + * ── Drag-to-scroll gate rules (WL-0MSI72S0Y0060BRK) ──────────────────── + * (D1) left press captures the pre-press selection and emits drag-start; + * (D2) left-held motion past DRAG_THRESHOLD_CELLS scrolls the list by + * whole rows (scrollBy, clamped to [0, maxOffset]) or the detail by + * whole lines (existing detail clamp), never moving the selection; + * (D3) sub-threshold motion is inert; a release ends the drag and never + * clicks (no select-row / open-detail from the drag consumer); + * (D4) button-less motion still drives hover; drag is inert in filter + * mode and the metadata panel; plain clicks/double-clicks unchanged. + * * ── Fixture geometry (TERM_80x24, 1-based rows, verified against the * renderer at authoring time) ──────────────────────────────────────── * @@ -117,9 +146,12 @@ import { mapMouseToAction, handleMouseInput, DOUBLE_CLICK_WINDOW_MS, + DRAG_THRESHOLD_CELLS, + createDragState, + mapMouseDrag, runWorklistTui, } from './worklist.js'; -import type { ParsedMouseEvent, MouseAction, MouseClickState, TermSize } from './worklist.js'; +import type { ParsedMouseEvent, MouseAction, MouseClickState, MouseDragState, TermSize } from './worklist.js'; import type { WorkItem } from './fetcher.js'; import { setExecFileAsync, resetExecFileAsync } from './fetcher.js'; @@ -148,6 +180,18 @@ function press(button: number, x: number, y: number): ParsedMouseEvent { return { button, x, y, release: false }; } +/** Build a WorkItem with a long description so the detail view scrolls. */ +function makeScrollableItem(id: string): WorkItem { + const item = makeItem(id); + item.description = [ + `# ${id} title`, + '', + '## Notes', + ...Array.from({ length: 80 }, (_, i) => `lorem ipsum dolor line ${i}`), + ].join('\n'); + return item; +} + // ── AC1: ANSI mouse lifecycle sequences ──────────────────────────────── describe('ANSI mouse sequences (WL-0MSGHM5BQ0096BNJ AC1)', () => { @@ -489,37 +533,45 @@ describe('mapMouseToAction — wheel and touch scroll (AC4)', () => { // ── AC6: drag-motion guard + button semantics ────────────────────────── -// Motion events (button & 32) map to hover actions in list mode -// (WL-0MT9XRZDK006GMUH): they NEVER navigate/select — they drive the -// hover tooltip instead (AC5: motion is consumed, never leaking to -// keyboard handlers). +// Motion events (button & 32) are split by the SGR button field +// (WL-0MSI72S0Y0060BRK): button-less motion ((button & 3) === 3) maps to +// hover actions (WL-0MT9XRZDK006GMUH); left-button-held motion +// ((button & 3) === 0) is a drag candidate handled by handleMouseInput — +// mapMouseToAction returns null for it (motion never navigates/selects). describe('mapMouseToAction — drag-motion guard and button codes (AC6)', () => { - it('never navigates/jumps the selection on motion events — maps to hover instead', () => { + it('never navigates/jumps the selection on motion events — button-less maps to hover', () => { const state = makeListState(30); - // 35 = 3 | 32 (motion carrying release marker), 33 = 1 | 32, 32 alone. - // Row 5 → display-row index 3 — motion over a visible item row maps to - // a hover-row action (tooltip candidate), never a select/open action. - expect(mapMouseToAction(state, press(32, 5, 5), TERM_80x24)).toEqual({ type: 'hover-row', index: 3 }); - expect(mapMouseToAction(state, press(33, 5, 5), TERM_80x24)).toEqual({ type: 'hover-row', index: 3 }); + // 35 = 3 | 32 (button-less motion). Row 5 → display-row index 3 — + // motion over a visible item row maps to a hover-row action (tooltip + // candidate), never a select/open action. expect(mapMouseToAction(state, press(35, 5, 5), TERM_80x24)).toEqual({ type: 'hover-row', index: 3 }); }); - it('maps motion over chrome/blank rows to hover-none (pointer left the rows)', () => { + it('left-held motion (32) and middle-held motion (33) are NOT hover — left is a drag candidate', () => { + const state = makeListState(30); + // 32 = 0 | 32 (left button held + motion) → the drag mapper owns it; + // mapMouseToAction returns null (never hover, never select). + expect(mapMouseToAction(state, press(32, 5, 5), TERM_80x24)).toBeNull(); + // 33 = 1 | 32 (middle button held + motion) → inert. + expect(mapMouseToAction(state, press(33, 5, 5), TERM_80x24)).toBeNull(); + }); + + it('maps button-less motion over chrome/blank rows to hover-none (pointer left the rows)', () => { const state = makeListState(30); // Header row 1 and metadata panel rows are NOT list rows → hover-none. - expect(mapMouseToAction(state, press(32, 5, 1), TERM_80x24)).toEqual({ type: 'hover-none' }); - expect(mapMouseToAction(state, press(32, 5, 21), TERM_80x24)).toEqual({ type: 'hover-none' }); + expect(mapMouseToAction(state, press(35, 5, 1), TERM_80x24)).toEqual({ type: 'hover-none' }); + expect(mapMouseToAction(state, press(35, 5, 21), TERM_80x24)).toEqual({ type: 'hover-none' }); }); it('guards motion in detail and filter modes (motion stays inert)', () => { const d = makeListState(3); d.mode = 'detail'; d.detailItem = makeItem('a'); - expect(mapMouseToAction(d, press(32, 5, 5), TERM_80x24)).toBeNull(); + expect(mapMouseToAction(d, press(35, 5, 5), TERM_80x24)).toBeNull(); const f = makeListState(3); f.mode = 'filter'; - expect(mapMouseToAction(f, press(34, 5, 3), TERM_80x24)).toBeNull(); + expect(mapMouseToAction(f, press(35, 5, 3), TERM_80x24)).toBeNull(); }); it('ignores release events for click actions (press drives selection)', () => { @@ -534,6 +586,300 @@ describe('mapMouseToAction — drag-motion guard and button codes (AC6)', () => }); }); +// ── WL-0MSI72S0Y0060BRK: drag-to-scroll ──────────────────────────────── +// Drag-to-scroll splits motion by the SGR button field: button-less motion +// ((button & 3) === 3) keeps driving the hover tooltip, while left-button- +// held motion ((button & 32) !== 0, (button & 3) === 0) drives drag-to- +// scroll in the list and detail views. A drag never selects/opens; a +// drag-release never clicks; sub-threshold jitter is inert. + +describe('DRAG_THRESHOLD_CELLS (WL-0MSI72S0Y0060BRK)', () => { + it('is defined as a 1-row dead-zone for cell-granular pointer reports', () => { + expect(DRAG_THRESHOLD_CELLS).toBe(1); + }); + + it('createDragState returns a fresh zeroed tracker', () => { + const ds = createDragState(); + expect(ds).toEqual({ + origin: null, + accumulator: 0, + active: false, + prePressSelection: null, + restored: false, + }); + }); +}); + +describe('mapMouseDrag — threshold dead-zone and drag-start (WL-0MSI72S0Y0060BRK)', () => { + it('a left press emits drag-start and records origin + pre-press state', () => { + const state = makeListState(30); + state.selectedIndex = 5; + state.scrollOffset = 5; + const ds = createDragState(); + expect(mapMouseDrag(state, press(0, 5, 5), ds, TERM_80x24)).toEqual({ type: 'drag-start' }); + expect(ds.origin).toEqual({ x: 5, y: 5 }); + expect(ds.active).toBe(false); + expect(ds.prePressSelection).toEqual({ selectedIndex: 5, scrollOffset: 5 }); + }); + + it('sub-threshold motion (0 rows from origin) is inert', () => { + const state = makeListState(30); + const ds = createDragState(); + mapMouseDrag(state, press(0, 5, 5), ds, TERM_80x24); // press at y=5 + expect(mapMouseDrag(state, press(32, 5, 5), ds, TERM_80x24)).toBeNull(); // motion, same row + expect(ds.active).toBe(false); + }); + + it('motion just short of the threshold stays inert; crossing it activates', () => { + const state = makeListState(30); + const ds = createDragState(); + mapMouseDrag(state, press(0, 5, 5), ds, TERM_80x24); + expect(mapMouseDrag(state, press(32, 5, 5), ds, TERM_80x24)).toBeNull(); // 0 delta + expect(mapMouseDrag(state, press(32, 5, 6), ds, TERM_80x24)).toEqual({ type: 'drag-list', rows: 1 }); // 1 row + expect(ds.active).toBe(true); + }); + + it('a release ends the drag and returns null', () => { + const state = makeListState(30); + const ds = createDragState(); + mapMouseDrag(state, press(0, 5, 5), ds, TERM_80x24); + mapMouseDrag(state, press(32, 5, 6), ds, TERM_80x24); + expect(mapMouseDrag(state, { button: 0, x: 5, y: 6, release: true }, ds, TERM_80x24)).toBeNull(); + expect(ds.origin).toBeNull(); + expect(ds.active).toBe(false); + expect(ds.prePressSelection).toBeNull(); + }); +}); + +describe('mapMouseDrag — list drag (WL-0MSI72S0Y0060BRK AC1)', () => { + it('maps vertical motion to proportional whole-row deltas', () => { + const state = makeListState(30); + const ds = createDragState(); + mapMouseDrag(state, press(0, 5, 5), ds, TERM_80x24); // press + expect(mapMouseDrag(state, press(32, 5, 6), ds, TERM_80x24)).toEqual({ type: 'drag-list', rows: 1 }); + expect(mapMouseDrag(state, press(32, 5, 8), ds, TERM_80x24)).toEqual({ type: 'drag-list', rows: 2 }); + }); + + it('negative motion scrolls up (back toward the start)', () => { + const state = makeListState(30); + const ds = createDragState(); + mapMouseDrag(state, press(0, 5, 10), ds, TERM_80x24); // press at y=10 + expect(mapMouseDrag(state, press(32, 5, 8), ds, TERM_80x24)).toEqual({ type: 'drag-list', rows: -2 }); + }); + + it('never returns select-row/open-detail from a drag', () => { + const state = makeListState(30); + const ds = createDragState(); + mapMouseDrag(state, press(0, 5, 5), ds, TERM_80x24); + mapMouseDrag(state, press(32, 5, 9), ds, TERM_80x24); + // A drag crossing the threshold must not select the row under the pointer. + const second = createDragState(); + expect(mapMouseDrag(state, press(32, 5, 12), second, TERM_80x24)).toBeNull(); // no origin → inert + expect(mapMouseDrag(state, press(32, 5, 12), ds, TERM_80x24)).toEqual({ type: 'drag-list', rows: 3 }); + }); +}); + +describe('mapMouseDrag — detail drag (WL-0MSI72S0Y0060BRK AC2)', () => { + it('maps motion in detail mode to whole-line deltas (1 row ≈ 1 line)', () => { + const state = new WorkItemListState([makeScrollableItem('a')], TERM_80x24); + state.mode = 'detail'; + state.detailItem = makeScrollableItem('a'); + const ds = createDragState(); + mapMouseDrag(state, press(0, 5, 5), ds, TERM_80x24); // press + expect(mapMouseDrag(state, press(32, 5, 6), ds, TERM_80x24)).toEqual({ type: 'drag-detail', lines: 1 }); + expect(mapMouseDrag(state, press(32, 5, 9), ds, TERM_80x24)).toEqual({ type: 'drag-detail', lines: 3 }); + }); + + it('negative motion maps to upward line deltas', () => { + const state = new WorkItemListState([makeScrollableItem('a')], TERM_80x24); + state.mode = 'detail'; + state.detailItem = makeScrollableItem('a'); + const ds = createDragState(); + mapMouseDrag(state, press(0, 5, 10), ds, TERM_80x24); + expect(mapMouseDrag(state, press(32, 5, 8), ds, TERM_80x24)).toEqual({ type: 'drag-detail', lines: -2 }); + }); +}); + +describe('mapMouseDrag — filter mode and other buttons (WL-0MSI72S0Y0060BRK)', () => { + it('is inert in filter mode (out of scope)', () => { + const state = makeListState(3); + state.mode = 'filter'; + const ds = createDragState(); + expect(mapMouseDrag(state, press(0, 5, 3), ds, TERM_80x24)).toBeNull(); + expect(mapMouseDrag(state, press(32, 5, 8), ds, TERM_80x24)).toBeNull(); + }); + + it('is inert for middle/right-button motion (not a drag candidate)', () => { + const state = makeListState(30); + const ds = createDragState(); + mapMouseDrag(state, press(1, 5, 5), ds, TERM_80x24); // middle press + expect(mapMouseDrag(state, press(33, 5, 8), ds, TERM_80x24)).toBeNull(); // 33 = 1 | 32 + }); + + it('does not start a drag without a prior left press', () => { + const state = makeListState(30); + const ds = createDragState(); + expect(mapMouseDrag(state, press(32, 5, 8), ds, TERM_80x24)).toBeNull(); // no origin + }); +}); + +// ── Drag-vs-click isolation via handleMouseInput ─────────────────────── +// A press that turns into a drag must restore the pre-press selection; a +// drag-release never selects; a press-release that never moves still +// selects exactly as before. + +describe('handleMouseInput — drag-vs-click isolation (WL-0MSI72S0Y0060BRK AC3)', () => { + const click0 = (): MouseClickState => ({ lastClick: null, now: 0 }); + + it('a drag that crosses the threshold restores the pre-press selection and never selects', () => { + const state = makeListState(30); + const click = click0(); + const drag = createDragState(); + // Snapshot the selection BEFORE the press — the drag must restore it. + const prePress = { selectedIndex: state.selectedIndex, scrollOffset: state.scrollOffset }; + // Press on row 6 (index 4) — selects momentarily (click behaviour), but + // the drag-state captures the pre-press selection at the same time. + handleMouseInput(state, '\x1b[<0;5;6M', TERM_80x24, click, drag); + // Drag down 2 rows — activates and scrolls the list past the threshold. + handleMouseInput(state, '\x1b[<32;5;7M', TERM_80x24, click, drag); + handleMouseInput(state, '\x1b[<32;5;8M', TERM_80x24, click, drag); + // Selection restored to the pre-press value (AC3) — a drag never leaves + // the selection moved by the initial press. + expect(state.selectedIndex).toBe(prePress.selectedIndex); + // List scrolled by the drag distance. + expect(state.scrollOffset).toBe(prePress.scrollOffset + 2); + expect(state.mode).toBe('list'); // never opened detail + }); + + it('a drag release never selects or opens the row it was pressed on', () => { + const state = makeListState(30); + const click = click0(); + const drag = createDragState(); + handleMouseInput(state, '\x1b[<0;5;6M', TERM_80x24, click, drag); // press on row 6 + handleMouseInput(state, '\x1b[<32;5;9M', TERM_80x24, click, drag); // drag 3 rows + const preRelease = { selectedIndex: state.selectedIndex, mode: state.mode }; + handleMouseInput(state, '\x1b[<0;5;9m', TERM_80x24, click, drag); // release + expect(state.selectedIndex).toBe(preRelease.selectedIndex); // unchanged by release + expect(state.mode).toBe(preRelease.mode); // list — no detail open + }); + + it('a press-release that never crosses the threshold still selects exactly as before', () => { + const state = makeListState(30); + const click = click0(); + const drag = createDragState(); + handleMouseInput(state, '\x1b[<0;5;3M', TERM_80x24, click, drag); // press on row 3 (index 1) + expect(state.selectedIndex).toBe(1); // still selects on press + handleMouseInput(state, '\x1b[<0;5;3m', TERM_80x24, click, drag); // release — inert + expect(state.selectedIndex).toBe(1); + expect(state.mode).toBe('list'); + }); + + it('a plain click still opens detail on a double-click (no drag interference)', () => { + const state = makeListState(30); + const click = click0(); + const drag = createDragState(); + handleMouseInput(state, '\x1b[<0;5;3M', TERM_80x24, click, drag); + handleMouseInput(state, '\x1b[<0;5;3M', TERM_80x24, click, drag); // second press, same row + expect(state.mode).toBe('detail'); // double-click still opens + }); +}); + +// ── drag-detail dispatch + clamping via handleMouseInput ────────────── + +describe('handleMouseInput — detail drag dispatch and clamping (WL-0MSI72S0Y0060BRK AC2)', () => { + const click0 = (): MouseClickState => ({ lastClick: null, now: 0 }); + + const detailState = (): WorkItemListState => { + const state = new WorkItemListState([makeScrollableItem('a')], TERM_80x24); + state.mode = 'detail'; + state.detailItem = makeScrollableItem('a'); + return state; + }; + + it('scrolls the detail view by whole lines in both focus states', () => { + const state = detailState(); + const click = click0(); + const drag = createDragState(); + handleMouseInput(state, '\x1b[<0;5;5M', TERM_80x24, click, drag); // press + handleMouseInput(state, '\x1b[<32;5;7M', TERM_80x24, click, drag); // drag down 2 + expect(state.detailScrollOffset).toBeGreaterThan(0); + const offset = state.detailScrollOffset; + handleMouseInput(state, '\x1b[<32;5;6M', TERM_80x24, click, drag); // drag up 1 + expect(state.detailScrollOffset).toBe(offset - 1); + expect(state.selectedIndex).toBe(0); // selection untouched by detail drag + }); + + it('clamps the detail scroll at the content bounds (no overscroll)', () => { + const state = detailState(); + const click = click0(); + const drag = createDragState(); + handleMouseInput(state, '\x1b[<0;5;5M', TERM_80x24, click, drag); // press + // Drag down in steps until the offset stops growing (clamped at the + // content maxScroll bound). + let last = -1; + for (let i = 1; i <= 200 && state.detailScrollOffset > last; i++) { + last = state.detailScrollOffset; + handleMouseInput(state, `\x1b[<32;5;${5 + i}M`, TERM_80x24, click, drag); + } + const maxScroll = state.detailScrollOffset; + expect(maxScroll).toBeGreaterThan(0); // the long description is scrollable + handleMouseInput(state, '\x1b[<32;5;250M', TERM_80x24, click, drag); // far beyond + expect(state.detailScrollOffset).toBe(maxScroll); // clamped, no overscroll + }); + + it('clamps the list scroll at 0 / maxOffset without moving the selection', () => { + const state = makeListState(30); + const click = click0(); + const drag = createDragState(); + handleMouseInput(state, '\x1b[<0;5;5M', TERM_80x24, click, drag); + // Drag far up — offset must clamp at 0. + handleMouseInput(state, '\x1b[<32;5;2M', TERM_80x24, click, drag); + expect(state.scrollOffset).toBe(0); + expect(state.selectedIndex).toBe(0); // unchanged + // Drag far down — offset must clamp at maxOffset. + handleMouseInput(state, '\x1b[<32;5;40M', TERM_80x24, click, drag); + const maxOffset = Math.max(0, state.flatCount - state._listHeight()); + expect(state.scrollOffset).toBe(maxOffset); // clamped, no overscroll + expect(state.selectedIndex).toBe(0); // selection never moved + }); +}); + +// ── Hover/drag separation via handleMouseInput ──────────────────────── + +describe('handleMouseInput — hover/drag split (WL-0MSI72S0Y0060BRK AC4)', () => { + const click0 = (): MouseClickState => ({ lastClick: null, now: 0 }); + + it('button-less motion still drives the hover tooltip', () => { + const state = makeListState(30); + const click = click0(); + const drag = createDragState(); + handleMouseInput(state, '\x1b[<35;5;5M', TERM_80x24, click, drag); // 35 = 3|32 — button-less motion + expect(state.hoveredRowIndex).toBe(3); // row 5 → display index 3 + handleMouseInput(state, '\x1b[<35;5;1M', TERM_80x24, click, drag); // chrome row + expect(state.hoveredRowIndex).toBeNull(); + }); + + it('left-button-held motion drives drag, never hover', () => { + const state = makeListState(30); + const click = click0(); + const drag = createDragState(); + handleMouseInput(state, '\x1b[<0;5;5M', TERM_80x24, click, drag); // press + handleMouseInput(state, '\x1b[<32;5;7M', TERM_80x24, click, drag); // 32 = left held + motion + expect(state.hoveredRowIndex).toBeNull(); // drag, not hover + expect(state.scrollOffset).toBeGreaterThan(0); // list scrolled + }); + + it('drag is inert in filter mode', () => { + const state = makeListState(3); + state.mode = 'filter'; + const click = click0(); + const drag = createDragState(); + handleMouseInput(state, '\x1b[<0;5;3M', TERM_80x24, click, drag); + handleMouseInput(state, '\x1b[<32;5;8M', TERM_80x24, click, drag); + expect(state.scrollOffset).toBe(0); + }); +}); + // ── AC6: unknown-sequence regression ─────────────────────────────────── describe('unknown-sequence regression (AC6)', () => { @@ -745,7 +1091,7 @@ describe('runWorklistTui — Alt+m mouse-tracking toggle (WL-0MT0AP2LR000JFWN)', } }); - it('footer shows the current mouse-tracking state (on/off)', async () => { + it('footer no longer advertises mouse-tracking state while Alt+m still toggles', async () => { const { dataListeners, writes, restore } = setupFakeTty(); try { const tui = runWorklistTui( @@ -755,22 +1101,28 @@ describe('runWorklistTui — Alt+m mouse-tracking toggle (WL-0MT0AP2LR000JFWN)', { autoRefresh: false, autoSync: false }, ); - // Initial render: hint says mouse on. + // The footer advertisement was removed (WL-0MV85QZXN002T51A AC3). await vi.waitFor(() => { - expect(writes.join('')).toContain('alt+m mouse on'); + expect(writes.join('')).toContain(ANSI.mouseEnable); }); + expect(writes.join('')).not.toContain('alt+m mouse on'); + expect(writes.join('')).not.toContain('alt+m mouse off'); - // Alt+m → hint flips to mouse off. + // Alt+m still toggles mouse tracking OFF (disable sequences emitted). dataListeners.forEach((cb) => cb(Buffer.from('\x1bm'))); await vi.waitFor(() => { - expect(writes.join('')).toContain('alt+m mouse off'); + expect(writes.join('')).toContain(ANSI.mouseDisable); }); - // Alt+m again → hint flips back to mouse on. + // Alt+m again still toggles it back ON (a second enable sequence). dataListeners.forEach((cb) => cb(Buffer.from('\x1bm'))); await vi.waitFor(() => { - expect(writes.join('')).toContain('alt+m mouse on'); + const enableCount = writes.join('').split(ANSI.mouseEnable).length - 1; + expect(enableCount).toBe(2); }); + // Still no footer advertisement after toggling. + expect(writes.join('')).not.toContain('alt+m mouse on'); + expect(writes.join('')).not.toContain('alt+m mouse off'); dataListeners.forEach((cb) => cb(Buffer.from('q'))); await tui; diff --git a/packages/herdr/src/worklist-tooltip.test.ts b/packages/herdr/src/worklist-tooltip.test.ts index e18c113ff..5627d353e 100644 --- a/packages/herdr/src/worklist-tooltip.test.ts +++ b/packages/herdr/src/worklist-tooltip.test.ts @@ -5,9 +5,12 @@ * Contract under test: * - Tooltip state machine (show / Esc-dismiss / re-show on re-entry): * `state.hoveredRowIndex` + `state.tooltipDismissed`. - * - Motion-to-row mapping: motion events (button & 32) map to - * `hover-row` (over an item row) or `hover-none` (over chrome/blank - * rows) in list mode, and never navigate (AC5/AC6). + * - Motion-to-row mapping: BUTTON-LESS motion events (button 3 | 32 = 35) + * map to `hover-row` (over an item row) or `hover-none` (over + * chrome/blank rows) in list mode, and never navigate (AC5/AC6). + * Left-button-held motion (button 32) is a drag candidate owned by + * handleMouseInput — it never drives the tooltip (WL-0MSI72S0Y0060BRK + * hover/drag split, Q5c). * - Metadata formatting: `formatTooltipLines` renders all 8 fields * (ID, Title, Command, Priority, Type, Risk, Effort, Start Time). * - Backward-compatible `agent-panes.json` parsing: entries lacking the @@ -77,7 +80,7 @@ describe('tooltip state machine (WL-0MT9XRZDK006GMUH AC2/AC7)', () => { const state = makeListState(30); const click: MouseClickState = { lastClick: null, now: 0 }; // Motion over row 5 → display-row index 3. - expect(handleMouseInput(state, '\x1b[<32;5;5M', TERM_80x24, click)).toBe(true); + expect(handleMouseInput(state, '\x1b[<35;5;5M', TERM_80x24, click)).toBe(true); expect(state.hoveredRowIndex).toBe(3); expect(state.tooltipDismissed).toBe(false); }); @@ -85,7 +88,7 @@ describe('tooltip state machine (WL-0MT9XRZDK006GMUH AC2/AC7)', () => { it('esc dismisses the tooltip: clears hover and sets dismissed', () => { const state = makeListState(30); const click: MouseClickState = { lastClick: null, now: 0 }; - handleMouseInput(state, '\x1b[<32;5;5M', TERM_80x24, click); + handleMouseInput(state, '\x1b[<35;5;5M', TERM_80x24, click); expect(state.hoveredRowIndex).toBe(3); const action = handleKeypress(state, '\x1b', TERM_80x24); @@ -97,12 +100,12 @@ describe('tooltip state machine (WL-0MT9XRZDK006GMUH AC2/AC7)', () => { it('while dismissed, motion over a row does NOT re-show the tooltip (stays dismissed)', () => { const state = makeListState(30); const click: MouseClickState = { lastClick: null, now: 0 }; - handleMouseInput(state, '\x1b[<32;5;5M', TERM_80x24, click); + handleMouseInput(state, '\x1b[<35;5;5M', TERM_80x24, click); handleKeypress(state, '\x1b', TERM_80x24); expect(state.tooltipDismissed).toBe(true); // Motion over a DIFFERENT row while the pointer is still in the rows area. - handleMouseInput(state, '\x1b[<32;5;8M', TERM_80x24, click); + handleMouseInput(state, '\x1b[<35;5;8M', TERM_80x24, click); expect(state.hoveredRowIndex).toBe(6); expect(state.tooltipDismissed).toBe(true); // still dismissed — no re-show }); @@ -110,17 +113,17 @@ describe('tooltip state machine (WL-0MT9XRZDK006GMUH AC2/AC7)', () => { it('motion over chrome rows (hover-none) clears dismissal — re-entry re-shows', () => { const state = makeListState(30); const click: MouseClickState = { lastClick: null, now: 0 }; - handleMouseInput(state, '\x1b[<32;5;5M', TERM_80x24, click); + handleMouseInput(state, '\x1b[<35;5;5M', TERM_80x24, click); handleKeypress(state, '\x1b', TERM_80x24); expect(state.tooltipDismissed).toBe(true); // Pointer leaves the rows area (header row 1) → hover-none clears. - handleMouseInput(state, '\x1b[<32;5;1M', TERM_80x24, click); + handleMouseInput(state, '\x1b[<35;5;1M', TERM_80x24, click); expect(state.hoveredRowIndex).toBeNull(); expect(state.tooltipDismissed).toBe(false); // Re-entry over a row re-shows the tooltip candidate. - handleMouseInput(state, '\x1b[<32;5;5M', TERM_80x24, click); + handleMouseInput(state, '\x1b[<35;5;5M', TERM_80x24, click); expect(state.hoveredRowIndex).toBe(3); expect(state.tooltipDismissed).toBe(false); }); @@ -134,7 +137,7 @@ describe('tooltip state machine (WL-0MT9XRZDK006GMUH AC2/AC7)', () => { it('motion events never select or navigate (AC5/AC6)', () => { const state = makeListState(30); const click: MouseClickState = { lastClick: null, now: 0 }; - handleMouseInput(state, '\x1b[<32;5;5M', TERM_80x24, click); + handleMouseInput(state, '\x1b[<35;5;5M', TERM_80x24, click); expect(state.selectedIndex).toBe(0); // unchanged expect(state.mode).toBe('list'); // unchanged }); @@ -142,8 +145,8 @@ describe('tooltip state machine (WL-0MT9XRZDK006GMUH AC2/AC7)', () => { it('releases do not disturb tooltip state', () => { const state = makeListState(30); const click: MouseClickState = { lastClick: null, now: 0 }; - handleMouseInput(state, '\x1b[<32;5;5M', TERM_80x24, click); // motion press - handleMouseInput(state, '\x1b[<32;5;5m', TERM_80x24, click); // motion release + handleMouseInput(state, '\x1b[<35;5;5M', TERM_80x24, click); // motion press + handleMouseInput(state, '\x1b[<35;5;5m', TERM_80x24, click); // motion release expect(state.hoveredRowIndex).toBe(3); }); }); @@ -154,23 +157,23 @@ describe('mapMouseToAction — motion-to-row mapping (AC4/AC7)', () => { it('maps motion over a visible item row to the display-row index', () => { const state = makeListState(30); // Row 2 → index 0; row 5 → index 3; row 14 → index 12 (fixture rows 2..14). - expect(mapMouseToAction(state, motion(0, 5, 2), TERM_80x24)).toEqual({ type: 'hover-row', index: 0 }); - expect(mapMouseToAction(state, motion(0, 5, 5), TERM_80x24)).toEqual({ type: 'hover-row', index: 3 }); - expect(mapMouseToAction(state, motion(0, 5, 14), TERM_80x24)).toEqual({ type: 'hover-row', index: 12 }); + expect(mapMouseToAction(state, motion(3, 5, 2), TERM_80x24)).toEqual({ type: 'hover-row', index: 0 }); + expect(mapMouseToAction(state, motion(3, 5, 5), TERM_80x24)).toEqual({ type: 'hover-row', index: 3 }); + expect(mapMouseToAction(state, motion(3, 5, 14), TERM_80x24)).toEqual({ type: 'hover-row', index: 12 }); }); it('maps motion over chrome/blank rows to hover-none', () => { const state = makeListState(30); - expect(mapMouseToAction(state, motion(0, 5, 1), TERM_80x24)).toEqual({ type: 'hover-none' }); // header - expect(mapMouseToAction(state, motion(0, 5, 20), TERM_80x24)).toEqual({ type: 'hover-none' }); // footer - expect(mapMouseToAction(state, motion(0, 5, 21), TERM_80x24)).toEqual({ type: 'hover-none' }); // metadata panel + expect(mapMouseToAction(state, motion(3, 5, 1), TERM_80x24)).toEqual({ type: 'hover-none' }); // header + expect(mapMouseToAction(state, motion(3, 5, 20), TERM_80x24)).toEqual({ type: 'hover-none' }); // footer + expect(mapMouseToAction(state, motion(3, 5, 21), TERM_80x24)).toEqual({ type: 'hover-none' }); // metadata panel }); it('accounts for the top fold indicator when scrolled', () => { const state = makeListState(30); state.scrollOffset = 20; - expect(mapMouseToAction(state, motion(0, 5, 2), TERM_80x24)).toEqual({ type: 'hover-none' }); // '▲ more' - expect(mapMouseToAction(state, motion(0, 5, 3), TERM_80x24)).toEqual({ type: 'hover-row', index: 20 }); + expect(mapMouseToAction(state, motion(3, 5, 2), TERM_80x24)).toEqual({ type: 'hover-none' }); // '▲ more' + expect(mapMouseToAction(state, motion(3, 5, 3), TERM_80x24)).toEqual({ type: 'hover-row', index: 20 }); }); }); @@ -199,6 +202,10 @@ describe('formatTooltipLines — metadata formatting (AC1/AC7)', () => { expect(text).toContain('critical'); expect(text).toContain('Type:'); expect(text).toContain('epic'); + // The epic glyph was removed from the tooltip Type line + // (WL-0MVAQ873D007LNWD); epic renders as plain text like every other type. + expect(text).not.toContain('\u{2299}'); // ⊙ + expect(text).toContain('epic'); expect(text).toContain('Risk:'); expect(text).toContain('High'); expect(text).toContain('Effort:'); diff --git a/packages/herdr/src/worklist-visibility.test.ts b/packages/herdr/src/worklist-visibility.test.ts index b9ff31d7a..127ca7d0f 100644 --- a/packages/herdr/src/worklist-visibility.test.ts +++ b/packages/herdr/src/worklist-visibility.test.ts @@ -695,4 +695,53 @@ describe('worklist — event-driven focus/visibility (WL-0MSHB7DHO004RHBJ)', () await subscriber.close(); await quit(p); }); + + it('after a stream close + reconnect, a pane_focused event for the current pane refreshes again (WL-0MVAS988R008PNH6)', async () => { + // AC3: when the events stream reconnects after a server-side close, the + // pane-focus → refresh path must resume — the list refreshes the moment + // the pane regains focus instead of waiting for the next 30 s tick. + const { createMockSocket } = await import('./test-utils/mock-herdr-socket.js'); + const { HerdrEventSubscriber } = await import('./events.js'); + const mockServer = await createMockSocket(); + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks: {}, + reconnectBaseDelayMs: 50, + maxReconnectAttempts: 10, + }); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeExecMock(true) as any); + + const fetcher = vi.fn().mockResolvedValue([]); + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + subscriber, + }); + await new Promise((r) => setTimeout(r, 100)); + const client = mockServer.getFirstClient(); + expect(client).toBeDefined(); + fetcher.mockClear(); + + // Server-side stream close (the stream_closed scenario) → the + // subscriber reconnects to the same mock address automatically. + client!.close(); + await new Promise((r) => setTimeout(r, 400)); + expect(subscriber.health().status).toBe('active'); + fetcher.mockClear(); + + // Focus the pane again on the RECONNECTED stream → immediate refresh. + const clients = mockServer.getConnectedClients(); + const newest = clients[clients.length - 1]; + expect(newest).toBeDefined(); + newest!.pushEvent({ event: 'pane_focused', data: { pane_id: 'w1:pCM' } }); + await new Promise((r) => setTimeout(r, 150)); + expect(fetcher).toHaveBeenCalled(); + + await subscriber.close(); + await mockServer.stop(); + await quit(p); + }); }); diff --git a/packages/herdr/src/worklist.test.ts b/packages/herdr/src/worklist.test.ts index 89a369745..d9b9399d3 100644 --- a/packages/herdr/src/worklist.test.ts +++ b/packages/herdr/src/worklist.test.ts @@ -26,6 +26,7 @@ import { computeMetadataPanelHeight, computeDynamicLayout, formatMetadataPanel, + metadataPanelContentHeight, resolveMetaLastCommand, formatTimestamp, buildMetaRows, @@ -39,6 +40,7 @@ import { formatChordHintsForHelp, formatFooterShortcutHints, resolvePodcastTarget, + formatPodcastNotice, clearDescriptionPreviewCache, isHeadingRow, formatItemLine, @@ -46,6 +48,8 @@ import { formatBlockedShipDialog, createGatedTick, reviewQueueBannerText, + runWorklistTui, + keyToAction, } from './worklist.js'; import type { DisplayRow } from './worklist.js'; import type { ChordState } from './worklist.js'; @@ -3234,6 +3238,89 @@ describe('formatMetadataPanel — field rendering and scrolling', () => { }); }); +describe('formatMetadataPanel — Blocked By rows (WL-0MV8EZRDJ001TDA8)', () => { + // Strip ANSI SGR codes so tests assert on visible text. + const visibleOf = (lines: string[]): string => lines.join('\n').replace(/\x1b\[[0-9;]*m/g, ''); + + it('renders one Blocked By row per active blocker with ID and title', () => { + const blockers = [ + { id: 'WL-B1', title: 'First blocker' }, + { id: 'WL-B2', title: 'Second blocker' }, + ]; + const lines = visibleOf(formatMetadataPanel(makeRichItem(), 80, 20, 0, 'cmd', false, blockers)); + expect(lines).toMatch(/Blocked By\s+WL-B1 — First blocker/); + expect(lines).toMatch(/Blocked By\s+WL-B2 — Second blocker/); + }); + + it('renders Blocked By rows for a dependency-blocked open item', () => { + const item = { ...makeRichItem(), status: 'open', description: undefined }; + const lines = visibleOf(formatMetadataPanel(item, 80, 20, 0, undefined, false, [ + { id: 'WL-B1', title: 'Still blocking' }, + ])); + expect(lines).toMatch(/Blocked By\s+WL-B1 — Still blocking/); + }); + + it('omits the Blocked By rows entirely when there are no active blockers', () => { + for (const blockedBy of [undefined, null, []]) { + const lines = visibleOf( + formatMetadataPanel(makeRichItem(), 80, 20, 0, 'cmd', false, blockedBy as never), + ); + expect(lines).not.toContain('Blocked By'); + } + }); + + it('falls back to the ID alone when a blocker has no title', () => { + const lines = visibleOf(formatMetadataPanel(makeRichItem(), 80, 20, 0, undefined, false, [ + { id: 'WL-NOTITLE', title: '' }, + ])); + expect(lines).toMatch(/Blocked By\s+WL-NOTITLE\s*$/m); + expect(lines).not.toContain('WL-NOTITLE —'); + }); + + it('appends the blocker status (icon + text) after the title when present', () => { + const blockers = [ + { id: 'WL-B1', title: 'First blocker', status: 'in-progress' }, + { id: 'WL-B2', title: 'Second blocker', status: 'blocked' }, + ]; + const lines = visibleOf(formatMetadataPanel(makeRichItem(), 80, 20, 0, undefined, false, blockers)); + expect(lines).toMatch(/Blocked By\s+WL-B1 — First blocker \u{1F504} in-progress/u); + expect(lines).toMatch(/Blocked By\s+WL-B2 — Second blocker \u{26D4} blocked/u); + }); + + it('renders the blocker status as plain text only when icons are disabled (noIcons)', () => { + const blockers = [{ id: 'WL-B1', title: 'First blocker', status: 'in-progress' }]; + const withIcons = visibleOf(formatMetadataPanel(makeRichItem(), 80, 20, 0, undefined, false, blockers)); + expect(withIcons).toContain('🔄'); + const noIconLines = visibleOf(formatMetadataPanel(makeRichItem(), 80, 20, 0, undefined, true, blockers)); + expect(noIconLines).toMatch(/Blocked By\s+WL-B1 — First blocker in-progress/); + expect(noIconLines).not.toContain('🔄'); + }); + + it('renders no status suffix when the blocker has no status', () => { + const lines = visibleOf(formatMetadataPanel(makeRichItem(), 80, 20, 0, undefined, false, [ + { id: 'WL-B1', title: 'No status ref' }, + ])); + expect(lines).toMatch(/Blocked By\s+WL-B1 — No status ref\s*$/m); + }); + + it('renders the Blocked By rows after the metadata rows and before the description preview', () => { + const item = { ...makeRichItem(), description: 'Some description here.' }; + const raw = formatMetadataPanel(item, 80, 30, 0, undefined, false, [ + { id: 'WL-B1', title: 'First blocker' }, + ]).map((l) => l.replace(/\x1b\[[0-9;]*m/g, '')); + const blockerIdx = raw.findIndex((l) => l.includes('WL-B1')); + expect(blockerIdx).toBeGreaterThan(-1); + // A metadata row (e.g. Status+Stage) precedes it; the Description + // preview follows it. + const metaIdx = raw.findIndex((l) => l.includes('Status')); + const descIdx = raw.findIndex((l) => l.includes('Description')); + expect(metaIdx).toBeGreaterThan(-1); + expect(descIdx).toBeGreaterThan(-1); + expect(metaIdx).toBeLessThan(blockerIdx); + expect(blockerIdx).toBeLessThan(descIdx); + }); +}); + describe('formatMetadataPanel — description preview (WL-0MT9ZJF28004UJ28)', () => { // Strip ANSI SGR codes so tests assert on visible text. const visibleOf = (lines: string[]): string => lines.join('\n').replace(/\x1b\[[0-9;]*m/g, ''); @@ -3474,10 +3561,71 @@ describe('WorkItemListState — metadata scroll state', () => { /* bannerCount = */ 0, TERM_80x24, ); - const content = formatMetadataPanel(state.getSelectedItem()!, TERM_80x24.cols, panelHeight, 0); - const maxScroll = Math.max(0, content.length - panelHeight); + // The clamp uses the UNPADDED content height (metadataPanelContentHeight), + // not formatMetadataPanel's padded return (always exactly panelHeight). + const contentHeight = metadataPanelContentHeight( + state.getSelectedItem()!, + TERM_80x24.cols, + panelHeight, + ); + const maxScroll = Math.max(0, contentHeight - panelHeight); state.metaScrollDown(100, panelHeight); expect(state.metaScrollOffset).toBe(maxScroll); + // For a rich item the content genuinely overflows a small panel. + state.metaScrollDown(100, 4); + expect(state.metaScrollOffset).toBeGreaterThan(0); + }); + + it('clamps metaScrollDown to include Blocked By rows (WL-0MV8EZRDJ001TDA8)', () => { + const state = new WorkItemListState([makeRichItem()], TERM_80x24); + // A deliberately small panel height forces overflow in both cases. + const panelHeight = 4; + const blockers = Array.from({ length: 6 }, (_, i) => ({ + id: `WL-B${i}`, + title: `Blocker ${i}`, + })); + + // Without blockers. + state.metaScrollDown(1000, panelHeight); + const plainMax = state.metaScrollOffset; + + // The render loop keeps state.metaBlockedBy in sync with the rows it + // shows; metaScrollDown must clamp against THOSE rows so `m` reaches the + // true bottom of the panel. + state.metaScrollOffset = 0; + state.metaBlockedBy = blockers; + state.metaScrollDown(1000, panelHeight); + const blockedMax = state.metaScrollOffset; + + expect(plainMax).toBeGreaterThan(0); + expect(blockedMax).toBeGreaterThan(plainMax); + }); + + it('clamps detailScrollDown to include Blocked By rows (WL-0MVBMLY2Z0032CYR)', () => { + const state = new WorkItemListState([makeRichItem()], TERM_80x24); + state.selectedIndex = 0; + state.selectItem(); // enter detail mode + // A deliberately tiny viewport forces overflow in both cases (a 24-row + // term and a short item keep plainMax at 0). The detail-scroll clamp + // must count the `Blocked By` rows the renderer shows or they become + // unreachable when the view scrolls. + const blockers = Array.from({ length: 6 }, (_, i) => ({ + id: `WL-B${i}`, + title: `Blocker ${i}`, + })); + + state.detailScrollDown(9999); + const plainMax = state.detailScrollOffset; + + // The render loop keeps state.detailBlockedBy in sync with the rows it + // shows; detailScrollDown must clamp against THOSE rows. + state.detailScrollOffset = 0; + state.detailBlockedBy = blockers; + state.detailScrollDown(9999); + const blockedMax = state.detailScrollOffset; + + expect(blockedMax).toBeGreaterThan(plainMax); + expect(blockedMax).toBeGreaterThan(0); }); it('metaScrollUp clamps at zero', () => { @@ -3572,6 +3720,38 @@ describe('handleKeypress — metadata scroll keys', () => { // the ShortcutRegistry (Ship It dialog) like every other shortcut, and stays // distinct from lowercase `s` (Search). +// ── Ctrl+r manual refresh (WL-0MV85T0LT008UT9H) ──────────────────────── +// The legacy bare-`r` refresh was removed (it collides with the `r` chord +// leader); Ctrl+r (\x12) is the re-instated, unambiguous manual-refresh key +// in list mode. The `refresh` action it returns drives the existing +// `doRefresh(true)` path, which preserves the selection across the refetch. + +describe('Ctrl+r manual refresh (WL-0MV85T0LT008UT9H)', () => { + it('maps Ctrl+r (\x12) to the refresh action', () => { + expect(keyToAction('\x12')).toBe('refresh'); + }); + + it('handleKeypress returns refresh for Ctrl+r in list mode', () => { + const state = new WorkItemListState([makeItem('A'), makeItem('B')], TERM_80x24); + expect(handleKeypress(state, '\x12', TERM_80x24)).toBe('refresh'); + // Navigation state is untouched — the caller performs the refetch. + expect(state.mode).toBe('list'); + expect(state.selectedIndex).toBe(0); + }); + + it('does not bind Ctrl+r in detail mode (list-mode-only scope)', () => { + const state = new WorkItemListState([makeItem('A')], TERM_80x24); + state.selectItem(); + expect(state.mode).toBe('detail'); + expect(handleKeypress(state, '\x12', TERM_80x24)).toBeNull(); + }); + + it('keeps bare r keyboard-neutral (r remains the chord leader)', () => { + const state = new WorkItemListState([makeItem('A')], TERM_80x24); + expect(handleKeypress(state, 'r', TERM_80x24)).toBeNull(); + }); +}); + describe('keyToAction — manual sync binding removed', () => { it('does not map S to the sync action anymore', () => { const state = new WorkItemListState([makeItem('A')], TERM_80x24); @@ -3951,7 +4131,7 @@ describe('metadata views — paired-row compression (WL-0MSNIX4V60012266)', () = // icons as the list rows, paired with their text label (icon + text, e.g. // `🔄 in_progress`, `⭐ high`, `📥 intake_complete`). The Stage row mirrors // the list's audit-aware in_review icon via the shared stageDisplayIcon -// helper; the Type row shows the epic icon for epic items only; Audit and +// helper; the Type row renders plain text (no icon) for every type; Audit and // Reviewed rows pair their icons with text labels. With icons disabled the // values fall back to plain text — no emoji, no bracketed fallbacks. @@ -3971,9 +4151,10 @@ describe('buildMetaRows — icon + text metadata values (WL-0MSGIXHHI009KFW9)', expect(rows.get('Effort')).toBe('3'); }); - it('shows the epic icon on the Type row for epic items only', () => { + it('renders the Type row as plain text for every type (no epic icon)', () => { const epic = { ...makeRichItem(), issueType: 'epic' }; - expect(new Map(buildMetaRows(epic)).get('Type')).toBe('\u{2299} epic'); // ⊙ + // The `⊙` epic icon was removed from the Type row (WL-0MVAQ873D007LNWD). + expect(new Map(buildMetaRows(epic)).get('Type')).toBe('epic'); expect(new Map(buildMetaRows(makeRichItem())).get('Type')).toBe('feature'); }); @@ -4000,7 +4181,7 @@ describe('buildMetaRows — icon + text metadata values (WL-0MSGIXHHI009KFW9)', // No icon glyphs leak into the noIcons values. for (const [label, value] of rows) { if (['Status', 'Stage', 'Priority', 'Type', 'Risk', 'Effort', 'Audit', 'Reviewed'].includes(label)) { - expect(value).not.toMatch(/[\u{1F300}-\u{1FAFF}\u{2700}-\u{27BF}\u{2600}-\u{26FF}\u{2299}]/u); + expect(value).not.toMatch(/[\u{1F300}-\u{1FAFF}\u{2700}-\u{27BF}\u{2600}-\u{26FF}]/u); expect(value).not.toMatch(/^\[/); } } @@ -4519,6 +4700,56 @@ function makeKeyFilesItem(id: string, paths: string[]): WorkItem { }; } +// ── formatDetailContent — Blocked By rows (WL-0MVBMLY2Z0032CYR) ─────── +// The detail view renders the SAME active `Blocked By` rows as the +// selection-list preview (reversing the documented divergence +// WL-0MV8Q5X8U004KKAP AC-5): one row per blocker after the metadata table, +// with the blocker's status (icon + text) appended when present. + +describe('formatDetailContent — Blocked By rows (WL-0MVBMLY2Z0032CYR)', () => { + const visibleOf = (lines: string[]): string => lines.join('\n').replace(/\x1b\[[0-9;]*m/g, ''); + + it('renders one Blocked By row per active blocker with ID, title and status (icon + text)', () => { + const blockers = [ + { id: 'WL-B1', title: 'First blocker', status: 'in-progress' }, + { id: 'WL-B2', title: 'Second blocker' }, + ]; + const joined = visibleOf(formatDetailContent(makeRichItem(), 80, undefined, false, 0, true, 0, blockers)); + expect(joined).toMatch(/\| Blocked By\s+\| WL-B1 — First blocker \u{1F504} in-progress \|/u); + expect(joined).toMatch(/\| Blocked By\s+\| WL-B2 — Second blocker \|/); + }); + + it('places the Blocked By rows after the metadata table and before the description body', () => { + const item = { ...makeRichItem(), description: 'Body text here.' }; + const lines = visibleOf(formatDetailContent(item, 80, undefined, false, 0, true, 0, [ + { id: 'WL-B1', title: 'First blocker', status: 'blocked' }, + ])).split('\n'); + const metaIdx = lines.findIndex((l) => l.includes('| Status')); + const blockerIdx = lines.findIndex((l) => l.includes('WL-B1')); + const descIdx = lines.findIndex((l) => l.includes('Description')); + expect(metaIdx).toBeGreaterThan(-1); + expect(blockerIdx).toBeGreaterThan(-1); + expect(descIdx).toBeGreaterThan(-1); + expect(metaIdx).toBeLessThan(blockerIdx); + expect(blockerIdx).toBeLessThan(descIdx); + }); + + it('renders the blocker status as plain text only when icons are disabled (noIcons)', () => { + const joined = visibleOf(formatDetailContent(makeRichItem(), 80, undefined, true, 0, true, 0, [ + { id: 'WL-B1', title: 'First blocker', status: 'in-progress' }, + ])); + expect(joined).toMatch(/\| Blocked By\s+\| WL-B1 — First blocker in-progress \|/); + expect(joined).not.toContain('🔄'); + }); + + it('omits the Blocked By rows entirely when there are no active blockers', () => { + for (const blockedBy of [undefined, null, []]) { + const joined = formatDetailContent(makeRichItem(), 80, undefined, false, 0, true, 0, blockedBy as never).join('\n'); + expect(joined).not.toContain('Blocked By'); + } + }); +}); + describe('buildMetaRows — Related Docs row (WL-0MSHWHRIF001YHF8)', () => { it('emits a Related Docs row listing every .md path from Key Files, joined with ", "', () => { const item = makeKeyFilesItem('WL-REL', ['docs/prd.md', 'docs/episode.podcast.md']); @@ -5912,7 +6143,9 @@ describe('createListRenderer — header truncation (WL-0MSNI6TQ5003JY1Z)', () => expect(visible.length).toBeLessThanOrEqual(cols); // At 120 cols the header may be truncated at the very tail, but the // core segments should all be present up to the truncation point. - expect(visible).toContain('[auto-refresh on]'); + // The `[auto-refresh on]` segment was removed entirely — it must never + // render, even when `autoRefresh` is passed as true (WL-0MV85T0LT008UT9H). + expect(visible).not.toContain('[auto-refresh on]'); expect(visible).toContain('[paused — hidden]'); // The downtime segment may be cut at the truncation boundary. expect(visible).toContain('[downtime'); @@ -6488,3 +6721,353 @@ describe('formatItemLine — priority title + stage id (WL-0MSJ2JFMO007PGQ6)', ( expect(line).toContain('WL-999'); }); }); + +// ── Footer help-line: advertise `?: Help`, drop downtime + mouse hints ── +// Test-first pins for parent help screen work (WL-0MTV97GV20088CS7 AC1, AC2, +// AC7a). The footer advertises `?: Help` instead of the low-value +// `d:downtime toggle` single-key entry and the `alt+m mouse on/off` suffix. +// The key bindings (AC4) are asserted with plain `it` — they stay green; only +// the advertisement changes. +describe('footer help-line advertises ?: Help and drops downtime/mouse hints (WL-0MV85QZCC002U28M)', () => { + // The live list entries the render path feeds to formatFooterShortcutHints(). + const listEntries = (): ShortcutEntry[] => + loadShortcutConfig() + .getEntriesForStage('plan_complete', false, 'task') + .filter(e => e.view === 'list' || e.view === 'both'); + + // AC1: the composed footer advertises the help screen. + it('advertises the literal ?: Help in the footer hint line', () => { + expect(formatFooterShortcutHints(listEntries())).toContain('?: Help'); + }); + + // AC2: the low-value downtime toggle is no longer advertised. + it('no longer advertises the d:downtime toggle single-key entry', () => { + expect(formatFooterShortcutHints(listEntries())).not.toContain('d:downtime toggle'); + }); + + // AC4: the d binding itself is untouched — only its advertisement changes. + // This regression guard is GREEN before and after the implementation. + it('keeps the d single-key downtime-toggle binding intact in the registry', () => { + const registry = loadShortcutConfig(); + expect(registry.lookupChordEntry(['d'], 'list')?.command).toBe('/downtime toggle'); + }); + + // AC5: the existing showHelpText gate still hides the new `?: Help` hint. + it('showHelpText: false hides the ?: Help hint while it is shown when enabled', () => { + const renderer = createListRenderer(); + const hints = formatFooterShortcutHints(listEntries()); + const items: WorkItem[] = [makeItem('A')]; + const renderWith = (showHelpText: boolean): string => + renderer( + items, 0, 0, TERM_80x24, null, 'list', null, + undefined, null, 0, false, undefined, hints, + 0, false, false, 0, undefined, undefined, undefined, + 0, false, undefined, showHelpText, + ); + expect(renderWith(true)).toContain('?: Help'); + expect(renderWith(false)).not.toContain('?: Help'); + }); + + // ── Composed-footer integration: the real TUI render loop ───────────── + // The alt+m suffix is appended to the dynamic hints inside render(), not in + // formatFooterShortcutHints(), so its removal can only be pinned end-to-end. + describe('runWorklistTui composed footer', () => { + type StdinListener = (chunk: Buffer) => void; + + // Shared fake-TTY harness (mirrors worklist-mouse.test.ts): swaps + // process.stdin for a fake TTY that records data listeners and captures + // process.stdout writes. + function setupFakeTty(): { + dataListeners: StdinListener[]; + writes: string[]; + restore: () => void; + } { + const dataListeners: StdinListener[] = []; + const fakeStdin = { + isTTY: true, + setRawMode: vi.fn(), + resume: vi.fn(), + pause: vi.fn(), + on: (ev: string, cb: StdinListener) => { + if (ev === 'data') dataListeners.push(cb); + }, + removeListener: (ev: string, cb: StdinListener) => { + if (ev === 'data') { + const i = dataListeners.indexOf(cb); + if (i >= 0) dataListeners.splice(i, 1); + } + }, + }; + const writes: string[] = []; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: any) => { + writes.push(String(chunk)); + return true; + }) as any); + const origStdin = (process as any).stdin; + Object.defineProperty(process, 'stdin', { value: fakeStdin, configurable: true, writable: true }); + setExecFileAsync((async () => ({ stdout: '{"count": 0}' })) as any); + return { + dataListeners, + writes, + restore: () => { + Object.defineProperty(process, 'stdin', { value: origStdin, configurable: true, writable: true }); + writeSpy.mockRestore(); + resetExecFileAsync(); + }, + }; + } + + // Drives the real render loop, then dismisses the TUI. Always dismisses and + // drains the TUI promise so a failing assertion never leaks the loop. + async function renderFooter(dismissKey = 'q'): Promise<string> { + const { dataListeners, writes, restore } = setupFakeTty(); + let tui: Promise<WorkItem | undefined> | undefined; + try { + tui = runWorklistTui(async () => [], [], loadShortcutConfig(), { + autoRefresh: false, + autoSync: false, + }); + await vi.waitFor(() => { + expect(writes.length).toBeGreaterThan(0); + }); + return stripAnsi(writes.join('')); + } finally { + dataListeners.forEach(cb => cb(Buffer.from(dismissKey))); + if (tui) await tui.catch(() => undefined); + restore(); + } + } + + // AC1 + AC2 + AC3: the composed footer advertises help and no longer + // carries the downtime or mouse-tracking advertisements. + it('composed TUI footer advertises ?: Help without downtime/mouse hints', async () => { + const footer = await renderFooter(); + expect(footer).toContain('?: Help'); + expect(footer).not.toContain('d:downtime toggle'); + expect(footer).not.toContain('alt+m mouse on'); + expect(footer).not.toContain('alt+m mouse off'); + }); + + // AC4: the Alt+m toggle itself remains functional — its footer hint is + // gone but the key binding still toggles mouse tracking. GREEN before and + // after the implementation. + it('Alt+m still toggles mouse tracking (binding intact)', async () => { + const { dataListeners, writes, restore } = setupFakeTty(); + let tui: Promise<WorkItem | undefined> | undefined; + try { + tui = runWorklistTui(async () => [], [], loadShortcutConfig(), { + autoRefresh: false, + autoSync: false, + }); + await vi.waitFor(() => { + expect(writes.join('')).toContain(ANSI.mouseEnable); + }); + dataListeners.forEach(cb => cb(Buffer.from('\x1bm'))); + await vi.waitFor(() => { + expect(writes.join('')).toContain(ANSI.mouseDisable); + }); + } finally { + dataListeners.forEach(cb => cb(Buffer.from('q'))); + if (tui) await tui.catch(() => undefined); + restore(); + } + }); + }); +}); + +// ── `?` help-screen key reservation (WL-0MV85R0JD004CHAE) ── +// The help overlay is opened by `?` in the key loop, so `?` must not be a +// registered chord or a navigation action — otherwise the key would conflict +// (parent AC: `?` must not collide with any registered chord or navigation +// key). GREEN before and after the implementation (test-first guard only). +describe('? help-screen key reservation (WL-0MV85R0JD004CHAE)', () => { + it('reserves ? — no registry chord and no navigation action claims it', () => { + const registry = loadShortcutConfig(); + expect(registry.lookupChordEntry(['?'], 'list')).toBeUndefined(); + expect(keyToAction('?')).toBeNull(); + }); +}); + +// ── Podcast w-chord dispatch notice (OSL-0MUQ59IRF009ICIF) ────────────── +// +// Producer decision (2026-10-14): the w-r/w-s/w-b chords keep their +// stage/guard semantics (producer choice (a) — `w-s` still errors on a +// script-bearing item with no open editor notes), and their failure/ +// confirmation feedback must be a PERSISTENT, dismissable in-pane notice +// rather than a transient fire-and-forget toast. This kills the reported +// "nothing happens" symptom: a refused dispatch can no longer be mistaken +// for a silently dropped keypress. +describe('podcast w-chord dispatch notice (OSL-0MUQ59IRF009ICIF)', () => { + // Local shortcuts.json fixture matching the project-local override. + const wChordEntries = [ + { chord: ['w', 'r'], command: '/skill:wiki-podcast-script --review <podcast-review>', view: 'both', label: 'write review', stages: ['plan_complete', 'in_review', 'done'], work_item_types: ['podcast'] }, + { chord: ['w', 's'], command: '/skill:wiki-podcast-script <podcast-target>', 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 <podcast-both>', view: 'both', label: 'write both', stages: ['plan_complete', 'in_review', 'done'], work_item_types: ['podcast'] }, + ]; + + it('formatPodcastNotice renders a titled, dismiss-hinted box for both outcomes', () => { + const dispatched = stripAnsiLocal(formatPodcastNotice(100, 30, { + kind: 'dispatched', + body: '/skill:wiki-podcast-script --review Ep10/Ep10.podcast.md', + })); + expect(dispatched).toContain('WRITE CHORD DISPATCHED'); + expect(dispatched).toContain('--review Ep10/Ep10.podcast.md'); + expect(dispatched).toContain('dismiss'); + + const refused = stripAnsiLocal(formatPodcastNotice(100, 30, { + kind: 'error', + body: 'podcast script already present, review and edit that rather than author a new one', + })); + expect(refused).toContain('WRITE CHORD — NOT DISPATCHED'); + expect(refused).toContain('already present'); + expect(refused).toContain('dismiss'); + }); + + // ── TUI-path harness (mirrors notify-dispatch.test.ts) ──────────── + let dataHandler: ((chunk: Buffer) => void) | undefined; + let writes: string[] = []; + let tempRoot: string | undefined; + + function stripAnsiLocal(s: string): string { + return s.replace(/\x1b\[[0-9;]*m/g, ''); + } + + beforeEach(() => { + dataHandler = undefined; + writes = []; + for (const prop of ['on', 'removeListener', 'pause', 'resume', 'setRawMode'] as const) { + if (!(prop in process.stdin)) { + Object.defineProperty(process.stdin, prop, { value: vi.fn(), configurable: true, writable: true }); + } + } + (process.stdin as any).on = vi.fn((event: string, cb: (chunk: Buffer) => void) => { + if (event === 'data') dataHandler = cb; + return process.stdin; + }); + (process.stdin as any).removeListener = vi.fn(() => process.stdin); + (process.stdin as any).pause = vi.fn(() => process.stdin); + (process.stdin as any).resume = vi.fn(() => process.stdin); + (process.stdin as any).setRawMode = vi.fn(() => process.stdin); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + vi.spyOn(process.stdout, 'write').mockImplementation(((s: any) => { + writes.push(String(s)); + return true; + }) as any); + vi.spyOn(process.stdout, 'on').mockImplementation((() => process.stdout as any) as any); + vi.spyOn(process.stdout, 'removeListener').mockImplementation((() => process.stdout as any) as any); + }); + + afterEach(() => { + if (tempRoot) { + try { rmSync(tempRoot, { recursive: true, force: true }); } catch { /* ignore */ } + tempRoot = undefined; + } + vi.restoreAllMocks(); + }); + + function registryWithLocalWChords(): ShortcutRegistry { + tempRoot = mkdtempSync(join(tmpdir(), 'herdr-podcast-notice-')); + writeFileSync(join(tempRoot, 'shortcuts.json'), JSON.stringify(wChordEntries)); + return loadShortcutConfig(tempRoot); + } + + const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 5)); + const lastRender = (): string => writes[writes.length - 1] ?? ''; + + const scriptItem: WorkItem = { + id: 'OSL-EP-1', + title: 'Episode with script', + status: 'open', + stage: 'plan_complete', + issueType: 'podcast', + description: '## Key Files:\n- Ep10/Ep10.podcast.md\n', + }; + + const scriptlessItem: WorkItem = { + ...scriptItem, + id: 'OSL-EP-2', + title: 'Episode with no script', + description: '# Episode 10 — spec only\n\nSee docs/video-series-outline.md for the outline.\n', + }; + + async function press(keys: string): Promise<void> { + for (const ch of keys) dataHandler?.(Buffer.from(ch)); + await tick(); + await tick(); + } + + it('surfaces a refused w-r dispatch as a persistent notice (and never dispatches)', async () => { + const onCommand = vi.fn(); + const registry = registryWithLocalWChords(); + const p = runWorklistTui(async () => [scriptlessItem], [scriptlessItem], registry, { + autoRefresh: false, + autoSync: false, + showHelpText: false, + onCommand, + }); + await tick(); + await tick(); + + await press('wr'); + + const rendered = stripAnsiLocal(lastRender()); + expect(rendered).toContain('WRITE CHORD — NOT DISPATCHED'); + expect(rendered).toContain('No podcast script found'); + expect(onCommand).not.toHaveBeenCalled(); + + // The notice is modal: dismiss it before the quit key will be processed. + await press('\x1b'); + dataHandler?.(Buffer.from('q')); + await p; + }); + + it('shows the dispatched notice AND dispatches the resolved command', async () => { + const onCommand = vi.fn(); + const registry = registryWithLocalWChords(); + const p = runWorklistTui(async () => [scriptItem], [scriptItem], registry, { + autoRefresh: false, + autoSync: false, + showHelpText: false, + onCommand, + }); + await tick(); + await tick(); + + await press('wr'); + + expect(onCommand).toHaveBeenCalledTimes(1); + expect(onCommand.mock.calls[0][0]).toBe('/skill:wiki-podcast-script --review Ep10/Ep10.podcast.md'); + const rendered = stripAnsiLocal(lastRender()); + expect(rendered).toContain('WRITE CHORD DISPATCHED'); + expect(rendered).toContain('--review Ep10/Ep10.podcast.md'); + + await press('\x1b'); + dataHandler?.(Buffer.from('q')); + await p; + }); + + it('the notice is modal until dismissed — other keys are consumed, Esc returns to the list', async () => { + const registry = registryWithLocalWChords(); + const p = runWorklistTui(async () => [scriptlessItem], [scriptlessItem], registry, { + autoRefresh: false, + autoSync: false, + showHelpText: false, + }); + await tick(); + await tick(); + + await press('wr'); + expect(stripAnsiLocal(lastRender())).toContain('WRITE CHORD — NOT DISPATCHED'); + + // A non-dismiss key keeps the notice up (modal — never scrolls away). + await press('j'); + expect(stripAnsiLocal(lastRender())).toContain('WRITE CHORD — NOT DISPATCHED'); + + // Esc dismisses; the list renders again (no notice title). + await press('\x1b'); + expect(stripAnsiLocal(lastRender())).not.toContain('WRITE CHORD — NOT DISPATCHED'); + + dataHandler?.(Buffer.from('q')); + await p; + }); +}); diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index 23c9e2ece..52aaa2703 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -13,11 +13,11 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve as resolvePath } from 'node:path'; -import { fetchChildrenForItem, fetchChildrenForItems, fetchActionableCount, fetchReviewQueueState, fetchItemsByStage, fetchItemsByPriority, fetchItemDetails, getWorklogDir, getExecFileAsync, buildWlArgs, buildWlArgsForRoot, DEFAULT_WL_TIMEOUT_MS, type WorkItem, type ReviewQueueState } from './fetcher.js'; +import { fetchChildrenForItem, fetchChildrenForItems, fetchActionableCount, fetchReviewQueueState, fetchItemsByStage, fetchItemsByPriority, fetchItemDetails, fetchBlockersForItem, getWorklogDir, getExecFileAsync, buildWlArgs, buildWlArgsForRoot, DEFAULT_WL_TIMEOUT_MS, type WorkItem, type ReviewQueueState, type BlockerRef } from './fetcher.js'; import { isPaneVisible, PollGate, DEFAULT_POLL_GATE_TTL_MS } from './visibility.js'; import { isAgentCommand } from './pane-title.js'; import { HerdrEventSubscriber } from './events.js'; -import { AgentTracker, mergeAgentStatesCached } from './agent-tracker.js'; +import { AgentTracker } from './agent-tracker.js'; import { readCodeFreezeState, readCodeFreezeStatus, writeCodeFreezeMarker, clearCodeFreezeMarker } from './code-freeze.js'; import { runShipGuard, formatBlockedNotice } from './ship-guard.js'; import type { ShortcutRegistry, ShortcutEntry } from './shortcut-config.js'; @@ -25,7 +25,6 @@ import { statusIcon, stageIcon, priorityIcon, - epicIcon, riskIcon, effortIcon, auditIcon, @@ -37,6 +36,7 @@ import { stageColor, applyPriorityColour, priorityColor, + ICON_LEGEND, type IconOptions, } from '@worklog/shared/icons'; import { @@ -444,7 +444,7 @@ export class NavigationStack { // ── State ───────────────────────────────────────────────────────────── -export type ViewMode = 'list' | 'detail' | 'filter' | 'form'; +export type ViewMode = 'list' | 'detail' | 'filter' | 'form' | 'help'; /** * A group heading row in the display-rows model (WL-0MSL5MPSZ003TG94). @@ -493,7 +493,7 @@ export class WorkItemListState { items: WorkItem[]; /** - * All loaded items (unfiltered). Lets the tab-marker refresh re-evaluate + * All loaded items (unfiltered). Lets the pane-marker refresh re-evaluate * every item — including those excluded by the active stage/priority * filter — without a `wl` re-fetch (WL-0MV1BWETT005GSUJ). */ @@ -614,6 +614,20 @@ export class WorkItemListState { */ metaScrollOffset = 0; + /** + * Active blockers of the current selection, supplied by the render loop so + * the metadata-panel scroll clamp counts the same `Blocked By` rows the + * renderer shows (WL-0MV8EZRDJ001TDA8). Null while unresolved or absent. + */ + metaBlockedBy: BlockerRef[] | null = null; + + /** + * Active blockers of the detail item, supplied by the render loop so the + * detail-view scroll clamp counts the `Blocked By` rows the renderer shows + * (WL-0MVBMLY2Z0032CYR). Null while unresolved or absent. + */ + detailBlockedBy: BlockerRef[] | null = null; + /** Set of expanded item IDs (for hierarchical display). */ expandedItems: Set<string> = new Set(); @@ -975,8 +989,10 @@ export class WorkItemListState { const maxCols = this.termSize.cols; const viewportHeight = Math.max(10, this.termSize.rows - 4); // The header + ToC block is pinned at the top; only the body below - // scrolls (WL-0MSHWHULZ001FL8I / WL-0MSI28AP80002F5S). - const allLines = formatDetailContent(this.detailItem, maxCols); + // scrolls (WL-0MSHWHULZ001FL8I / WL-0MSI28AP80002F5S). The content + // height must include the `Blocked By` rows the renderer shows or they + // become unreachable when the view scrolls (WL-0MVBMLY2Z0032CYR). + const allLines = formatDetailContent(this.detailItem, maxCols, undefined, false, 0, true, 0, this.detailBlockedBy); const tocLines = formatDetailToC(this.detailItem, maxCols).length; const pinnedHeight = DETAIL_HEADER_LINES + tocLines; const bodyLines = Math.max(0, allLines.length - pinnedHeight); @@ -985,6 +1001,18 @@ export class WorkItemListState { this.detailScrollOffset = Math.min(maxScroll, this.detailScrollOffset + amount); } + // ── Selection-preserving list scroll (drag-to-scroll) ─────────── + /** + * Scroll the list by a given number of rows **without** changing the + * selected index. Clamps `scrollOffset` to `[0, maxOffset]` using the + * same `_listHeight()` / `flatCount` maths as `_adjustScroll`. (WL-0MSI72S0Y0060BRK) + */ + scrollBy(rows: number): void { + const listHeight = this._listHeight(); + const maxOffset = Math.max(0, this.flatCount - listHeight); + this.scrollOffset = Math.max(0, Math.min(maxOffset, this.scrollOffset + rows)); + } + // ── Metadata panel scroll ─────────────────────────────────────── /** @@ -1025,8 +1053,8 @@ export class WorkItemListState { } const selected = this.getSelectedItem(); if (!selected) return; - const allLines = formatMetadataPanel(selected, this.termSize.cols, ph, 0); - const maxScroll = Math.max(0, allLines.length - ph); + const contentHeight = metadataPanelContentHeight(selected, this.termSize.cols, ph, undefined, false, this.metaBlockedBy); + const maxScroll = Math.max(0, contentHeight - ph); this.metaScrollOffset = Math.min(maxScroll, this.metaScrollOffset + amount); } @@ -1611,6 +1639,13 @@ export function formatTooltipLines( cols: number, noIcons = false, ): string[] { + // NOTE — `Blocked By` parity decision (WL-0MV8Q5X8U004KKAP AC-5): the + // hover tooltip deliberately omits the blocker rows. Blockers are the + // metadata panel's job (the SELECTED item's `Blocked By` rows in + // `formatMetadataPanel`); the tooltip is a compact pane-status summary and + // rendering its own async blocker lookup would duplicate the uncoordinated + // async preview path whose flicker WL-0MV8Q5X8U004KKAP fixes. Kept + // documented-out-of-scope rather than silently divergent. const parts: string[] = []; // ID + Title line. @@ -1624,7 +1659,7 @@ export function formatTooltipLines( // Priority + Type (compact row). const priorityStr = item.priority ? `${priorityIcon(item.priority, { noIcons })} ${item.priority}` : ''; - const typeStr = item.issueType ? `${item.issueType === 'epic' && !noIcons ? epicIcon() : ''}${item.issueType}` : ''; + const typeStr = item.issueType ? `${item.issueType}` : ''; const typeLabel = typeStr ? `${ANSI.dim}Type:${ANSI.reset} ${typeStr}` : ''; const priorityLabel = priorityStr ? `${ANSI.dim}Priority:${ANSI.reset} ${priorityStr}` : ''; if (priorityLabel && typeLabel) { @@ -1690,17 +1725,17 @@ export function formatTooltipOverlay(cols: number, lines: string[]): string[] { * * Includes every tracked field: ID, Title, Status, Stage, Priority, Type, * Risk, Effort, Children, Parent, Tags, GitHub Issue, Created, Updated, - * Audit, Reviewed, and Audited At. When the item's `Key Files:` section + * Audit, and Audited At. When the item's `Key Files:` section * contains `.md` paths, a `Related Docs` row lists every one of them (joined * with `, `); the row is omitted when there are no `.md` Key Files * (WL-0MSGTLSUT002NF29). Fields that are unset are omitted. * Timestamps (Created, Updated, Audited At) are rendered in local time as * `DD/MM/YY HH:MM` via {@link formatTimestamp}. * - * Icon-bearing fields (Status, Stage, Priority, Type, Risk, Effort, Audit, - * Reviewed) render as **icon + text label**, using the same icon helpers as - * the list rows (statusIcon, stageDisplayIcon, priorityIcon, epicIcon, - * riskIcon, effortIcon, auditIcon, needsProducerReviewIcon) so the metadata + * Icon-bearing fields (Status, Stage, Priority, Type, Risk, Effort, Audit) + * render as **icon + text label**, using the same icon helpers as + * the list rows (statusIcon, stageDisplayIcon, priorityIcon, + * riskIcon, effortIcon, auditIcon) so the metadata * section and the list can never diverge (WL-0MSGIXHHI009KFW9). With icons * disabled the values fall back to plain text — the metadata deliberately * does NOT use the list's `[BRACKET]` fallbacks. @@ -1738,7 +1773,7 @@ export function buildMetaRows(item: WorkItem, noIcons = false): Array<[string, s return icon ? `${icon} ${text}` : text; }; - // Text labels paired with the Audit/Reviewed icons (AC5). + // Text labels paired with the Audit icons (AC5). const auditLabel = (result: boolean | null | undefined): string | undefined => { if (result === true) return 'ready to close'; if (result === false) return 'not ready'; @@ -1773,9 +1808,10 @@ export function buildMetaRows(item: WorkItem, noIcons = false): Array<[string, s addMeta('Covered by', item.parentId); } addMetaOrDash('Priority', iconText(noIcons ? '' : priorityIcon(item.priority), item.priority)); - // Type shows the epic icon (⊙) for epic items only, matching the list; - // non-epic types remain text-only (AC3). - addMeta('Type', iconText(noIcons ? '' : (item.issueType === 'epic' ? epicIcon() : ''), item.issueType)); + // Type renders as plain text for every issue type — the `⊙` epic icon was + // removed (WL-0MVAQ873D007LNWD); parent items are marked by the list's + // expand arrow, so the indicator added no information. + addMeta('Type', item.issueType); addMetaOrDash('Risk', iconText(noIcons ? '' : riskIcon(item.risk), item.risk)); addMetaOrDash('Effort', iconText(noIcons ? '' : effortIcon(item.effort), item.effort)); // Dispatch provenance for log-derived rows (WL-0MUL2IY8L009S3PQ). @@ -1798,9 +1834,13 @@ export function buildMetaRows(item: WorkItem, noIcons = false): Array<[string, s } else { addMeta('Audit', iconText(noIcons ? '' : auditIcon(item.auditResult), auditLabel(item.auditResult))); } - addMeta('Reviewed', iconText(noIcons ? '' : needsProducerReviewIcon(item.needsProducerReview), reviewLabel(item.needsProducerReview))); addMeta('Audited At', item.auditedAt ? formatTimestamp(item.auditedAt) : undefined); + // Producer-review row (restored WL-0MVAMS4RO000H3B0 F2): the flag drives a + // `Reviewed` metadata row, mirroring the list-row ✓/✗ producer column so the + // list and metadata can never diverge (WL-0MSGIXHHI009KFW9). + addMeta('Reviewed', iconText(noIcons ? '' : needsProducerReviewIcon(item.needsProducerReview), reviewLabel(item.needsProducerReview))); + // Related Docs — every .md path referenced in the item's `Key Files:` // section, joined with a compact delimiter so multi-file values fit the // existing label/value row format (WL-0MSGTLSUT002NF29). Display-only: @@ -1978,39 +2018,22 @@ function buildDescriptionPreview( } /** - * Format the metadata panel shown below the selection list. - * - * 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 - * status 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. + * Build the FULL, unpadded metadata-panel content lines for an item. * - * @param item - Selected work item (or null for an empty panel). - * @param maxCols - Terminal width (lines are truncated to fit). - * @param panelRows - Number of rows available for the panel. - * @param metaScrollOffset - Vertical scroll offset into the panel content. - * @param lastCommand - Most recent command for the item (shown only when the - * item status is `in-progress`). - * @returns Exactly `panelRows` lines ready for the renderer. + * Shared by {@link formatMetadataPanel} (which truncates, slices and pads + * them to the panel viewport) and {@link metadataPanelContentHeight} (the + * `m`/`M` scroll clamp), so the rendered window and the scroll clamp can + * never diverge (WL-0MV8EZRDJ001TDA8). */ -export function formatMetadataPanel( - item: WorkItem | null, +function buildMetadataPanelLines( + item: WorkItem, maxCols: number, panelRows: number, - metaScrollOffset = 0, lastCommand?: string | null, noIcons = false, + blockedBy?: BlockerRef[] | null, ): string[] { const lines: string[] = []; - if (!item) { - // Blank panel — pad to the full height - while (lines.length < panelRows) { - lines.push(''); - } - return lines; - } // Header separator identifying the selected item lines.push(` ${ANSI.dim}── ${item.id} ──${ANSI.reset}`); @@ -2019,6 +2042,17 @@ export function formatMetadataPanel( // Audit+AuditedAt onto single rows for a more compact display (WL-0MSNIX4V60012266). // Drop the ID row since the panel header already shows the ID (WL-0MSHIJR7T007Q9R7). const metaRows = pairMetaRows(buildMetaRows(item, noIcons)).filter(([label]) => label !== 'ID'); + // Blocked By rows (WL-0MV8EZRDJ001TDA8) — one row per ACTIVE blocker, at + // the END of the metadata section (after every field row, before the + // description preview). ``${id} — ${title}`` keeps both readable; a + // title-less blocker falls back to the bare ID rather than a dangling + // separator. The blocker's status is appended as an icon + text suffix + // when the edge carries one (WL-0MVBMLY2Z0032CYR). Omitted entirely when + // there are no active blockers. + for (const blocker of blockedBy ?? []) { + if (!blocker || !blocker.id) continue; + metaRows.push(['Blocked By', formatBlockerValue(blocker, noIcons)]); + } if (metaRows.length > 0) { const fieldWidth = Math.max(...metaRows.map(([l]) => l.length), 6); for (const [label, value] of metaRows) { @@ -2047,21 +2081,106 @@ export function formatMetadataPanel( } } + return lines; +} + +/** + * Format a single `Blocked By` row value (WL-0MVBMLY2Z0032CYR): + * ``<id> — <title>`` with the blocker's status appended as an icon + text + * suffix when the edge carries one (``<id> — <title> <icon> <status>``), + * consistent with the Status/Stage/Priority row convention + * (WL-0MSGIXHHI009KFW9). A title-less blocker falls back to the bare ID; + * a status-less blocker renders ID (+ title) alone — the status suffix is + * only appended when present. `noIcons` gates the emoji exactly like every + * other meta row: icons disabled → plain text status only. + */ +function formatBlockerValue(blocker: BlockerRef, noIcons: boolean): string { + const base = blocker.title ? `${blocker.id} — ${blocker.title}` : blocker.id; + const status = (blocker.status ?? '').trim(); + if (!status) return base; + const icon = noIcons ? '' : statusIcon(status); + return icon ? `${base} ${icon} ${status}` : `${base} ${status}`; +} + +/** + * Height (in lines) of the metadata panel's full content for `item` at + * `panelRows` — the value the `m`/`M` scroll clamp subtracts the panel + * height from (WL-0MV8EZRDJ001TDA8). Unlike {@link formatMetadataPanel}'s + * return value (always exactly `panelRows` lines after padding), this is the + * UNPADDED content length, so the scroll offset can reach the true bottom of + * the panel. + */ +export function metadataPanelContentHeight( + item: WorkItem | null, + maxCols: number, + panelRows: number, + lastCommand?: string | null, + noIcons = false, + blockedBy?: BlockerRef[] | null, +): number { + if (!item) return 0; + return buildMetadataPanelLines(item, maxCols, panelRows, lastCommand, noIcons, blockedBy).length; +} + +/** + * Format the metadata panel shown below the selection list. + * + * 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 + * status 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). + * @param maxCols - Terminal width (lines are truncated to fit). + * @param panelRows - Number of rows available for the panel. + * @param metaScrollOffset - Vertical scroll offset into the panel content. + * @param lastCommand - Most recent command for the item (shown only when the + * item status is `in-progress`). + * @param noIcons - Icons gated off for this frame. + * @param blockedBy - Active dependency blockers of the selected item + * (WL-0MV8EZRDJ001TDA8). One `Blocked By` row is appended + * per blocker, after the metadata rows and before the + * description preview. Omitted entirely when empty, so an + * unblocked item's panel is byte-for-byte unchanged. + * @returns Exactly `panelRows` lines ready for the renderer. + */ +export function formatMetadataPanel( + item: WorkItem | null, + maxCols: number, + panelRows: number, + metaScrollOffset = 0, + lastCommand?: string | null, + noIcons = false, + blockedBy?: BlockerRef[] | null, +): string[] { + const lines: string[] = []; + if (!item) { + // Blank panel — pad to the full height + while (lines.length < panelRows) { + lines.push(''); + } + return lines; + } + + const content = buildMetadataPanelLines(item, maxCols, panelRows, lastCommand, noIcons, blockedBy); + // Truncate to fit the terminal width - for (let i = 0; i < lines.length; i++) { - if (lines[i].length > 0) { - lines[i] = truncateLine(lines[i], maxCols); + for (let i = 0; i < content.length; i++) { + if (content[i].length > 0) { + content[i] = truncateLine(content[i], maxCols); } } // Apply independent scrolling - const maxScroll = Math.max(0, lines.length - panelRows); + const maxScroll = Math.max(0, content.length - panelRows); const safeOffset = Math.min(metaScrollOffset, maxScroll); - const visible = lines.slice(safeOffset, safeOffset + panelRows); + const visible = content.slice(safeOffset, safeOffset + panelRows); // Scroll indicator when content overflows - if (lines.length > panelRows) { - const percent = Math.round(((safeOffset + panelRows) / lines.length) * 100); + if (content.length > panelRows) { + const percent = Math.round(((safeOffset + panelRows) / content.length) * 100); const indicator = ` ${ANSI.dim}[m/M scroll ${Math.min(percent, 100)}%]${ANSI.reset}`; visible[visible.length - 1] = truncateLine(visible[visible.length - 1] + indicator, maxCols); } @@ -2186,17 +2305,21 @@ export function formatDetailToC( * * Metadata section includes: Status, Priority, Stage, Type, Risk, Effort, * Children, Tags, GitHub Issue (number), Created, Updated, Audit - * (auditResult icon + text label), Reviewed (needsProducerReview icon + - * text label), and Audited At (ISO timestamp). Rendered as a markdown + * (auditResult icon + text label), and Audited At (ISO timestamp). Rendered as a markdown * table; icon-bearing values are `icon + text` via the shared * {@link buildMetaRows} (WL-0MSGIXHHI009KFW9). ID and Title are shown in * the header (from the shared {@link buildMetaRows} set they are omitted * from the table to avoid duplication). * - * When the item has ≥1 `.md` Key File, a pinned `Related Docs` ToC is - * rendered at the top (see {@link formatDetailToC}); the md viewer section - * below renders the file at `detailRenderedIndex` (default 0 = first file, - * preserving the existing auto-render behavior). + * `Blocked By` rows (WL-0MVBMLY2Z0032CYR): the detail view renders the + * SAME active blockers the selection-list metadata panel shows — one row + * per blocker (`<id> — <title> <status-icon> <status>`) appended to the + * metadata table, reversing the documented divergence (WL-0MV8Q5X8U004KKAP + * AC-5). The rows arrive pre-resolved from the render loop via the same + * single-flight + signature-keyed cache as the preview, so the detail view + * never spawns an uncoordinated async lookup and cannot re-open the + * WL-0MV8Q5X8U004KKAP flicker class; an expired/failed lookup simply + * passes no rows (fail-open). */ export function formatDetailContent( item: WorkItem | null, @@ -2206,6 +2329,7 @@ export function formatDetailContent( detailToCIndex = 0, detailToCFocus = true, detailRenderedIndex = 0, + blockedBy?: BlockerRef[] | null, ): string[] { if (!item) return []; @@ -2230,6 +2354,15 @@ export function formatDetailContent( const metaRows = pairMetaRows( buildMetaRows(item, noIcons).filter(([label]) => label !== 'ID' && label !== 'Title'), ); + // `Blocked By` rows (WL-0MVBMLY2Z0032CYR) — one row per ACTIVE blocker + // after the metadata table, mirroring the selection-list preview: + // ``<id> — <title>`` plus the blocker's status (icon + text) when the + // edge carries one. Omitted entirely when there are none, so an + // unblocked item's detail view is unchanged. + for (const blocker of blockedBy ?? []) { + if (!blocker || !blocker.id) continue; + metaRows.push(['Blocked By', formatBlockerValue(blocker, noIcons)]); + } // Render the metadata as a markdown table if (metaRows.length > 0) { @@ -2517,11 +2650,12 @@ export function formatDetailView( detailToCIndex = 0, detailToCFocus = true, detailRenderedIndex = 0, + blockedBy?: BlockerRef[] | null, ): string { // The Related Docs ToC is pinned at the top of the detail view; only the // body below it scrolls (WL-0MSHWHULZ001FL8I). const tocLines = formatDetailToC(item, maxCols, detailToCIndex, detailToCFocus, noIcons); - const allLines = formatDetailContent(item, maxCols, readFile, noIcons, detailToCIndex, detailToCFocus, detailRenderedIndex); + const allLines = formatDetailContent(item, maxCols, readFile, noIcons, detailToCIndex, detailToCFocus, detailRenderedIndex, blockedBy); if (allLines.length === 0) return ''; const pinnedHeight = DETAIL_HEADER_LINES + tocLines.length; @@ -2716,20 +2850,23 @@ export function formatChordHintsForHelp( } /** - * Build the dynamic footer hint string for the visible shortcut entries. + * Literal footer advertisement for the help screen (WL-0MV85QZXN002T51A). * - * Most chord leaders collapse to a single `<leader>:<firstWord>...` hint - * (e.g. `u:update...`), which keeps the footer compact for homogeneous - * variant families. The podcast-progression family (`w-r`/`w-s`/`w-b`) is - * the exception: its sub-chords are distinct, stage-gated steps whose labels - * differ after the shared leading word, so collapsing them hid the - * sub-options from the producer (OSL-0MUQ59IRF009ICIF AC5). + * `?` is a reserved key handled by the key loop, not a registry chord, so it + * has no `shortcuts.json` entry to format. The hint is prepended to the + * dynamic shortcut hints so it stays visible even when the footer is + * truncated in a narrow pane. It replaces the retired `d:downtime toggle` + * single-key advertisement and the `alt+m mouse on/off` suffix (parent + * WL-0MTV97GV20088CS7): both bindings still work, only their inline + * advertising moved to the help screen. + */ +export const HELP_FOOTER_HINT = '?: Help'; + +/** + * Build the dynamic footer hint string for the visible shortcut entries. * - * A leader is expanded (every sub-chord hinted) only when its entries carry - * an explicit work-item-type allowlist — the marker that the entries were - * authored as type-specific progression steps rather than generic variants. - * Ungated families (`u`, `f`, `a`, `x`, `P`, `r`, `n`) keep the collapsed - * form, preserving existing behaviour. + * The `?: Help` advertisement is always prepended; the `d` single-key + * downtime-toggle entry is no longer advertised (its binding is unchanged). * * @param entries - Shortcut entries already filtered by stage/view/issue type. * @returns Space-joined hint string (empty when no entry yields a hint). @@ -2745,6 +2882,11 @@ export function formatFooterShortcutHints(entries: ShortcutEntry[]): string { const grouped = new Map<string, ShortcutEntry[]>(); const parts: string[] = []; for (const e of entries) { + // Skip the d single-key downtime-toggle entry — only its advertisement + // changes; the key binding remains in shortcuts.json (WL-0MV85QZXN002T51A). + if (e.chord?.length === 1 && e.chord[0] === 'd') { + continue; + } if (e.chord && e.chord.length >= 2) { const leader = e.chord[0]; const group = grouped.get(leader) ?? []; @@ -2769,9 +2911,265 @@ export function formatFooterShortcutHints(entries: ShortcutEntry[]): string { parts.push(`${leader}:${labelOf(group[0]).split(/\s+/)[0]}...`); } } + // Advertise the help screen ahead of the dynamic hints (parent + // WL-0MTV97GV20088CS7 AC1). The `d:downtime toggle` advertisement is + // dropped above but its key binding is untouched. + parts.unshift(HELP_FOOTER_HINT); return parts.join(' '); } +/** A single row in the help screen's icon legend. */ +export interface HelpIconRow { + /** Icon category (the name prefix, e.g. `status`) used to group the legend. */ + category: string; + /** The glyph (or noIcons text fallback) as rendered in the list. */ + glyph: string; + /** Short human-readable meaning of the glyph. */ + meaning: string; +} + +/** + * Word-wrap a single logical line into lines of at most `cols` display + * cells, indenting continuation lines by `indent` columns. Keeps the FULL + * text — never truncates — so a long description still reads completely and + * the pane's physical row budget stays exact (producer audit on + * WL-0MTV97GV20088CS7: "full (not truncated) descriptions"). + */ +function wrapHelpLine(text: string, cols: number, indent: number): string[] { + if (cols <= 1) return [text]; + const words = text.split(/\s+/).filter(w => w.length > 0); + const lines: string[] = []; + let current = ''; + let currentWidth = 0; + for (const word of words) { + const width = visibleLength(word); + if (current === '') { + current = word; + currentWidth = width; + continue; + } + if (currentWidth + 1 + width <= cols) { + current += ` ${word}`; + currentWidth += 1 + width; + } else { + lines.push(current); + current = `${' '.repeat(indent)}${word}`; + currentWidth = indent + width; + } + } + if (current !== '') lines.push(current); + return lines.length > 0 ? lines : [text]; +} + +/** + * Explicit group headings for chord-leader families whose member labels do + * not share a common first word (so the derived heading would be a bare key). + * Unknown leaders fall back to the label-derived heading in + * {@link helpGroupHeading}, so future chords are still grouped. + */ +const HELP_LEADER_LABELS: Record<string, string> = { + r: 'Review', +}; + +/** + * Render a human-readable group heading for a chord-leader command family: the + * leader key plus the shared first label word when a strict majority of the + * group's labels agree (e.g. `c — create`, `a — audit`), else the bare leader + * key (`r`). Derived from live registry data so future chords keep working. + */ +function helpGroupHeading(leader: string, group: ShortcutEntry[]): string { + const explicit = HELP_LEADER_LABELS[leader]; + if (explicit) return `${leader} \u2014 ${explicit}`; + const counts = new Map<string, number>(); + for (const e of group) { + const word = (e.label ?? e.command).split(/\s+/)[0] ?? ''; + if (word) counts.set(word, (counts.get(word) ?? 0) + 1); + } + let best = ''; + let bestCount = 0; + for (const [word, count] of counts) { + if (count > bestCount) { + bestCount = count; + best = word; + } + } + if (bestCount > group.length / 2) { + return `${leader} \u2014 ${best.charAt(0).toUpperCase()}${best.slice(1)}`; + } + return leader; +} + +/** Human-readable heading for an icon category (e.g. `status` → `Status`). */ +function helpCategoryHeading(category: string): string { + return category + .split('-') + .map(w => (w ? w.charAt(0).toUpperCase() + w.slice(1) : w)) + .join(' '); +} + +/** + * Strip the leading category name from an icon meaning (the producer audit's + * "remove the name prefix"): the group heading already names the category, so + * `Status: open — not started` renders as `open — not started` under `Status`. + * Meanings that do not start with the category label are returned unchanged. + */ +function helpMeaningWithoutCategory(category: string, meaning: string): string { + const label = helpCategoryHeading(category); + const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Strip a leading `<Category>` optionally followed by a separator + // (`:`, `—`, `–`, `-`) and/or whitespace. `Audit passed — …` under the + // `Audit` heading becomes `passed — …`; unrelated meanings are untouched. + const re = new RegExp(`^\\s*${escaped}\\s*[:\\u2014\\u2013-]?\\s*`, 'i'); + const stripped = meaning.replace(re, '').trim(); + return stripped.length > 0 ? stripped : meaning; +} + +/** ANSI 256-colour code for the help-screen title (gold, WL-0MTV97GV20088CS7 rework). */ +const HELP_TITLE_COLOUR = 220; +/** ANSI 256-colour code for the top-level section headings (Commands / Icons). */ +const HELP_SECTION_COLOUR = 39; +/** ANSI 256-colour code for the command-family and icon-category sub-headings. */ +const HELP_GROUP_COLOUR = 36; + +/** Style a help-screen title line (bold + gold). */ +function helpTitle(text: string): string { + return `${ANSI.bold}${ANSI.fg(HELP_TITLE_COLOUR)}${text}${ANSI.reset}`; +} + +/** Style a top-level help section heading (bold + blue). */ +function helpSectionHeading(text: string): string { + return `${ANSI.bold}${ANSI.fg(HELP_SECTION_COLOUR)}${text}${ANSI.reset}`; +} + +/** Style a help sub-heading (command family / icon category, bold + cyan). */ +function helpSubHeading(text: string): string { + return `${ANSI.bold}${ANSI.fg(HELP_GROUP_COLOUR)}${text}${ANSI.reset}`; +} + +/** + * Render the full-pane `?` help overlay (WL-0MV85R14Y003RII9; layout reworked + * per the producer audits of 2026-10-15 and 2026-10-16). + * + * Two sections are produced, each under a coloured heading: + * - **Commands** — every supplied shortcut entry in a SINGLE column with its + * FULL (never truncated) description, grouped under a heading per chord + * leader family (`c — Create`, `a — Audit`, …) so groups are separated; + * - **Icons** — the glyph legend (supplied by the caller from the shared icon + * source of truth), grouped by category under a heading (`Status`, `Stage`,…) + * with the category name prefix removed from each meaning and every group + * listed on a single line under its heading. + * + * Layout (2026-10-16 audit): sections and sub-headings are separated by blank + * lines, and every heading level is coloured (title gold, section headings + * blue, sub-headings cyan). + * + * The output honours the `rows - 1` render budget (matching the list + * viewport contract, WL-0MTV979LK005YB1B): the title line and the dismiss + * footer (`? / Esc / q`) are always shown, and the content between them is a + * scrolling window at `scrollOffset`. When the full content is longer than + * the budget the footer appends a scroll hint (`[↑↓/j:k scroll g/G top/bot]`) + * mirroring the detail view (WL-0MSHWHULZ001FL8I); further content is + * revealed by scrolling. + * + * Display-only: it takes no state and mutates nothing. + * + * @param entries - Shortcut entries filtered by stage/view/issue type. + * @param cols - Pane width in terminal cells. + * @param rows - Pane height in terminal cells. + * @param iconRows - Icon legend rows (empty when no legend is supplied). + * @param scrollOffset - Vertical offset into the full content (clamped to the + * last window; default 0 = top of the document). + */ +export function formatHelpScreen( + entries: ShortcutEntry[], + cols: number, + rows: number, + iconRows: ReadonlyArray<HelpIconRow> = [], + scrollOffset = 0, +): string { + const budget = Math.max(2, rows - 1); + const title = helpTitle('Help'); + const footerBase = '? / Esc / q close'; + // Title + footer are always reserved; the remainder is the scroll window. + const contentBudget = Math.max(0, budget - 2); + const lineWidth = Math.max(20, cols - 2); + + // ── Commands: single column, grouped by chord leader with headings ── + const grouped = new Map<string, ShortcutEntry[]>(); + for (const e of entries) { + const leader = (e.chord && e.chord.length > 0) ? e.chord[0] : ''; + const list = grouped.get(leader) ?? []; + list.push(e); + grouped.set(leader, list); + } + + const content: string[] = []; + // Leading blank: separates the pinned title from the first section heading + // (2026-10-16 audit: "blank lines to separate the sections"). + content.push(''); + content.push(helpSectionHeading('Commands')); + content.push(''); + for (const [leader, group] of grouped) { + content.push(helpSubHeading(helpGroupHeading(leader, group))); + for (const e of group) { + const chord = (e.chord ?? []).join(' '); + const label = e.label ?? e.command; + const text = e.description + ? `${chord} ${label} \u2014 ${e.description}` + : `${chord} ${label}`; + content.push(...wrapHelpLine(` ${text}`, lineWidth, 6)); + } + // A blank line separates each command family under the section. + content.push(''); + } + + // ── Icons: grouped by category with a heading, one line per group ── + if (iconRows.length > 0) { + // The blank separator above already follows the last command family; + // keep a clean exchange between the two top-level sections. + content.push(helpSectionHeading('Icons')); + content.push(''); + const byCategory = new Map<string, HelpIconRow[]>(); + for (const row of iconRows) { + const list = byCategory.get(row.category) ?? []; + list.push(row); + byCategory.set(row.category, list); + } + for (const [category, rows] of byCategory) { + content.push(helpSubHeading(helpCategoryHeading(category))); + const tokens = rows + .map(r => `${r.glyph} ${helpMeaningWithoutCategory(category, r.meaning)}`) + .join(' '); + content.push(...wrapHelpLine(` ${tokens}`, lineWidth, 4)); + // A blank line separates each icon category under the section. + content.push(''); + } + } + + // ── Scroll window (2026-10-16 audit: "we need to be able to scroll") ── + const maxScroll = Math.max(0, content.length - contentBudget); + const safeOffset = Math.min(scrollOffset, maxScroll); + const visible = content.slice(safeOffset, safeOffset + contentBudget); + + let footer = footerBase; + if (maxScroll > 0) { + const percent = content.length > 0 + ? Math.round(((safeOffset + contentBudget) / content.length) * 100) + : 0; + const hint = `${ANSI.dim}[↑↓/j:k scroll g/G top/bot ${Math.min(100, percent)}%]${ANSI.reset}`; + // Keep the dismiss keys discoverable on narrow panes (WL-0MTV979LK005YB1B): + // only append the scroll hint when the footer still fits the pane width. + if (visibleLength(`${footerBase} ${hint}`.replace(/\x1b\[[0-9;]*m/g, '')) <= cols) { + footer = `${footerBase} ${hint}`; + } + } + + const out = [title, ...visible]; + while (out.length < budget - 1) out.push(''); + out.push(footer); + return out.slice(0, budget).join('\n'); +} + /** * Get chord hints for showing in the help bar when in list mode. * Shows leader keys and abbreviated labels for all chords. @@ -2856,6 +3254,14 @@ export function keyToAction(key: string): KeyAction { return 'toggle-expand'; case '\x1b': return 'back'; + // Ctrl+r (\x12) — immediate manual refresh of the selection list + // (WL-0MV85T0LT008UT9H). The legacy bare-`r` refresh stays removed: + // `r` is the Producer Review / Interview chord leader (WL-0MS4LDWHW0062FTA) + // and is resolved via the ShortcutRegistry, not here. \x12 is not a chord + // leader and not a single-key shortcut, so the existing + // `action === 'refresh'` branch fires `doRefresh(true)`. + case '\x12': + return 'refresh'; // '/' 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 @@ -3149,6 +3555,49 @@ export interface ParsedMouseEvent { * inclusive (<=). */ export const DOUBLE_CLICK_WINDOW_MS = 400; +/** Minimum pointer displacement (in terminal cells / rows) before a + * held-button motion event is treated as a drag-to-scroll gesture rather + * than click jitter. SGR coordinates are 1-based cell positions so a + * threshold of 1 row distinguishes deliberate scrolling from incidental + * movement. (WL-0MSI72S0Y0060BRK) */ +export const DRAG_THRESHOLD_CELLS = 1; + +/** State tracker for an active drag-to-scroll gesture. Tracks the press + * origin, a fractional accumulator for proportional scrolling, and the + * pre-press selection/scroll so the drag can restore them when it + * activates — ensuring a drag never leaves the selection moved by the + * initial press. (WL-0MSI72S0Y0060BRK) */ +export interface MouseDragState { + /** Origin of the press (x, y) or null when no drag is active. */ + origin: { x: number; y: number } | null; + /** Fractional row/line accumulator — integer parts are dispatched as + * discrete scroll deltas. */ + accumulator: number; + /** True when the drag has crossed the threshold and is actively + * scrolling. */ + active: boolean; + /** Snapshot of selectedIndex and scrollOffset taken at press time, used + * to restore state when the drag activates so the drag never leaves the + * selection moved by the initial press. */ + prePressSelection: { selectedIndex: number; scrollOffset: number } | null; + /** True once the pre-press selection has been restored for this drag + * gesture — the restore happens exactly once, on the first threshold- + * crossing motion; subsequent drag events accumulate scroll on top of + * the restored state. */ + restored: boolean; +} + +/** Create a fresh drag state (helper for tests). */ +export function createDragState(): MouseDragState { + return { + origin: null, + accumulator: 0, + active: false, + prePressSelection: null, + restored: false, + }; +} + /** Prior left-press coordinates plus an injectable clock for deterministic * double-click tests. */ export interface MouseClickState { @@ -3158,7 +3607,18 @@ export interface MouseClickState { now: number; } -/** Dispatchable mouse action — mirrors the list/detail/filter key paths. */ +/** Internal drag bookkeeping action — emitted on the first press and + * never dispatched to the state switch (it triggers state capture only). + * (WL-0MSI72S0Y0060BRK) */ +type DragAction = + | { type: 'drag-start' } + | { type: 'drag-list'; rows: number } + | { type: 'drag-detail'; lines: number }; + +/** Dispatchable mouse action — mirrors the list/detail/filter key paths. + * Drag actions are emitted by `mapMouseDrag` and dispatched by + * `handleMouseInput`; the drag consumer never emits `select-row` / + * `open-detail`, so a drag-release can never click. (WL-0MSI72S0Y0060BRK) */ export type MouseAction = | { type: 'select-row'; index: number } // list: click/tap an item row | { type: 'open-detail' } // list: double-click (Enter-equivalent) @@ -3170,6 +3630,7 @@ export type MouseAction = | { type: 'filter-stage'; index: number } // filter: tap a stage option | { type: 'hover-row'; index: number } // list: motion over an item row (tooltip) | { type: 'hover-none' } // list: motion over chrome rows (pointer left the rows) + | DragAction // internal drag actions (drag-start, drag-list, drag-detail) | null; // inert: chrome rows, motion, releases, unknown buttons /** @@ -3322,10 +3783,13 @@ function filterStageIndexForColumn(x: number): number | null { /** * Map a parsed SGR mouse event to a dispatchable action for the current - * state. Gate rules (WL-0MSGHM5BQ0096BNJ AC2–AC6): release events and - * motion (button & 32) are inert; wheel 64/65 navigate (list) or scroll the - * detail (detail) and are ignored in filter mode; only the left button - * selects; two left presses on the same row within + * state. Gate rules (WL-0MSGHM5BQ0096BNJ AC2–AC6, WL-0MSI72S0Y0060BRK): + * release events are inert; button-less motion (button & 32 and & 3 = 3) + * drives the hover tooltip in list mode (left-button-held motion is a drag + * candidate handled by {@link handleMouseInput} via {@link mapMouseDrag} — + * it never returns a navigation/select action); wheel 64/65 navigate (list) + * or scroll the detail (detail) and are ignored in filter mode; only the + * left button selects; two left presses on the same row within * {@link DOUBLE_CLICK_WINDOW_MS} open the detail view (list mode) or go * back (detail mode); filter-mode taps on the stage-options row select a * stage. @@ -3338,15 +3802,13 @@ export function mapMouseToAction( ): MouseAction { // Release events are consumed but inert — presses drive selection (AC6). if (ev.release) return null; - // Motion events (button & 32): never navigate, but trigger hover tooltip - // in list mode (WL-0MT9XRZDK006GMUH). They remain inert in detail/filter. - if ((ev.button & 32) !== 0) { + // Motion events (button & 32): button-less motion triggers the hover + // tooltip in list mode; left-button-held motion is handled separately + // in handleMouseInput via mapMouseDrag (AC4). + if ((ev.button & 32) !== 0 && (ev.button & 3) === 3) { + // Button-less motion → hover tooltip (WL-0MT9XRZDK006GMUH). if (state.mode === 'list') { const index = mapListRowToIndex(state, ev.y, termSize); - // Motion over a visible list row → hover-row (tooltip candidate). - // Motion over chrome/blank rows (header, fold indicators, footer, - // panel) → hover-none: the pointer has left the rows area, allowing - // a dismissed tooltip to re-show on the next re-entry (AC2). return index !== null ? { type: 'hover-row', index } : { type: 'hover-none' }; } return null; // detail/filter: motion is inert @@ -3393,13 +3855,114 @@ export function mapMouseToAction( return index !== null ? { type: 'select-row', index } : null; } +/** Pure function that maps a drag candidate SGR mouse event to a drag + * action, driven by the supplied `dragState` tracker. + * + * Behaviour (WL-0MSI72S0Y0060BRK): + * + * - Left-button press: record origin + pre-press selection → `drag-start` + * - Left-button motion past `DRAG_THRESHOLD_CELLS`: return `drag-list` + * (list mode, whole-row deltas) or `drag-detail` (detail mode, lines) + * - Sub-threshold motion / release / filter mode / detail single-click: null + * + * Drag actions never emit `select-row` / `open-detail`; releases are inert. + */ +export function mapMouseDrag( + state: WorkItemListState, + ev: ParsedMouseEvent, + dragState: MouseDragState, + termSize: TermSize, +): MouseAction { + // Filter mode and detail single-clicks: drag is out of scope (constraints). + if (state.mode === 'filter') return null; + + // ── Left-button press: capture state and emit drag-start ───────── + if (!ev.release && ev.button === 0) { + dragState.origin = { x: ev.x, y: ev.y }; + dragState.accumulator = 0; + dragState.active = false; + dragState.restored = false; + dragState.prePressSelection = { + selectedIndex: state.selectedIndex, + scrollOffset: state.scrollOffset, + }; + return { type: 'drag-start' }; + } + + // ── Release: end the drag; return null (inert) ────────────────── + if (ev.release) { + dragState.origin = null; + dragState.accumulator = 0; + dragState.active = false; + dragState.restored = false; + dragState.prePressSelection = null; + return null; + } + + // ── Motion (left button held): compute deltas ─────────────────── + // Only process motion for the left button (button bits 0, with motion bit 32). + // Other button+motion combos (e.g. middle+motion) are inert — fall through. + if ((ev.button & 3) !== 0 || (ev.button & 32) === 0) { + return null; + } + + if (!dragState.origin) return null; + + const deltaY = ev.y - dragState.origin.y; + + if (state.mode === 'list') { + // List mode: scroll proportional to vertical movement, snapped to whole + // rows. A fractional accumulator converts continuous movement into + // discrete row steps. + dragState.accumulator += deltaY; + let rows = 0; + while (dragState.accumulator >= DRAG_THRESHOLD_CELLS) { + dragState.accumulator -= DRAG_THRESHOLD_CELLS; + rows++; + } + while (dragState.accumulator <= -DRAG_THRESHOLD_CELLS) { + dragState.accumulator += DRAG_THRESHOLD_CELLS; + rows--; + } + // Reset accumulator for next delta + if (rows !== 0) { + dragState.origin = { x: ev.x, y: ev.y }; + dragState.active = true; + } + return rows !== 0 ? { type: 'drag-list', rows } : null; + } + + if (state.mode === 'detail') { + // Detail mode: scroll proportional to vertical movement, 1 row ≈ 1 line. + dragState.accumulator += deltaY; + let lines = 0; + while (dragState.accumulator >= DRAG_THRESHOLD_CELLS) { + dragState.accumulator -= DRAG_THRESHOLD_CELLS; + lines++; + } + while (dragState.accumulator <= -DRAG_THRESHOLD_CELLS) { + dragState.accumulator += DRAG_THRESHOLD_CELLS; + lines--; + } + if (lines !== 0) { + dragState.origin = { x: ev.x, y: ev.y }; + dragState.active = true; + } + return lines !== 0 ? { type: 'drag-detail', lines } : null; + } + + // Other modes (e.g. filter): drag is out of scope. + return null; +} + /** * Wire a raw stdin chunk into the mouse path — the onData entry point - * (WL-0MSGHM5BQ0096BNJ AC2–AC6, fix WL-0MSZBWT500034E74). Parses SGR mouse - * events via {@link consumeMouseChunk}, maps them to an action via - * {@link mapMouseToAction}, dispatches it against `state` (mirroring the - * handleKeypress action handling), and tracks the double-click window in - * `clickState`. + * (WL-0MSGHM5BQ0096BNJ AC2–AC6, fix WL-0MSZBWT500034E74, WL-0MSI72S0Y0060BRK). + * Parses SGR mouse events via {@link consumeMouseChunk}, maps them to an + * action via {@link mapMouseToAction}, dispatches it against `state` + * (mirroring the handleKeypress action handling), tracks the double-click + * window in `clickState`, and drives drag-to-scroll via {@link mapMouseDrag} + * with the accumulated `dragState`. * * Returns true when the chunk was mouse-shaped (fully consumed — it must * NOT reach the keyboard path); false for plain keys and foreign escape @@ -3419,6 +3982,7 @@ export function handleMouseInput( key: string, termSize: TermSize, clickState: MouseClickState, + dragState?: MouseDragState, ): boolean { const bufferBefore = mouseChunkBuffer; const mouseEvent = consumeMouseChunk(key); @@ -3433,19 +3997,69 @@ export function handleMouseInput( return mouseChunkBuffer !== bufferBefore; } + // Drag-state tracker — callers that only exercise click/wheel behaviour + // may omit it; drag tracking then uses a fresh per-event tracker (no + // cross-event state, so no drag can activate). + const ds: MouseDragState = dragState ?? createDragState(); + // Current clock for the double-click window (Date.now; deterministic // tests inject clickState directly into mapMouseToAction instead). const now = Date.now(); clickState.now = now; const action = mapMouseToAction(state, mouseEvent, termSize, clickState); - // Advance the tracker AFTER the double-click check — the next event's - // window compares against THIS press (fix WL-0MSZBWT500034E74). + // Advance the double-click tracker AFTER the double-click check — the next + // event's window compares against THIS press (fix WL-0MSZBWT500034E74). if (!mouseEvent.release && mouseEvent.button === 0) { clickState.lastClick = { x: mouseEvent.x, y: mouseEvent.y, at: now }; } - if (action === null) return true; // consumed but inert (motion, release, chrome) + // Update the drag-state tracker on every mouse event (press / motion / + // release). mapMouseDrag captures the pre-press selection and returns + // the appropriate drag action (drag-start / drag-list / drag-detail) or + // null for inert events. + const dragAction = mapMouseDrag(state, mouseEvent, ds, termSize); + + if (action === null && dragAction === null) return true; // consumed but inert + + // Dispatch drag actions — they override regular mouse actions when the + // drag mapper returns something (left-held motion in list/detail mode). + if (dragAction !== null) { + switch (dragAction.type) { + case 'drag-start': + // Bookkeeping only — mapMouseDrag already captured the pre-press + // selection. Fall through to the regular dispatch so the press + // still selects (click behaviour preserved); a later threshold- + // crossing motion restores the pre-press selection. + break; + case 'drag-list': + // Restore the pre-press selection/scroll exactly once, on the first + // threshold-crossing motion — a drag never leaves the selection + // moved by the initial press (AC3). Subsequent drag events + // accumulate scroll on top of the restored state. + if (!ds.restored && ds.prePressSelection) { + state.selectedIndex = ds.prePressSelection.selectedIndex; + state.scrollOffset = ds.prePressSelection.scrollOffset; + ds.restored = true; + } + state.scrollBy(dragAction.rows); + return true; + case 'drag-detail': + if (!ds.restored && ds.prePressSelection) { + state.selectedIndex = ds.prePressSelection.selectedIndex; + state.scrollOffset = ds.prePressSelection.scrollOffset; + ds.restored = true; + } + if (state.detailItem) { + dragAction.lines > 0 + ? state.detailScrollDown(dragAction.lines) + : state.detailScrollUp(-dragAction.lines); + } + return true; + } + } + + if (action === null) return true; // consumed but inert // Dispatch mouse actions (mirrors the handleKeypress action handling). switch (action.type) { @@ -3627,6 +4241,11 @@ export function createListRenderer(getShowIcons?: () => boolean): ( totalCount?: number, chordState?: ChordState | null, detailScrollOffset?: number, + /** + * Retained for positional call-site compatibility only — the + * `[auto-refresh on]` header indicator it drove was removed in + * WL-0MV85T0LT008UT9H. Callers/tests pass it positionally; do not remove. + */ autoRefresh?: boolean, expandedItems?: Set<string>, chordHelpHints?: string, @@ -3656,6 +4275,10 @@ export function createListRenderer(getShowIcons?: () => boolean): ( * (AH-0MUDYTQ55002NUSJ) */ sprintAuditsOutstanding?: boolean, + /** Active dependency blockers of the selected item, rendered as `Blocked By` rows (WL-0MV8EZRDJ001TDA8). */ + metaBlockedBy?: BlockerRef[] | null, + /** Active dependency blockers of the detail item, rendered as `Blocked By` rows (WL-0MVBMLY2Z0032CYR). */ + detailBlockedBy?: BlockerRef[] | null, ) => string { // Default to icons enabled when no getter is supplied (backwards // compatible — callers/tests that render without options keep icons). @@ -3671,6 +4294,11 @@ export function createListRenderer(getShowIcons?: () => boolean): ( totalCount?: number, chordState?: ChordState | null, detailScrollOffset?: number, + /** + * Retained for positional call-site compatibility only — the + * `[auto-refresh on]` header indicator it drove was removed in + * WL-0MV85T0LT008UT9H. Callers/tests pass it positionally; do not remove. + */ autoRefresh?: boolean, expandedItems?: Set<string>, chordHelpHints?: string, @@ -3691,6 +4319,9 @@ export function createListRenderer(getShowIcons?: () => boolean): ( sprintCompletedCount?: number, browseItemCount?: number, sprintAuditsOutstanding?: boolean, + metaBlockedBy?: BlockerRef[] | null, + /** Active dependency blockers of the detail item, rendered as `Blocked By` rows (WL-0MVBMLY2Z0032CYR). */ + detailBlockedBy?: BlockerRef[] | null, ): string => { const { rows, cols } = termSize; // Icons are gated by the getter for the whole frame (list lines, detail @@ -3717,6 +4348,7 @@ export function createListRenderer(getShowIcons?: () => boolean): ( detailToCIndex ?? 0, detailToCFocus ?? true, detailRenderedIndex ?? 0, + detailBlockedBy, ); } @@ -3734,18 +4366,20 @@ export function createListRenderer(getShowIcons?: () => boolean): ( // ── Render list mode ────────────────────────────────────────── - // Header with total count and auto-refresh indicator. `totalItems` is + // Header with total count. `totalItems` is // the count of WorkItem rows only (heading rows are excluded so the // header reconciles with per-group heading counts). + // `autoRefresh` is intentionally unused since WL-0MV85T0LT008UT9H removed + // the `[auto-refresh on]` header indicator; it is retained for + // positional call-site compatibility (the production call site and many + // tests pass it positionally). The setting still controls the scheduler + // tick — the shortcut is independent of it. const totalItems = displayRows.filter((row) => !isHeadingRow(row)).length; const filterLabel = activeFilter ? ` (filtered: ${activeFilter})` : ''; let header = ` ${ANSI.bold}Work Items${ANSI.reset} — ${totalItems} item(s)${filterLabel}`; if (totalCount !== undefined && totalCount > totalItems) { header += ` (top ${totalItems} of ${totalCount})`; } - if (autoRefresh) { - header += ` ${ANSI.dim}[auto-refresh on]${ANSI.reset}`; - } if (panePaused) { header += ` ${ANSI.fg(220)}[paused — hidden]${ANSI.reset}`; } @@ -3926,7 +4560,12 @@ export function createListRenderer(getShowIcons?: () => boolean): ( const navHint = (navStackDepth && navStackDepth > 0) ? ` ${ANSI.dim}[esc] back${navStackDepth > 1 ? ` (${navStackDepth} levels)` : ''}${ANSI.reset}` : ''; - const chordHelpSuffix = chordHelpHints ? ` ${ANSI.fg(220)}${chordHelpHints}${ANSI.reset}` : ''; + // Gate the dynamic hint line behind showHelpText so `false` hides + // the `?: Help` advertisement too, not only the chord-in-progress + // footer (WL-0MV85QZXN002T51A AC3). + const chordHelpSuffix = (helpEnabled && chordHelpHints) + ? ` ${ANSI.fg(220)}${chordHelpHints}${ANSI.reset}` + : ''; const footerLine = navHint + chordHelpSuffix || ' '; // ── Footer wrap guard (WL-0MTV979LK005YB1B) ────────────── // In narrow panes the dynamic help line can exceed `cols`, @@ -3962,6 +4601,7 @@ export function createListRenderer(getShowIcons?: () => boolean): ( metaScrollOffset ?? 0, metaLastCommand, noIcons, + metaBlockedBy, ); for (const line of panelLines) { output.push(line); @@ -4018,6 +4658,60 @@ function logCommandForItem(command: string, itemId?: string): void { } } +/** + * Invoke the resolved-command callback, forwarding the optional routing + * flags with the strict "append only when explicitly set" arity rules shared + * by every dispatch path: + * + * - `openPane` is appended only when explicitly set (`false`); an undefined + * value keeps the 2-arg call byte-compatible so shortcuts without + * `open_pane` open a pane exactly as before (WL-0MSJLD1I70045ZUL). + * - `onRefresh` occupies the 4th slot and is forwarded when present so a + * background (no-pane) dispatch can refresh the list on child exit + * (WL-0MT1KB70U0012X6T). + * - `paneTitle` occupies the 5th slot; only pane-opening routes with a + * selected item carry one (WL-0MSJ4E8UA005KG9Y). + * - `focus` is appended as the 6th arg only when `true`, so callers + * without a focus flag see today's arity (WL-0MT70LC6B009TL3Q). + * + * Extracted so the placeholder form dialog honours `open_pane` / + * `onRefresh` / `focus` exactly like the placeholder-free paths + * (WL-0MV6J4HQ0009OJPQ). + */ +function invokeOnCommand( + onCommand: (command: string, model?: string, openPane?: boolean, onRefresh?: () => Promise<void>, paneTitle?: string, focus?: boolean) => void, + command: string, + model: string | undefined, + openPane: boolean | undefined, + onRefresh: (() => Promise<void>) | undefined, + paneTitle: string | undefined, + focus: boolean | undefined, +): void { + if (openPane === undefined) { + if (paneTitle !== undefined) { + if (focus === true) onCommand(command, model, undefined, undefined, paneTitle, true); + else onCommand(command, model, undefined, undefined, paneTitle); + } else { + if (focus === true) onCommand(command, model, undefined, undefined, undefined, true); + else onCommand(command, model); + } + } else if (onRefresh) { + if (paneTitle !== undefined) { + if (focus === true) onCommand(command, model, openPane, onRefresh, paneTitle, true); + else onCommand(command, model, openPane, onRefresh, paneTitle); + } else { + if (focus === true) onCommand(command, model, openPane, onRefresh, undefined, true); + else onCommand(command, model, openPane, onRefresh); + } + } else if (paneTitle !== undefined) { + if (focus === true) onCommand(command, model, openPane, undefined, paneTitle, true); + else onCommand(command, model, openPane, undefined, paneTitle); + } else { + if (focus === true) onCommand(command, model, openPane, undefined, undefined, true); + else onCommand(command, model, openPane); + } +} + /** * Resolve `<id>` placeholders in a command and route it through the * output mechanism. Used by {@link dispatchChordCommand} for agent @@ -4066,46 +4760,11 @@ function resolveAndRouteCommand( selected && (isAgentCommand(resolvedCommand) || resolvedCommand.startsWith('!')) ? selected.title : undefined; - // The openPane flag is passed only when explicitly set (false): an - // undefined third arg keeps the 2-arg call identical to today's - // dispatch, so shortcuts without open_pane are byte-compatible - // (WL-0MSJLD1I70045ZUL). Focus (WL-0MT70LC6B009TL3Q) follows the same - // "append when explicitly true" rule: omitted/false keeps the current - // no-focus default. The onRefresh hook is appended only for - // background (no-pane) dispatches (openPane === false) so they can - // trigger a refresh when the child exits (WL-0MT1KB70U0012X6T); - // pane-opening paths keep their existing arity. The focuses arg slots - // after paneTitle so callers that pass a title keep their position; - // callers that don't pass a focus flag see no extra arg (backward - // compatible). Pane titles are appended only when the command opens a - // pane and a title exists. - // Focus handling (WL-0MT70LC6B009TL3Q): when focus is true, the new pane - // is zoomed. The value is appended as a 6th arg only when true so - // omitting/undefined stays backward compatible — callers without a focus - // flag see today's arity (no trailing undefined). - if (openPane === undefined) { - if (paneTitle !== undefined) { - if (focus === true) onCommand(resolvedCommand, model, undefined, undefined, paneTitle, true); - else onCommand(resolvedCommand, model, undefined, undefined, paneTitle); - } else { - if (focus === true) onCommand(resolvedCommand, model, undefined, undefined, undefined, true); - else onCommand(resolvedCommand, model); - } - } else if (onRefresh) { - if (paneTitle !== undefined) { - if (focus === true) onCommand(resolvedCommand, model, openPane, onRefresh, paneTitle, true); - else onCommand(resolvedCommand, model, openPane, onRefresh, paneTitle); - } else { - if (focus === true) onCommand(resolvedCommand, model, openPane, onRefresh, undefined, true); - else onCommand(resolvedCommand, model, openPane, onRefresh); - } - } else if (paneTitle !== undefined) { - if (focus === true) onCommand(resolvedCommand, model, openPane, undefined, paneTitle, true); - else onCommand(resolvedCommand, model, openPane, undefined, paneTitle); - } else { - if (focus === true) onCommand(resolvedCommand, model, openPane, undefined, undefined, true); - else onCommand(resolvedCommand, model, openPane); - } + // Forward openPane/onRefresh/paneTitle/focus with the shared arity rules + // (invokeOnCommand) so shortcuts without an explicit open_pane stay + // byte-compatible (WL-0MSJLD1I70045ZUL, WL-0MT1KB70U0012X6T, + // WL-0MT70LC6B009TL3Q). + invokeOnCommand(onCommand, resolvedCommand, model, openPane, onRefresh, paneTitle, focus); } return true; } @@ -4187,13 +4846,26 @@ export function formatCodeFreezeDialog(maxCols: number, maxRows: number, reason? } /** - * Full-pane "Ship mode blocked" notice (WL-0MUD6DDZC007ZSIW). Renders the - * guard's plain-text blocked notice inside the same box style as the Code - * Freeze dialog, so the blocked state reuses the established in-pane modal - * pattern (esc/enter/q dismiss). Shown INSTEAD of the Ship It dialog when - * live agent panes are working on project items — the dialog never opens. + * Render a full-pane, dismissable modal notice: a centred box with a bold + * accent-coloured title, a body, and a dim dismiss-key footer. + * + * Extracted from {@link formatBlockedShipDialog} so the Ship-mode blocked + * notice and the podcast w-chord dispatch notice (OSL-0MUQ59IRF009ICIF) + * share one implementation and render identically apart from title/accent. + * + * @param maxCols - Terminal width. + * @param maxRows - Terminal height (the box is padded to fill it). + * @param title - Bold title line (rendered in the accent colour). + * @param body - Body text; `\n` is honoured and each line is padded. + * @param accent - ANSI 256-colour code for the title. */ -export function formatBlockedShipDialog(maxCols: number, maxRows: number, body: string): string { +export function formatNoticeDialog( + maxCols: number, + maxRows: number, + title: string, + body: string, + accent: number, +): string { const lines: string[] = []; const dialogWidth = Math.min(maxCols - 4, 76); const effectiveWidth = Math.max(52, dialogWidth); @@ -4210,7 +4882,7 @@ export function formatBlockedShipDialog(maxCols: number, maxRows: number, body: lines.push(''); lines.push(borderLine('┌', '┐')); - lines.push(padLine(`${ANSI.bold}${ANSI.fg(196)}⛔ SHIP MODE BLOCKED${ANSI.reset}`)); + lines.push(padLine(`${ANSI.bold}${ANSI.fg(accent)}${title}${ANSI.reset}`)); lines.push(padLine('')); for (const raw of body.split('\n')) { lines.push(padLine(raw)); @@ -4228,6 +4900,52 @@ export function formatBlockedShipDialog(maxCols: number, maxRows: number, body: return lines.join('\n'); } +/** + * Outcome of a podcast w-chord dispatch attempt, surfaced to the operator. + * + * - `dispatched` — the command resolved and was sent to a Pi agent pane. + * - `error` — `resolvePodcastTarget` refused to resolve the command (e.g. no + * script in `Key Files:`, or the belt-and-braces "never author a duplicate" + * guard). Nothing was dispatched. + */ +export interface PodcastNotice { + kind: 'dispatched' | 'error'; + body: string; +} + +/** + * Render the persistent, dismissable notice for a podcast w-chord dispatch + * attempt (OSL-0MUQ59IRF009ICIF). + * + * The w-chords' failure and confirmation feedback used to be transient + * fire-and-forget `herdr notification` toasts that were easy to miss — the + * reported symptom was "nothing happens". This notice stays on screen until + * the operator dismisses it, so a refused dispatch can never be mistaken for + * a silently dropped keypress. + */ +export function formatPodcastNotice( + maxCols: number, + maxRows: number, + notice: PodcastNotice, +): string { + const title = notice.kind === 'dispatched' + ? '✅ WRITE CHORD DISPATCHED' + : '⚠ WRITE CHORD — NOT DISPATCHED'; + const accent = notice.kind === 'dispatched' ? 82 : 196; + return formatNoticeDialog(maxCols, maxRows, title, notice.body, accent); +} + +/** + * Full-pane "Ship mode blocked" notice (WL-0MUD6DDZC007ZSIW). Renders the + * guard's plain-text blocked notice inside the same box style as the Code + * Freeze dialog, so the blocked state reuses the established in-pane modal + * pattern (esc/enter/q dismiss). Shown INSTEAD of the Ship It dialog when + * live agent panes are working on project items — the dialog never opens. + */ +export function formatBlockedShipDialog(maxCols: number, maxRows: number, body: string): string { + return formatNoticeDialog(maxCols, maxRows, '⛔ SHIP MODE BLOCKED', body, 196); +} + /** * Build the production Ship Guard query seam (WL-0MUD6DDZC007ZSIW): runs * `wl list --json --fields id` and `herdr pane list` and returns their raw @@ -4427,7 +5145,6 @@ export async function resolveDispatchDetail( * - `/wl <stage>` — stage filter actions (applied internally) * - `/skill:implement`, `/skill:audit` — agent skill invocations * - `/intake`, `/plan` — agent workflow commands - * - `!!wl reviewed` — producer review toggle * - Compound audit commands containing `&& wl audit-set` * * @param command - The resolved command string (may contain `<id>` placeholders) @@ -4534,12 +5251,13 @@ export function dispatchChordCommand( if (command.startsWith('!!wl reviewed')) { return resolveAndRouteCommand(command, state, onCommand, model, openPane, onRefresh, focus); } - // ── Data-modifying wl commands (close/delete/update/search) ── + + // ── Data-modifying wl commands (close/delete/update/reviewed/search) ── // These mutate the work-item data set or change the list contents, so // they are routed here (not the generic callback path) so the caller's // isWlModifyingCommand check sees 'dispatched' and triggers an immediate // list refresh after the command completes (WL-0MTA217DZ003H5K8). - if (/^!!\s*wl\s+(close|delete|update|search)\b/i.test(command)) { + if (/^!!\s*wl\s+(close|delete|update|reviewed|search)\b/i.test(command)) { return resolveAndRouteCommand(command, state, onCommand, model, openPane, onRefresh, focus); } if (command.includes('&& wl audit-set')) { @@ -4726,7 +5444,7 @@ export async function resolvePodcastTarget( * Routing priority: * 1. {@link dispatchChordCommand} — handles `/wl <stage>` (internal filter), * `/skill:implement`, `/skill:audit`, `/skill:ship`, `/intake`, `/plan`, - * `!!wl reviewed`, and compound `&& wl audit-set` commands (resolves + * and compound `&& wl audit-set` commands (resolves * `<id>` 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 @@ -4771,7 +5489,7 @@ export function executeResolvedCommand( } // Try dispatchChordCommand first — handles /wl, /downtime, /skill:, - // /intake, /plan, !!wl reviewed, and compound audit commands. Focus + // /intake, /plan, and compound audit commands. Focus // (WL-0MT70LC6B009TL3Q) is threaded through so the new pane can be focused // when requested (only `P n` today). if (dispatchChordCommand(command, state, onCommand, model, onDowntimeToggle, openPane, onRefresh, focus)) { @@ -4812,35 +5530,11 @@ export function executeResolvedCommand( // The openPane flag is passed only when explicitly set (false): an // undefined third arg keeps the 2-arg call identical to today's // dispatch, so shortcuts without open_pane are byte-compatible - // (WL-0MSJLD1I70045ZUL). Focus (WL-0MT70LC6B009TL3Q) follows the same - // append-when-explicitly-true rule — omitted/false keeps today's arity. - // The onRefresh hook is appended only for background (no-pane) - // dispatches (openPane === false) so they can trigger a refresh when - // the child exits (WL-0MT1KB70U0012X6T); pane-opening paths keep their - // existing arity. - if (openPane === undefined) { - if (paneTitle !== undefined) { - if (focus === true) onCommand(resolvedCommand, model, undefined, undefined, paneTitle, true); - else onCommand(resolvedCommand, model, undefined, undefined, paneTitle); - } else { - if (focus === true) onCommand(resolvedCommand, model, undefined, undefined, undefined, true); - else onCommand(resolvedCommand, model); - } - } else if (onRefresh) { - if (paneTitle !== undefined) { - if (focus === true) onCommand(resolvedCommand, model, openPane, onRefresh, paneTitle, true); - else onCommand(resolvedCommand, model, openPane, onRefresh, paneTitle); - } else { - if (focus === true) onCommand(resolvedCommand, model, openPane, onRefresh, undefined, true); - else onCommand(resolvedCommand, model, openPane, onRefresh); - } - } else if (paneTitle !== undefined) { - if (focus === true) onCommand(resolvedCommand, model, openPane, undefined, paneTitle, true); - else onCommand(resolvedCommand, model, openPane, undefined, paneTitle); - } else { - if (focus === true) onCommand(resolvedCommand, model, openPane, undefined, undefined, true); - else onCommand(resolvedCommand, model, openPane); - } + // (WL-0MSJLD1I70045ZUL). `focus`/`onRefresh`/`paneTitle` follow the rules + // documented on invokeOnCommand. Centralised so the placeholder form + // dialog (WL-0MV6J4HQ0009OJPQ) and the placeholder-free paths can never + // drift apart. + invokeOnCommand(onCommand, resolvedCommand, model, openPane, onRefresh, paneTitle, focus); } return 'callback'; } @@ -4862,7 +5556,7 @@ export async function runWorklistTui( fetcher: () => Promise<WorkItem[]>, initialItems?: WorkItem[], shortcutRegistry?: { lookupChord: Function; getChordByLeader: Function; getChordByPrefix: Function; getChordEntries: Function } | ShortcutRegistry | undefined, - options?: { autoRefresh?: boolean; refreshIntervalMs?: number; autoSync?: boolean; syncIntervalMs?: number; browseItemCount?: number; showHelpText?: boolean; getShowHelpText?: () => boolean; showIcons?: boolean; getShowIcons?: () => boolean; onCommand?: (command: string, model?: string, openPane?: boolean, onRefresh?: () => Promise<void>, paneTitle?: string) => void; downtimeWorker?: DowntimeWorker; downtimePollIntervalMs?: number; mergeAgentStates?: (items: WorkItem[]) => Promise<void>; applyActiveTabMarkers?: (items: WorkItem[]) => Promise<void>; subscriber?: HerdrEventSubscriber | null; agentTracker?: AgentTracker | null; onDowntimeToggle?: () => void; modeSwitchWorker?: ModeSwitchWorker; modeSwitchPollIntervalMs?: number; modeSwitchEnabled?: boolean; maxSyncStalenessMs?: number; onRefresh?: () => Promise<void>; cwd?: string; shipGuardQuery?: () => Promise<{ worklogOutput: string | null; paneOutput: string | null }>; dbWatch?: typeof watchDbChanges; dbWatchOptions?: WatchDbChangesOptions }, + options?: { autoRefresh?: boolean; refreshIntervalMs?: number; autoSync?: boolean; syncIntervalMs?: number; browseItemCount?: number; showHelpText?: boolean; getShowHelpText?: () => boolean; showIcons?: boolean; getShowIcons?: () => boolean; onCommand?: (command: string, model?: string, openPane?: boolean, onRefresh?: () => Promise<void>, paneTitle?: string) => void; downtimeWorker?: DowntimeWorker; downtimePollIntervalMs?: number; applyActivePaneMarkers?: (items: WorkItem[]) => Promise<void>; subscriber?: HerdrEventSubscriber | null; agentTracker?: AgentTracker | null; onDowntimeToggle?: () => void; modeSwitchWorker?: ModeSwitchWorker; modeSwitchPollIntervalMs?: number; modeSwitchEnabled?: boolean; maxSyncStalenessMs?: number; onRefresh?: () => Promise<void>; cwd?: string; shipGuardQuery?: () => Promise<{ worklogOutput: string | null; paneOutput: string | null }>; dbWatch?: typeof watchDbChanges; dbWatchOptions?: WatchDbChangesOptions }, ): Promise<WorkItem | undefined> { const opts = { autoRefresh: options?.autoRefresh ?? true, @@ -4877,8 +5571,7 @@ export async function runWorklistTui( onCommand: options?.onCommand, downtimeWorker: options?.downtimeWorker, downtimePollIntervalMs: options?.downtimePollIntervalMs ?? DEFAULT_DOWNTIME_POLL_INTERVAL_MS, - mergeAgentStates: options?.mergeAgentStates, - applyActiveTabMarkers: options?.applyActiveTabMarkers, + applyActivePaneMarkers: options?.applyActivePaneMarkers, subscriber: options?.subscriber ?? null, agentTracker: options?.agentTracker ?? null, onDowntimeToggle: options?.onDowntimeToggle, @@ -4896,18 +5589,17 @@ export async function runWorklistTui( let termSize = getTermSize(); /** - * Apply the tab-based active marker to freshly fetched items: any item with - * an exact-match tab in THIS workspace is shown as `in-progress` (marker + - * Group N), independent of the worklog status the demotion loop maintains. - * This makes the selection list's marker reflect tab existence directly - * (WL-0MV1BWETT005GSUJ). The production implementation is injected by - * `index.ts` (same pattern as `mergeAgentStates`); when absent it is a - * no-op, so tests never spawn `herdr`. Fail-open — a tab lookup failure - * leaves every item unchanged. + * Apply the pane-based active marker to freshly fetched items: any item + * with a matching pane title in THIS workspace is shown as `in-progress` + * (marker + Group N), independent of the worklog status the demotion loop + * maintains. This makes the selection list's marker reflect pane existence + * directly (WL-0MV1BWETT005GSUJ). The production implementation is + * injected by `index.ts`; when absent it is a no-op, so tests never spawn + * `herdr`. Fail-open — a pane lookup failure leaves every item unchanged. */ - const applyTabMarkers = async (list: WorkItem[]): Promise<void> => { + const applyPaneMarkers = async (list: WorkItem[]): Promise<void> => { try { - await opts.applyActiveTabMarkers?.(list); + await opts.applyActivePaneMarkers?.(list); } catch { // Fail-open: a marker failure must never break the refresh/render. } @@ -4920,10 +5612,10 @@ export async function runWorklistTui( } catch { items = []; } - // Reflect open item tabs on the very first render (no 30 s wait). Kicked + // Reflect open item panes on the very first render (no 30 s wait). Kicked // off without blocking TUI startup; the marker lands on a follow-up render - // once the tab lookup resolves (fail-open). - void applyTabMarkers(items).then(() => render()); + // once the pane lookup resolves (fail-open). + void applyPaneMarkers(items).then(() => render()); // Fetch total actionable count (best effort — failure is silent) fetchActionableCount().then((count) => { @@ -4936,15 +5628,15 @@ export async function runWorklistTui( const state = new WorkItemListState(items, termSize); /** - * Re-evaluate tab existence against the CURRENT items (no `wl` re-fetch) + * Re-evaluate pane existence against the CURRENT items (no `wl` re-fetch) * and re-render. Called on the hydrate cadence and on focus-resume so an - * opened/closed work-item tab changes the marker within one cadence even + * opened/closed work-item pane changes the marker within one cadence even * though it does not touch the worklog DB (which is why the DB-gated - * refresh tick cannot be relied on for this). Spawns only `herdr tab list`, - * never `wl`. Fail-open (see `applyTabMarkers`). + * refresh tick cannot be relied on for this). Spawns only `herdr pane list`, + * never `wl`. Fail-open (see `applyPaneMarkers`). */ - const refreshTabMarkers = async (): Promise<void> => { - await applyTabMarkers(state.allItems()); + const refreshPaneMarkers = async (): Promise<void> => { + await applyPaneMarkers(state.allItems()); render(); }; @@ -4952,10 +5644,113 @@ export async function runWorklistTui( // showIcons setting change applies without a plugin restart — same pattern // as getShowHelpText). The renderer is created per-TUI-run with the getter. const renderer = createListRenderer(opts.getShowIcons); + + // ── Metadata-panel dependency blockers (WL-0MV8EZRDJ001TDA8) ──────── + // Resolved for the SELECTED item only (never every list row) and cached by + // item ID. The cache SURVIVES list refreshes (WL-0MV8Q5X8U004KKAP): the + // previous implementation cleared it on EVERY refresh, which re-entered the + // unresolved frame and made the `Blocked By` line flicker on/off endlessly + // under the frequent refresh traffic of a live workspace (DB-change watch + // + auto-sync + `wl next` re-sorts all bump the read-cache counter). + // + // Invalidation: a cached entry is reused while the item's payload signature + // (status/stage/updatedAt) is unchanged. A signature change — a dependency + // add/remove reconciles the from-item's status (`blocked` ↔ `open`) and + // bumps `updatedAt` — invalidates just that entry. While a re-resolve is in + // flight the last known value is still served (stale-while-revalidate), so + // a refresh never blanks the line and a stable selection shows exactly one + // unresolved→resolved transition. The render path never blocks: an item + // with no cached value renders without `Blocked By` rows and a follow-up + // render shows them once the async lookup lands. + // + // Single-flight: the in-flight lookup is memoised per item, so repeated + // renders/refreshes while it is pending share one spawn (no race/duplicate) + // and the resolved value is re-rendered from cache thereafter. + // + // Fail-open — a lookup error caches an empty list so a broken `wl dep list` + // can never break rendering or navigation (mirrors resolveMetaLastCommand). + const blockerCache = new Map<string, { blockers: BlockerRef[]; signature: string }>(); + const blockerInFlight = new Map<string, Promise<BlockerRef[]>>(); + + /** + * Payload signature of an item whose change can invalidate its cached + * blockers. A dependency edge add/remove reconciles the from-item's status + * and bumps `updatedAt` (packages/shared/src/database.ts + * `reconcileDependentStatus`), so a signature change is the refresh-time + * signal that the item's edges may have changed. Fields absent from a + * payload collapse to empty strings, keeping the comparison stable. + * + * Known residual: an edge change that leaves the from-item's payload + * untouched (e.g. adding a second blocker to an already-`blocked` item, or + * a blocker target completing while other blockers keep the item `blocked`) + * does not change the signature and the cache serves the last resolved rows + * until the item itself changes or it is re-selected. This is the accepted + * trade-off of the AC-2 invalidation rule (reuse until the item changes) + * — the alternative (re-resolve on every refresh) is the flicker this item + * fixes. + */ + const blockerSignature = (item: WorkItem): string => + `${item.id}|${item.status ?? ''}|${item.stage ?? ''}|${item.updatedAt ?? ''}`; + + const resolveMetaBlockers = (item: WorkItem | null): BlockerRef[] | undefined => { + if (!item) return undefined; + // Synthetic log-derived rows have no live `wl` counterpart; blockers are + // only meaningful for live rows, so skip the lookup (WL-0MUL2IY8L009S3PQ). + if (item.isLogDerived || item.isLogOnly) return undefined; + const signature = blockerSignature(item); + const cached = blockerCache.get(item.id); + // Cached value whose signature still matches the payload — reuse it, + // never re-entering the unresolved frame or re-spawning the lookup. + if (cached !== undefined && cached.signature === signature) return cached.blockers; + if (!blockerInFlight.has(item.id)) { + // Fail-open even against an unexpectedly unavailable helper (tests may + // replace the fetcher module): never let the lookup break a render. + let lookup: Promise<BlockerRef[]>; + try { + lookup = fetchBlockersForItem(item.id); + } catch { + // Synchronous throw: cache the fail-open empty list and stop. + blockerCache.set(item.id, { blockers: [], signature }); + return []; + } + const inFlight = lookup + .then((blockers) => { + blockerCache.set(item.id, { blockers, signature }); + return blockers; + }) + .catch(() => { + // Fail-open: never surface a blocker lookup failure. + blockerCache.set(item.id, { blockers: [], signature }); + return [] as BlockerRef[]; + }) + .finally(() => { + blockerInFlight.delete(item.id); + render(); + }); + blockerInFlight.set(item.id, inFlight); + } + // A re-resolve is in flight: serve the last known value so a refresh + // never drops the line (stale-while-revalidate). On a first resolve + // there is none yet, so render no rows this frame and let the follow-up + // render show them — the one unresolved→resolved transition per selection. + return cached?.blockers; + }; const chordState: ChordState = createChordState(); let formState: FormState | null = null; /** Saved mode before entering form overlay (to restore on cancel) */ let preFormMode: ViewMode = 'list'; + /** + * The mode the help overlay returns to when dismissed (WL-0MV85R14Y003RII9). + * Mirrors the `preFormMode` pattern: opening help records the mode, and + * `?`/Esc/`q` restore it without mutating list state. + */ + let preHelpMode: ViewMode = 'list'; + /** + * Vertical scroll offset of the help overlay's content window (2026-10-16 + * producer audit: the overlay must be scrollable). Reset to 0 every time + * the overlay opens; clamped to the last window in `formatHelpScreen`. + */ + let helpScrollOffset = 0; /** * Ship It confirmation dialog (WL-0MSGG5N5Z0074TLY). Non-null while the * dialog is open; its buffer holds the typed confirmation input. The @@ -4986,6 +5781,12 @@ export async function runWorklistTui( */ let blockedNotice: string | null = null; + // Persistent, dismissable notice for a podcast w-chord dispatch attempt + // (OSL-0MUQ59IRF009ICIF). Replaces the transient toast for the w-r/w-s/w-b + // (and t) markers so a refused dispatch — e.g. the belt-and-braces + // "already present" guard — can never look like a silently dropped key. + let podcastNotice: PodcastNotice | null = null; + /** * Single source of truth for "the user is typing" (WL-0MTV67MZU003H7SH): * true while any text-input overlay (`formState`, `shipItDialog`, or the @@ -4995,7 +5796,7 @@ export async function runWorklistTui( * command-completion hook). See {@link isInputActive}. */ const inputActive = (): boolean => - isInputActive(formState, shipItDialog, blockedNotice !== null); + isInputActive(formState, shipItDialog, blockedNotice !== null || podcastNotice !== null); /** * Re-read the code-freeze marker tri-state. Fail-open for browsing: an @@ -5085,10 +5886,15 @@ export async function runWorklistTui( if (!subscriber || !agentTracker) return; try { const current = new Set(subscriber.getTrackedPaneIds()); + const missing: string[] = []; for (const entry of agentTracker.snapshot()) { - if (!current.has(entry.paneId)) { - await subscriber.addPaneSubscription(entry.paneId); - } + if (!current.has(entry.paneId)) missing.push(entry.paneId); + } + if (missing.length > 0) { + // Batch: herdr accepts ONE events.subscribe per connection, so all + // new panes are applied by re-establishing the connection ONCE with + // the combined set (WL-0MVAS988R008PNH6) — never one restart per pane. + await subscriber.addPaneSubscriptions(missing); } } catch { // Fail-open: a subscription sync failure keeps polling as fallback. @@ -5096,28 +5902,30 @@ export async function runWorklistTui( }; /** - * Re-apply the tracker's cached states to the live items and re-render - * (coalesced per microtask so event storms do not thrash the TUI). + * Re-evaluate pane existence and re-render after a pane lifecycle event + * (agent status change, agent detected, pane closed/exited). The row's + * green-circle icon is pane-title existence (WL-0MV1BWETT005GSUJ), so an + * event that opens or closes a work-item pane must refresh the pane list. + * Single-flight across concurrent events; visibility-gated so a hidden tab + * spawns no `herdr`; fail-open (see `applyPaneMarkers`). */ - let agentEventRenderPending = false; - const scheduleAgentEventRender = (): void => { - if (agentEventRenderPending) return; - agentEventRenderPending = true; - Promise.resolve().then(() => { - agentEventRenderPending = false; - // Typing gate (WL-0MTV67MZU003H7SH): do not re-render the TUI (and its - // open overlay) while a text-input overlay is active. Icons catch up on - // the next non-typing render/refresh. - if (inputActive()) return; + let paneMarkerRefreshInFlight: Promise<void> | null = null; + const refreshPaneMarkersFromEvent = (): void => { + if (paneMarkerRefreshInFlight) return; + paneMarkerRefreshInFlight = (async () => { try { - if (agentTracker) { - mergeAgentStatesCached(state.items, agentTracker); + // Typing gate (WL-0MTV67MZU003H7SH): do not re-render the TUI (and + // its open overlay) while a text-input overlay is active. + if (inputActive()) return; + if (await paneGate.visible()) { + await refreshPaneMarkers(); } - render(); } catch { - // Fail-open: render failures never crash the TUI. + // Fail-open: pane-marker failures never crash the TUI. + } finally { + paneMarkerRefreshInFlight = null; } - }); + })(); }; if (subscriber) { @@ -5144,40 +5952,64 @@ export async function runWorklistTui( // task uses (WL-0MSHB7DHO004RHBJ + WL-0MV1BWETT005GSUJ). void runHydrator(); }, - // Agent-status events update the tracker's cached state map and - // re-render the icons immediately (no `herdr agent list` exec). + // Agent-status events keep the tracker's association bookkeeping + // fresh (the shared `.worklog/agent-panes.json` feeds the hover + // tooltip and pane subscriptions); the ROW icon is pane-title + // existence, refreshed from the pane list so a pane that just + // appeared/disappeared updates the green circle without a 30 s wait. + // (WL-0MV1BWETT005GSUJ — no longer any `herdr agent list` icon merge.) onAgentStatusChanged: (data) => { if (!agentTracker) return; agentTracker.applyAgentStatusChanged(data.pane_id, data.agent_status); - scheduleAgentEventRender(); + refreshPaneMarkersFromEvent(); }, // A new agent pane appeared (possibly from another herdr instance): // re-read the shared file for late associations and add the pane to - // the per-pane subscription set (AC3). + // the per-pane subscription set (AC3), then refresh the pane-existence + // icons. onAgentDetected: (data) => { if (!agentTracker || !subscriber) return; const grew = agentTracker.applyAgentDetected(); if (grew) void syncPaneSubscriptions(); void subscriber.addPaneSubscription(data.pane_id).catch(() => {}); - scheduleAgentEventRender(); + refreshPaneMarkersFromEvent(); }, - // A pane closed/exited: prune its associations so its icons disappear - // immediately and drop the per-pane subscription (best-effort). + // A pane closed/exited: prune its associations so its hover metadata + // disappears immediately and drop the per-pane subscription + // (best-effort); the pane-existence marker (green circle) is re-read + // from the pane list. onPaneClosed: (data) => { if (!agentTracker || !subscriber) return; agentTracker.applyPaneGone(data.pane_id); void subscriber.removePaneSubscription(data.pane_id).catch(() => {}); - scheduleAgentEventRender(); + refreshPaneMarkersFromEvent(); }, onPaneExited: (data) => { if (!agentTracker || !subscriber) return; agentTracker.applyPaneGone(data.pane_id); void subscriber.removePaneSubscription(data.pane_id).catch(() => {}); - scheduleAgentEventRender(); + refreshPaneMarkersFromEvent(); }, onError: () => { - // Socket errors are already handled by the subscriber's reconnect - // logic; no TUI action needed (fail-open). + // The event stream dropped (socket error/close). The subscriber + // reconnects with backoff; meanwhile flip back to the resume-poll + // fallback so a hidden → visible transition still refreshes + // immediately while events are down (WL-0MVAS988R008PNH6 AC3/AC1). + // `onReconnected` below restores the event path on recovery. + if (eventsActive) { + eventsActive = false; + void paneGate.visible().then((visible) => { + if (!visible) startResumePoll(); + }); + } + }, + onReconnected: () => { + // Stream re-established (restart or backoff reconnect): the event + // path drives focus/refresh again — stop the resume-poll fallback + // and top up per-pane subscriptions for any pane tracked meanwhile. + eventsActive = true; + stopResumePoll(); + void syncPaneSubscriptions(); }, }); @@ -5375,12 +6207,20 @@ export async function runWorklistTui( attachFresh([topLevel]); } } - // Tab-based active marker (WL-0MV1BWETT005GSUJ): items with an - // exact-match tab in this workspace are shown in-progress even when - // the worklog status is still `open` (e.g. a claim that was released - // before the tab was (re)opened). Fail-open. - await applyTabMarkers(newItems); + // Pane-based active marker (WL-0MV1BWETT005GSUJ): items with a + // matching pane title in this workspace are shown in-progress even + // when the worklog status is still `open` (e.g. a claim that was + // released before the pane was (re)opened). Fail-open. + await applyPaneMarkers(newItems); state.refreshItems(newItems); + // Blocked-by rows survive refreshes (WL-0MV8Q5X8U004KKAP): the + // blocker cache is keyed by item ID and invalidated per item only + // when its payload signature changes (status/stage/updatedAt — see + // blockerSignature above), so an unrelated refresh reuses the last + // resolved rows instead of re-running the async lookup and dropping + // the `Blocked By` line for a frame (the endless flicker fix). A + // dependency edge change reconciles the from-item's status and + // bumps updatedAt, so the very next refresh invalidates that item. // Re-read the Code Freeze marker so a freeze that started (or ended) // since the last refresh is reflected in the banner promptly. refreshFreezeState(); @@ -5391,14 +6231,10 @@ export async function runWorklistTui( // state unchanged. Queue depth never writes the disable marker // (WL-0MTTSWC1X005P4VD). applySprintState(await reviewStatePromise); - // Merge agent-status state into the refreshed items (top-level + - // expanded children) so the agent icons reflect the latest tracker - // state (WL-0MSBQUJQX005RAT9). Fail-open: no herdr CLI → no icons. - try { - await opts.mergeAgentStates?.(newItems); - } catch { - // Fail-open: a merge failure must never break the refresh cycle. - } + // The pane-existence marker (in-progress status + green-circle icon) + // was already applied to `newItems` above by `applyPaneMarkers`; the + // row icons are pane-title existence, not the event-driven agent + // tracker (WL-0MV1BWETT005GSUJ). No separate agent merge runs here. if (showNotification && newItems.length !== oldLen) { const diff = newItems.length - oldLen; const msg = diff > 0 ? `+${diff} new` : `${diff} removed`; @@ -5445,11 +6281,12 @@ export async function runWorklistTui( // clear-filter `/wl`. // True when a command modifies the work-item data set (close, delete, - // update, reviewed, search), warranting an immediate list refresh so the + // update, search), warranting an immediate list refresh so the // selection list reflects the change without waiting for the auto-refresh // cycle (WL-0MTA217DZ003H5K8). const isWlModifyingCommand = (cmd: string): boolean => { - // Commands like "!!wl close <id>", "!!wl delete <id>", "!!wl update <id> ..." + // Commands like "!!wl close <id>", "!!wl delete <id>", "!!wl update <id> ...", + // "!!wl reviewed <id> ...", "!!wl search ..." if (/^!!\s*wl\s+(close|delete|update|reviewed|search)\b/i.test(cmd)) return true; return false; }; @@ -5592,15 +6429,21 @@ export async function runWorklistTui( // double-clicks within {@link DOUBLE_CLICK_WINDOW_MS}. (WL-0MSGHM5BQ0096BNJ AC3) let clickState: MouseClickState = { lastClick: null, now: 0 }; + // Drag-to-scroll state tracker (WL-0MSI72S0Y0060BRK): persists across + // onData calls so the drag mapper can accumulate fractional movement + // and restore the pre-press selection when the drag activates. + let dragState: MouseDragState = createDragState(); + // ── Mouse event dispatch (WL-0MSGHM5BQ0096BNJ AC2–AC6) ───────── // handleMouseInput parses SGR mouse events from the raw chunk BEFORE the // keypress path and dispatches them (click select, double-click open, - // wheel/scroll, filter tap). Mouse-shaped sequences are fully consumed; - // plain keys and foreign escapes fall through to the keyboard handler - // untouched (AC6). See the fix note in handleMouseInput for the - // single-click-select vs double-click-open ordering (WL-0MSZBWT500034E74). + // wheel/scroll, filter tap, drag-to-scroll). Mouse-shaped sequences are + // fully consumed; plain keys and foreign escapes fall through to the + // keyboard handler untouched (AC6). See the fix note in handleMouseInput + // for the single-click-select vs double-click-open ordering + // (WL-0MSZBWT500034E74). const dispatchMouse = (key: string): boolean => - handleMouseInput(state, key, termSize, clickState); + handleMouseInput(state, key, termSize, clickState, dragState); const handleKeyInput = async (key: string): Promise<void> => { @@ -5614,9 +6457,10 @@ export async function runWorklistTui( } // Try mouse event dispatch first — only runs when the pane is not in a - // modal state (code-freeze notice, form, ship-it dialog), matching the - // keyboard path. Mouse input is ignored during those modal states. - if (!codeFreezeNotice && blockedNotice === null && formState === null && shipItDialog === null) { + // modal state (code-freeze notice, form, ship-it dialog, help overlay), + // matching the keyboard path. Mouse input is ignored during those modal + // states. + if (!codeFreezeNotice && blockedNotice === null && podcastNotice === null && formState === null && shipItDialog === null && state.mode !== 'help') { if (dispatchMouse(key)) { render(); return; @@ -5648,6 +6492,19 @@ export async function runWorklistTui( return; } + // ── Podcast w-chord dispatch notice handling (OSL-0MUQ59IRF009ICIF) ── + // Persistent modal surfacing the outcome of a w-r/w-s/w-b dispatch: a + // refused resolution ("nothing happens") or a successful dispatch. Esc, + // Enter or q dismisses it and returns to the list; all other keys are + // consumed so the notice cannot be scrolled away unnoticed. + if (podcastNotice !== null) { + if (key === '\x1b' || key === '\r' || key === '\n' || key === 'q') { + podcastNotice = null; + } + render(); + return; + } + // ── Form mode handling ────────────────────────────────────── if (formState !== null) { const result = formState.handleInput(key); @@ -5713,7 +6570,52 @@ export async function runWorklistTui( // Quit on a bare `q` — but NOT while a chord is pending, because `q` // may be the chord's next key (e.g. the `c q` create-quick chord, // WL-0MTCLB50D0026YA7). Chord-mode handling below consumes the key - // when `chordState.pendingKeys` is non-empty. + // when `chordState.pendingKeys` is non-empty. Help mode is handled + // first (below) so `q` dismisses the overlay instead of quitting. + + // ── Help overlay mode (WL-0MV85R14Y003RII9) ───────────────── + // Full-pane, display-only overlay. `?`, Esc and `q` dismiss it back to + // the recorded pre-help mode; scroll keys (j/k/arrow/PageUp/PageDown, + // g/G) move the content window; every other key is consumed so no + // command is dispatched and no list state is mutated (2026-10-16 audit: + // the overlay must be scrollable). + if (state.mode === 'help') { + const pageSize = Math.max(5, termSize.rows - 4); + if (key === '?' || key === '\x1b' || key === 'q') { + helpScrollOffset = 0; + state.mode = preHelpMode; + } else if (key === 'j' || key === '\x1b[B') { + helpScrollOffset += 1; + } else if (key === 'k' || key === '\x1b[A') { + helpScrollOffset = Math.max(0, helpScrollOffset - 1); + } else if (key === '\x1b[6~') { + helpScrollOffset += pageSize; + } else if (key === '\x1b[5~') { + helpScrollOffset = Math.max(0, helpScrollOffset - pageSize); + } else if (key === 'g') { + helpScrollOffset = 0; + } else if (key === 'G') { + helpScrollOffset = Number.MAX_SAFE_INTEGER; // clamped in the formatter + } + render(); + return; + } + // Open the help overlay from the list view. Reserved key `?` (no + // registry chord, no navigation action) — only offered while help text + // is enabled, matching the footer hint gate (WL-0MV85QZXN002T51A AC3). + if ( + state.mode === 'list' + && key === '?' + && chordState.pendingKeys.length === 0 + && opts.getShowHelpText() + ) { + preHelpMode = state.mode; + helpScrollOffset = 0; + state.mode = 'help'; + render(); + return; + } + if (key === 'q' && state.mode !== 'filter' && chordState.pendingKeys.length === 0) { cleanup(); resolve(undefined); @@ -5747,6 +6649,10 @@ export async function runWorklistTui( chordState.resolvedModel = null; chordState.resolvedOpenPane = undefined; chordState.resolvedFocus = undefined; + // True when the resolved command carried a podcast-progression marker + // (OSL-0MUQ59IRF009ICIF): its outcome is surfaced as the persistent + // podcastNotice instead of the transient 'Sent' toast. + let podcastMarkerCommand = false; if (command) { // Podcast-progression markers (<podcast-target>/<podcast-script>/ // <podcast-review>/<podcast-both>) are resolved from the selected @@ -5757,11 +6663,12 @@ export async function runWorklistTui( || command.includes('<podcast-review>') || command.includes('<podcast-both>')) { const podcast = await resolvePodcastTarget(command, state.getSelectedItem()); if (podcast.error) { - showToast('Error', { body: podcast.error }); + podcastNotice = { kind: 'error', body: podcast.error }; render(); return; } command = podcast.command ?? command; + podcastMarkerCommand = true; } // Ship It confirmation (WL-0MSGG5N5Z0074TLY): the dev→main // release command, however it reaches this path, requires a typed @@ -5920,7 +6827,15 @@ export async function runWorklistTui( return; } if (opts.onCommand) { - opts.onCommand(finalCmd, model ?? undefined); + // Forward openPane/onRefresh/focus with the same arity rules + // as the placeholder-free paths (invokeOnCommand): a form- + // triggered shortcut carrying `open_pane: false` must run in + // the background exactly like a placeholder-free one + // (WL-0MV6J4HQ0009OJPQ). paneTitle is intentionally not + // computed here — the form path has never supplied one, so + // pane-opening form dispatches stay byte-compatible with + // today's 2-arg call. + invokeOnCommand(opts.onCommand, finalCmd, model ?? undefined, openPane, opts.onRefresh, undefined, focus); } }, // onCancel @@ -5946,6 +6861,10 @@ export async function runWorklistTui( // Code Freeze — show the notice dialog; the command was NOT // routed, no pane spawned, no work item claimed. codeFreezeNotice = true; + } else if (podcastMarkerCommand) { + // Persistent in-pane confirmation (OSL-0MUQ59IRF009ICIF): the + // resolved podcast command replaces the transient 'Sent' toast. + podcastNotice = { kind: 'dispatched', body: command }; } else if (result === 'noop') { showToast('Skipped', { body: `${command.length > 60 ? command.substring(0, 57) + '...' : command} (no item)` }); } else { @@ -5956,7 +6875,7 @@ export async function runWorklistTui( // 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). - // Modifying-command dispatch (close, delete, update, reviewed, + // Modifying-command dispatch (close, delete, update, // search): refetch so the selection list reflects the change // immediately instead of waiting for the auto-refresh cycle // (WL-0MTA217DZ003H5K8). @@ -6026,6 +6945,9 @@ export async function runWorklistTui( // openPane: undefined (default) = open a pane; false = background, // no pane (WL-0MSJLD1I70045ZUL). const singleOpenPane = singleEntry.openPane ?? undefined; + // True when the command carried a podcast-progression marker: its + // outcome uses the persistent podcastNotice (OSL-0MUQ59IRF009ICIF). + let singlePodcastMarker = false; // Podcast-progression markers (<podcast-target>/<podcast-script>/ // <podcast-review>/<podcast-both>) are resolved from the selected // item's context BEFORE the generic modal-form check so they never @@ -6035,11 +6957,12 @@ export async function runWorklistTui( || singleCmd.includes('<podcast-review>') || singleCmd.includes('<podcast-both>')) { const podcast = await resolvePodcastTarget(singleCmd, state.getSelectedItem()); if (podcast.error) { - showToast('Error', { body: podcast.error }); + podcastNotice = { kind: 'error', body: podcast.error }; render(); return; } singleCmd = podcast.command ?? singleCmd; + singlePodcastMarker = true; } // Ship It (S) — typed-confirmation dialog (WL-0MSGG5N5Z0074TLY): // the dev→main release shortcut does NOT dispatch immediately. It @@ -6106,6 +7029,9 @@ export async function runWorklistTui( if (result === 'blocked') { // Code Freeze — show the notice dialog; no pane spawned. codeFreezeNotice = true; + } else if (singlePodcastMarker) { + // Persistent in-pane confirmation (OSL-0MUQ59IRF009ICIF). + podcastNotice = { kind: 'dispatched', body: singleCmd }; } else { // Surface a brief toast, then continue showToast('Sent', { body: singleCmd.length > 60 ? singleCmd.substring(0, 57) + '...' : singleCmd }); @@ -6114,7 +7040,7 @@ export async function runWorklistTui( // 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). - // Modifying-command dispatch (close, delete, update, reviewed, + // Modifying-command dispatch (close, delete, update, // search): refetch so the selection list reflects the change // immediately instead of waiting for the auto-refresh cycle // (WL-0MTA217DZ003H5K8). @@ -6192,13 +7118,11 @@ export async function runWorklistTui( // survive the flatten (which may hold a copy with a corrected // depth) — see WorkItemListState.attachChildren. state.attachChildren(selected.id, children); - // Merge agent-status state into the freshly loaded children so - // their rows show agent icons too (WL-0MSBQUJQX005RAT9). - try { - await opts.mergeAgentStates?.([selected]); - } catch { - // Fail-open: a merge failure must never break expansion. - } + // The pane-existence marker recurses into children, so newly + // expanded children pick up their green-circle / in-progress + // markers on the next pane-marker pass (the 30 s cadence or the + // next refresh) — same behaviour as the status marker + // (WL-0MV1BWETT005GSUJ). state.toggleExpand(selected.id); render(); return; @@ -6263,6 +7187,42 @@ export async function runWorklistTui( } }; + /** + * Build the command list shown in the `?` help overlay (WL-0MV85R14Y003RII9). + * + * Derived live from the ShortcutRegistry for the selected item's stage and + * issue type, so a future shortcut appears automatically. Mirrors the + * footer's list-view filter (`view === 'list' | 'both'`); unlike the footer + * it never collapses a chord family — the overlay enumerates every entry. + */ + const buildHelpEntries = (): ShortcutEntry[] => { + if (!shortcutRegistry) return []; + const reg = shortcutRegistry as ShortcutRegistry; + const displayItems = state.getDisplayRows(); + const selIdx = state.selectedIndex; + const selItem = displayItems.length > 0 && selIdx < displayItems.length + ? displayItems[selIdx] + : undefined; + const selStage = selItem !== undefined && !isHeadingRow(selItem) ? selItem.stage : undefined; + const selIssueType = selItem !== undefined && !isHeadingRow(selItem) ? selItem.issueType : undefined; + return reg + .getEntriesForStage(selStage, codeFreezeActive, selIssueType) + .filter(e => e.view === 'list' || e.view === 'both'); + }; + + /** + * Build the icon legend shown in the `?` help overlay (WL-0MV85R2G6000G7BU). + * Rows come from the shared `ICON_LEGEND` (the single icon source of truth) + * so a newly added row glyph cannot silently miss the legend — the drift + * guard in packages/shared/src/icons-legend.test.ts enforces it. + */ + const buildHelpIconLegend = (): HelpIconRow[] => + ICON_LEGEND.map((entry) => ({ + category: entry.category, + glyph: entry.glyph, + meaning: entry.meaning, + })); + const render = (): void => { termSize = getTermSize(); state.termSize = termSize; @@ -6300,6 +7260,39 @@ export async function runWorklistTui( return; } + // ── Podcast w-chord dispatch notice overlay rendering ────── + // Persistent modal for a w-r/w-s/w-b dispatch outcome + // (OSL-0MUQ59IRF009ICIF). Rendered above the list so a refused dispatch + // is impossible to miss; the operator dismisses it with Esc/Enter/q. + if (podcastNotice !== null) { + const dialogOutput = formatPodcastNotice(termSize.cols, termSize.rows, podcastNotice); + process.stdout.write(ANSI.clear); + process.stdout.write(ANSI.cursorHome); + process.stdout.write(dialogOutput); + return; + } + + // ── Help overlay rendering (WL-0MV85R14Y003RII9) ────────── + // Full-pane, read-only `?` help screen: every list-view shortcut + // (grouped by chord leader) plus the icon legend, clipped to the + // `rows - 1` budget with the dismiss footer always visible, and a + // scrollable content window (2026-10-16 audit). Nothing is + // dispatched and no list state is read or mutated beyond the registry. + if (state.mode === 'help') { + const helpEntries = buildHelpEntries(); + const helpOutput = formatHelpScreen( + helpEntries, + termSize.cols, + termSize.rows, + buildHelpIconLegend(), + helpScrollOffset, + ); + process.stdout.write(ANSI.clear); + process.stdout.write(ANSI.cursorHome); + process.stdout.write(helpOutput); + return; + } + // Use the display-rows model for list rendering (headings + items, // WL-0MSL5MPSZ003TG94). Heading rows have no stage/issueType, so // shortcut hints fall back to defaults for heading selections. @@ -6333,17 +7326,6 @@ export async function runWorklistTui( } } - // Mouse-tracking toggle hint (WL-0MT0AP2LR000JFWN): appends the Alt+m - // toggle to the footer shortcut hints so the user knows how to switch - // between mouse interaction and native text-selection (drag-select). - // Shown whenever help text is enabled. - if (opts.getShowHelpText()) { - const mouseHint = mouseTrackingEnabled - ? `alt+m mouse on` - : `alt+m mouse off`; - dynamicHints = dynamicHints ? `${dynamicHints} ${mouseHint}` : ` ${mouseHint}`; - } - // Look up the selected item's last recorded command for the metadata // panel (only shown for actively-worked, status in-progress items). Best // effort: a missing or unreadable log yields undefined and the panel falls @@ -6352,6 +7334,27 @@ export async function runWorklistTui( ? resolveMetaLastCommand(state.getSelectedItem()) : undefined; + // Active dependency blockers for the metadata panel's `Blocked By` rows + // (WL-0MV8EZRDJ001TDA8). Cached/async; the render path never blocks. + const metaBlockers = state.mode === 'list' + ? resolveMetaBlockers(state.getSelectedItem()) + : undefined; + // Keep the state's `m`/`M` scroll clamp in sync with the rows the + // renderer will actually show. + state.metaBlockedBy = metaBlockers ?? null; + // Detail view: the SAME single-flight + signature-keyed cache resolves + // the detail item's active blockers so `formatDetailContent` can render + // `Blocked By` rows without re-opening the WL-0MV8Q5X8U004KKAP flicker + // class (WL-0MVBMLY2Z0032CYR). Sharing the per-item cache/in-flight memo + // means a detail lookup never duplicates a preview lookup for the same + // item: mode switches between the two surfaces reuse the resolved rows. + const detailBlockers = state.mode === 'detail' + ? resolveMetaBlockers(state.detailItem) + : undefined; + // Keep the detail scroll-bounds calculation in sync with the rows the + // renderer will actually show (detailScrollDown clamp, WL-0MVBMLY2Z0032CYR). + state.detailBlockedBy = detailBlockers ?? null; + // ── Hover tooltip (WL-0MT9XRZDK006GMUH) ──────────────────────── // Build the tooltip lines for the currently hovered row when it is a // work item with an associated agent-pane and the tooltip has not been @@ -6418,6 +7421,8 @@ export async function runWorklistTui( loadSettings().browseItemCount ?? opts.browseItemCount, // Outstanding-audit flag driving the banner copy (AH-0MUDYTQ55002NUSJ). sprintAuditsOutstanding, + metaBlockers, + detailBlockers, ); // Notifications are surfaced via Herdr toasts (showToast), never as a @@ -6653,18 +7658,18 @@ export async function runWorklistTui( panePaused = false; // Hydrator focus-resume (AC4): the hidden→visible transition also // re-checks `in_progress` claims immediately — the same runner the - // 30 s hydrate task uses — so a claim whose item tab was closed while + // 30 s hydrate task uses — so a claim whose item pane was closed while // the pane was hidden is released as soon as focus returns (no wait // for the next hydrate tick). This runs BEFORE the DB-change gate - // because opening/closing a work-item tab does not touch the worklog + // because opening/closing a work-item pane does not touch the worklog // DB (WL-0MV1BWETT005GSUJ). `paneGate.visible()` is already cached // true here, so the runner proceeds without a new visibility probe. void runHydrator(); - // Selection-list marker (WL-0MV1BWETT005GSUJ): re-evaluate tab + // Selection-list marker (WL-0MV1BWETT005GSUJ): re-evaluate pane // existence independently of the DB-change gate below — opening or - // closing a work-item tab does not touch the worklog DB, so a + // closing a work-item pane does not touch the worklog DB, so a // DB-gated refresh can never show/clear its marker. - void refreshTabMarkers(); + void refreshPaneMarkers(); // DB-change gate: skip the refresh when the DB is unchanged (same as // the regular refresh tick). if (tracker && !tracker.dbChanged()) { @@ -6693,22 +7698,22 @@ export async function runWorklistTui( }, }); - // Active-tab marker task (WL-0MV1BWETT005GSUJ): the selection-list marker - // must reflect tab existence, but the regular refresh tick is DB-change - // gated and opening/closing a work-item tab does NOT touch the worklog DB. - // This task re-evaluates tabs on the hydrator cadence and re-renders, - // spawning only `herdr tab list` (never `wl`), so the zero-`wl`-when-idle + // Active-pane marker task (WL-0MV1BWETT005GSUJ): the selection-list marker + // must reflect pane existence, but the regular refresh tick is DB-change + // gated and opening/closing a work-item pane does NOT touch the worklog DB. + // This task re-evaluates panes on the hydrator cadence and re-renders, + // spawning only `herdr pane list` (never `wl`), so the zero-`wl`-when-idle // guarantee holds. Visibility- and typing-gated like the hydrator. if (opts.autoRefresh) { scheduler.addTask({ - id: 'active-tab-marker', + id: 'active-pane-marker', intervalMs: HYDRATOR_INTERVAL_MS, singleFlight: true, runTimeoutMs: HYDRATOR_RUN_TIMEOUT_MS, run: async () => { if (inputActive()) return; if (!(await paneGate.visible())) return; - await refreshTabMarkers(); + await refreshPaneMarkers(); }, }); } diff --git a/packages/herdr/tsconfig.json b/packages/herdr/tsconfig.json index 64a2c7116..79a9be7e9 100644 --- a/packages/herdr/tsconfig.json +++ b/packages/herdr/tsconfig.json @@ -6,5 +6,5 @@ "declaration": true }, "include": ["src/**/*.ts"], - "exclude": ["src/**/*.test.ts", "node_modules", "dist"] + "exclude": ["src/**/*.test.ts", "src/__fixtures__", "node_modules", "dist"] } diff --git a/packages/shared/README.md b/packages/shared/README.md index 68052082d..d01682af1 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -119,15 +119,16 @@ content is still persisted — only `updatedAt` is preserved. All other fields (tags, status, priority, …) use strict comparison; genuine content changes still bump `updatedAt`. -### Metadata flag flips do not bump `updatedAt` (WL-0MSN6ZCTN0027U2R) +### Review-state updates do not bump `updatedAt` (WL-0MSN6ZCTN0027U2R) -In `update()`, `needsProducerReview` is treated as **metadata**, not content: -a flag-only flip is persisted (written to the store) but does **not** bump -`updatedAt`. Content fields (title, description, status, stage, priority, -sortIndex, parentId, tags, assignee, issueType, risk, effort) still bump the -timestamp exactly as before, and a combined content+flag update bumps too. +In `update()`, an assignee hold (applying or clearing the producer-hold +assignee) is treated as **metadata**, not content: the write is persisted but +does **not** bump `updatedAt`. Content fields (title, description, status, +stage, priority, sortIndex, parentId, tags, assignee, issueType, risk, +effort) still bump the timestamp exactly as before, and a combined +content+hold update bumps too. -Rationale: the flag is a review-state signal, not item content. Bumping +Rationale: the hold is a review-state signal, not item content. Bumping `updatedAt` on a flag flip made previously valid audits look stale to herdr's `isAuditFresh` predicate, degrading the TUI icon and re-dispatching redundant `/skill:audit` runs for unchanged content. diff --git a/packages/shared/src/database.test.ts b/packages/shared/src/database.test.ts index 638b75e77..dd46813f9 100644 --- a/packages/shared/src/database.test.ts +++ b/packages/shared/src/database.test.ts @@ -252,6 +252,100 @@ describe('updateIfMatches() CAS claim (RCA WL-0MSRBFFLN005W3VT design point 1)', }); }); +// ── WL-0MV85WARS000THEC: assignee CAS guard on updateIfMatches ────── + +/** + * Extension of `updateIfMatches`' expected-match surface with `assignee` + * (the `wl update --if-assignee` guard, C2 WL-0MV85WBDB003J35W). + */ +describe('updateIfMatches() assignee CAS guard (WL-0MV85WARS000THEC)', () => { + function seed(assignee: string, status: string = 'open', stage: string = 'idea'): WorkItem { + const item = makeItem({ + id: 'WI-CAS-ASG', + status: status as WorkItem['status'], + stage, + assignee, + }); + db.import([item]); + return item; + } + + it('applies the update when expected.assignee matches the stored assignee', () => { + const seeded = seed(''); + const result = db.updateIfMatches( + seeded.id, + { assignee: 'Map' }, + { assignee: '' }, + ); + expect(result.ok).toBe(true); + expect(result.item?.assignee).toBe('Map'); + }); + + it('fails stale (no write) when expected.assignee does not match', () => { + const seeded = seed('Other'); + const result = db.updateIfMatches( + seeded.id, + { assignee: 'Map', priority: 'high' }, + { assignee: '' }, + ); + expect(result.ok).toBe(false); + expect(result.reason).toBe('stale'); + // No partial write: neither the guarded assignee nor a sibling field. + expect(db.get(seeded.id)?.assignee).toBe('Other'); + expect(db.get(seeded.id)?.priority).toBe('medium'); + }); + + it('assignee "" expected value matches an empty stored assignee', () => { + const seeded = seed(''); + const result = db.updateIfMatches( + seeded.id, + { assignee: 'Map' }, + { assignee: '' }, + ); + expect(result.ok).toBe(true); + expect(result.item?.assignee).toBe('Map'); + }); + + it('assignee "" expected value fails stale when the stored assignee is non-empty', () => { + const seeded = seed('Map'); + const result = db.updateIfMatches( + seeded.id, + { assignee: 'Other' }, + { assignee: '' }, + ); + expect(result.ok).toBe(false); + expect(result.reason).toBe('stale'); + expect(db.get(seeded.id)?.assignee).toBe('Map'); + }); + + it('composes with the status/stage guards in one atomic guard (audit-tier claim shape)', () => { + const seeded = seed('', 'completed', 'in_review'); + const result = db.updateIfMatches( + seeded.id, + { assignee: 'audit-tier' }, + { status: 'completed', stage: 'in_review', assignee: '' }, + ); + expect(result.ok).toBe(true); + const after = db.get(seeded.id); + expect(after?.assignee).toBe('audit-tier'); + // Status-neutral: the claim never rewrote status/stage. + expect(after?.status).toBe('completed'); + expect(after?.stage).toBe('in_review'); + }); + + it('combined guard fails stale (no write) when only the assignee mismatches', () => { + const seeded = seed('Other', 'completed', 'in_review'); + const result = db.updateIfMatches( + seeded.id, + { assignee: 'audit-tier' }, + { status: 'completed', stage: 'in_review', assignee: '' }, + ); + expect(result.ok).toBe(false); + expect(result.reason).toBe('stale'); + expect(db.get(seeded.id)?.assignee).toBe('Other'); + }); +}); + describe('upsertItems() no-op guard (WL-0MSORD6HC005QVZX)', () => { it('preserves updatedAt when only description differs by a trailing newline', () => { const original = makeItem(); @@ -270,6 +364,47 @@ describe('upsertItems() no-op guard (WL-0MSORD6HC005QVZX)', () => { }); }); +// ── WL-0MVB9557U009LEIF: omitted needsProducerReview must not look changed ── +// +// Stored items round-trip needsProducerReview through Boolean() (SQLite +// INTEGER column), so a stored false reads back as `false`. Import/upsert +// callers may omit the key entirely (undefined) — an omitted flag must be +// equivalent to `false`, otherwise every no-op re-import is treated as a +// semantic change and updatedAt is wrongly bumped. + +describe('import()/upsertItems() omit-needsProducerReview no-op guard (WL-0MVB9557U009LEIF)', () => { + /** Strip needsProducerReview so the incoming item carries `undefined`. */ + function withoutFlag(item: WorkItem): WorkItem { + const copy: Partial<WorkItem> = { ...item }; + delete copy.needsProducerReview; + return copy as WorkItem; + } + + it('import() preserves updatedAt when the incoming item omits the flag', () => { + const stored = makeItem({ id: 'WI-NPR-IMP', needsProducerReview: false }); + db.import([stored]); + // Re-import with the key omitted (undefined) — must be a no-op. + db.import([withoutFlag(stored)]); + expect(db.get(stored.id)?.needsProducerReview).toBe(false); + expect(db.get(stored.id)?.updatedAt).toBe(FIXED_TS); + }); + + it('upsertItems() preserves updatedAt when the incoming item omits the flag', () => { + const stored = makeItem({ id: 'WI-NPR-UPS', needsProducerReview: false }); + db.import([stored]); + db.upsertItems([withoutFlag(stored)]); + expect(db.get(stored.id)?.updatedAt).toBe(FIXED_TS); + }); + + it('import() still bumps updatedAt when the omitted flag differs from a stored true', () => { + const stored = makeItem({ id: 'WI-NPR-TRUE', needsProducerReview: true }); + db.import([stored]); + db.import([withoutFlag(stored)]); + // Boolean(undefined) === false, so this IS a genuine change. + expect(db.get(stored.id)?.updatedAt).not.toBe(FIXED_TS); + }); +}); + // ── WL-0MSN6ZCTN0027U2R: needsProducerReview flag flips must not bump updatedAt ── describe('update() needsProducerReview flag-only does not bump updatedAt (WL-0MSN6ZCTN0027U2R)', () => { diff --git a/packages/shared/src/database.ts b/packages/shared/src/database.ts index c32ff8db2..1fddfeb5f 100644 --- a/packages/shared/src/database.ts +++ b/packages/shared/src/database.ts @@ -1340,13 +1340,18 @@ export class WorklogDatabase { /** * Conditional update (compare-and-swap, RCA WL-0MSRBFFLN005W3VT design - * point 1): apply `input` ONLY if the item's current status/stage match the - * expected guard. Runs the check and the write inside a single `BEGIN - * IMMEDIATE` transaction (see `PersistentStore.transactionImmediate`), so - * two concurrent `wl update --if-status/--if-stage` processes serialize on - * the SQLite write lock: exactly one observes the expected state and wins; - * the loser observes the winner's committed transition and fails `stale` - * without writing. + * point 1): apply `input` ONLY if the item's current status/stage/assignee + * match the expected guard. Runs the check and the write inside a single + * `BEGIN IMMEDIATE` transaction (see `PersistentStore.transactionImmediate`), + * so two concurrent `wl update --if-status/--if-stage/--if-assignee` + * processes serialize on the SQLite write lock: exactly one observes the + * expected state and wins; the loser observes the winner's committed + * transition and fails `stale` without writing. + * + * `expected.assignee` distinguishes an explicit empty-string guard (match + * an unassigned item — the status-neutral audit-tier claim) from an omitted + * guard (`undefined`): the empty string is a meaningful expected value, not + * a "no guard" sentinel. * * @returns `{ok:true, item}` on success; `{ok:false, reason:'not-found'}` * when the id is absent; `{ok:false, reason:'stale'}` when the guard @@ -1355,7 +1360,7 @@ export class WorklogDatabase { updateIfMatches( id: string, input: UpdateWorkItemInput, - expected: { status?: string; stage?: string } = {}, + expected: { status?: string; stage?: string; assignee?: string } = {}, ): UpdateIfMatchesResult { return this.store.transactionImmediate(() => { const current = this.store.getWorkItem(id); @@ -1372,6 +1377,9 @@ export class WorklogDatabase { if (expected.stage !== undefined && current.stage !== expected.stage) { return { ok: false, reason: 'stale' }; } + if (expected.assignee !== undefined && (current.assignee ?? '') !== expected.assignee) { + return { ok: false, reason: 'stale' }; + } const updated = this.update(id, input); return updated ? { ok: true, item: updated } : { ok: false, reason: 'not-found' }; }); @@ -3059,7 +3067,11 @@ export class WorklogDatabase { * 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 + * (WL-0MSORD6HC005QVZX). `needsProducerReview` is normalised through + * `Boolean()` so that an omitted flag (undefined on incoming items) is + * equivalent to an explicit `false` (the value stored items round-trip + * to) — no-op re-imports must not bump `updatedAt` (WL-0MVB9557U009LEIF). + * All other fields use strict equality; arrays * (tags) are compared by value. * * Shared by {@link hasWorkItemChanged} (16-field tracked set, used by @@ -3075,6 +3087,14 @@ export class WorklogDatabase { // are not semantic changes — compare normalized text only. return String(oldVal ?? '').trim() !== String(newVal ?? '').trim(); } + if (f === 'needsProducerReview') { + // Stored items round-trip this flag through Boolean() (SQLite INTEGER + // column), while incoming import/upsert items may omit the key + // (undefined). Normalise so an omitted flag is equivalent to an + // explicit false — otherwise every no-op re-import looks like a + // semantic change and updatedAt gets bumped (WL-0MVB9557U009LEIF). + return Boolean(oldVal) !== Boolean(newVal); + } if (Array.isArray(oldVal) && Array.isArray(newVal)) { return JSON.stringify(oldVal) !== JSON.stringify(newVal); } @@ -3087,7 +3107,9 @@ export class WorklogDatabase { * tracked field has semantically changed. * * Uses the same field set and comparison logic as the no-op guard in {@link update} - * (via {@link compareTrackedFields}). + * (via {@link compareTrackedFields}). `needsProducerReview` is compared via + * `Boolean()` normalisation, so incoming items that omit the flag are not + * treated as changed against stored `false` values (WL-0MVB9557U009LEIF). */ private hasWorkItemChanged(oldItem: WorkItem, newItem: WorkItem): boolean { const fieldsToCompare: (keyof WorkItem)[] = [ @@ -3127,6 +3149,12 @@ export class WorklogDatabase { * re-timestamp unchanged items. Changed items get a new `updatedAt`; * entirely new items use the incoming value as-is. * + * `needsProducerReview` is compared via `Boolean()` normalisation (see + * {@link compareTrackedFields}), so an omitted flag on incoming items is + * treated as unchanged against a stored `false` — a no-op re-import must + * not re-timestamp every item (WL-0MVB9557U009LEIF). A genuine flip to + * `true` still counts as a change. + * * 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, @@ -3209,7 +3237,8 @@ export class WorklogDatabase { * * **No-op guard**: For each item that already exists in the store AND has * identical tracked fields (same field set as {@link hasWorkItemChanged}, - * whitespace-insensitive for `title`/`description`), the save is entirely + * whitespace-insensitive for `title`/`description`, `Boolean()`-normalised + * for `needsProducerReview`), the save is entirely * skipped — preserving the existing `updatedAt`. Items whose tracked fields * differ, or that are new, get a fresh `updatedAt` timestamp. * diff --git a/packages/shared/src/icons-legend.test.ts b/packages/shared/src/icons-legend.test.ts new file mode 100644 index 000000000..b3f1b7dfb --- /dev/null +++ b/packages/shared/src/icons-legend.test.ts @@ -0,0 +1,88 @@ +/** + * packages/shared/src/icons-legend.test.ts — Icon legend coverage + drift + * guard (parent WL-0MTV97GV20088CS7 AC5; test-first child WL-0MV85R1V6000IL4E; + * implementation child WL-0MV85R2G6000G7BU). + * + * The `?` help overlay must explain every glyph rendered in a work-item row. + * The legend is authored in the single source of truth (`icons.ts`) and these + * tests pin its completeness so a new row glyph cannot be added without a + * matching legend entry (drift guard). + * + * The implementation child WL-0MV85R2G6000G7BU landed the legend, so the + * bodies below assert the implemented behaviour with a plain `it` (the + * red-witness `it.fails` form was flipped on landing). + * + * Run through the test skill (`/skill:test`); do not invoke vitest directly. + */ +import { describe, it, expect } from 'vitest'; +// The repo-wide guard forbids importing an `icons.js` path directly; import +// the package re-export instead. +import { ICON_LEGEND, iconLegendGlyphs, type IconLegendEntry } from '@worklog/shared/icons'; + +/** Categories the legend must cover (parent AC5). */ +const REQUIRED_CATEGORIES = [ + 'status', + 'stage', + 'priority', + 'risk', + 'effort', + 'audit', + 'agent', + 'coverage', +]; + +describe('icon legend — coverage (WL-0MV85R1V6000IL4E)', () => { + it('covers every row glyph advertised by the icon source of truth', () => { + const glyphs = iconLegendGlyphs(); + const legendGlyphs = new Set(ICON_LEGEND.map((e) => e.glyph)); + + expect(glyphs.length).toBeGreaterThan(0); + for (const glyph of glyphs) { + expect( + legendGlyphs.has(glyph), + `legend is missing an entry for glyph ${JSON.stringify(glyph)}`, + ).toBe(true); + } + }); + + it('does not invent glyphs the row renderer never emits', () => { + const glyphs = new Set(iconLegendGlyphs()); + for (const entry of ICON_LEGEND) { + expect( + glyphs.has(entry.glyph), + `legend entry ${JSON.stringify(entry.glyph)} is not a row glyph`, + ).toBe(true); + } + }); + + it('covers every required icon category', () => { + const categories = new Set(ICON_LEGEND.map((e) => e.category)); + for (const required of REQUIRED_CATEGORIES) { + expect(categories.has(required), `missing legend category ${required}`).toBe(true); + } + }); + + // Producer-review category (restored by F2, WL-0MVAMS4RO000H3B0): the + // `producer-review` legend rows were removed with `needsProducerReview` + // (WL-0MV85WFYU0071YQ9) and restored by F2's shared-icons restore. + it('covers the producer-review category', () => { + const categories = new Set(ICON_LEGEND.map((e) => e.category)); + expect(categories.has('producer-review')).toBe(true); + }); + + // The epic glyph and its legend category were removed + // (WL-0MVAQ873D007LNWD): parent items are marked by the expand arrow, so + // the `⊙` indicator was redundant. Guard against both creeping back. + it('no longer advertises the retired epic glyph or category', () => { + const categories = new Set(ICON_LEGEND.map((e) => e.category)); + expect(categories.has('epic')).toBe(false); + expect(iconLegendGlyphs()).not.toContain('\u{2299}'); // ⊙ + }); + + it('pairs every glyph with a short, non-empty meaning', () => { + for (const entry of ICON_LEGEND) { + expect(entry.glyph.length).toBeGreaterThan(0); + expect(entry.meaning.trim().length).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/shared/src/icons.ts b/packages/shared/src/icons.ts index fdecf60c2..3f807af1c 100644 --- a/packages/shared/src/icons.ts +++ b/packages/shared/src/icons.ts @@ -7,7 +7,7 @@ * home so the plugin no longer carries a duplicate that can drift. * * The icon glyphs deliberately preserve the values the herdr worklist has - * always rendered (❓ unknown audit, ⏳ stale-passed, ⊙ epic, 💥 critical + * always rendered (❓ unknown audit, ⏳ stale-passed, 💥 critical * risk, `[ready]`/`[?]` text fallbacks) so removing the local copy does not * change any rendered output (parent WL-0MSJ4BT4Z002HH9B AC3: "the worklist * renders icons/colours identically"). @@ -75,7 +75,7 @@ const STAGE_FALLBACK: Record<string, string> = { const PRIORITY_ICONS: Record<string, string> = { critical: '\u{1F6A8}', // 🚨 high: '\u{2B50}', // ⭐ - medium: '\u{1F4CB}', // 📋 + medium: '\u{1F7E8}', // 🟨 low: '\u{1F422}', // 🐢 }; @@ -100,9 +100,6 @@ const EFFORT_ICONS: Record<string, string> = { xlarge: '\u{1F4A0}', // 💠 }; -const EPIC_ICON = '\u{2299}'; // ⊙ -const EPIC_FALLBACK = '[EPIC]'; - const AUDIT_READY = '\u{2705}'; // ✅ const AUDIT_NOT_READY = '\u{274C}'; // ❌ const AUDIT_UNKNOWN = '\u{2753}'; // ❓ @@ -120,12 +117,17 @@ const AUDIT_COVERED_FALLBACK = '[COVERED]'; const NEEDS_REVIEW_ICON = '\u{274C}'; // ❌ const REVIEW_DONE_ICON = '\u{2705}'; // ✅ -// Agent-status icons (WL-0MSBQUJQX005RAT9). Glyph mapping confirmed by the -// user: working → 🟢 (green circle), blocked → ⛔ (no-entry sign), idle → ⚪ -// (white circle). `done`/`unknown`/absent → no icon. +// Agent-slot icons (WL-0MSBQUJQX005RAT9, repurposed for pane existence by +// WL-0MV1BWETT005GSUJ). The worklist's selection list stamps `working` — the +// green circle — on an item when a pane in the current workspace carries the +// item's ID in its title (an open work-item pane), and clears it otherwise; +// so in the list the glyph means "work-item pane open", never a live agent +// status. `blocked`/`idle` remain valid `agentStatusIcon` states for other +// consumers (the icon-fn API is unchanged). `done`/`unknown`/absent → no +// icon. const AGENT_STATE_ICONS: Record<string, string> = { idle: '\u{26AA}', // ⚪ - working: '\u{1F7E2}', // 🟢 + working: '\u{1F7E2}', // 🟢 (green circle — list meaning: "work-item pane open") blocked: '\u{26D4}', // ⛔ }; @@ -213,19 +215,15 @@ export function auditStaleIcon(result: boolean | null | undefined, opts?: IconOp } /** - * Get the epic icon. - */ -export function epicIcon(opts?: IconOptions): string { - if (opts?.noIcons) return EPIC_FALLBACK; - return EPIC_ICON; -} - -/** - * Get the icon for a tracked agent's current state. + * Get the icon for an item's agent slot. * - * `working → 🟢`, `blocked → ⛔`, `idle → ⚪`. `done`, `unknown`, or an - * absent state render no icon (the agent finished or the pane is gone). - * Text fallbacks follow the `[TEXT]` convention for noIcons mode. + * In the worklist selection list the stamped value is `working` — the + * green circle — which now (WL-0MV1BWETT005GSUJ) means "a work-item pane + * is open in this workspace", i.e. the item is being worked, regardless of + * the pane's live agent status. `blocked → ⛔`, `idle → ⚪` remain valid + * `agentStatusIcon` states for other consumers. `done`, `unknown`, or an + * absent state render no icon. Text fallbacks follow the `[TEXT]` + * convention for noIcons mode. */ export function agentStatusIcon(state: string | undefined, opts?: IconOptions): string { const key = (state || '').toLowerCase(); @@ -269,6 +267,98 @@ export function needsProducerReviewIcon( return needsReview ? NEEDS_REVIEW_ICON : REVIEW_DONE_ICON; } +// ── Icon legend (help screen) ─────────────────────────────────────────── + +/** + * A single row of the help screen's icon legend (WL-0MV85R2G6000G7BU). + * + * `category` groups the row in the overlay (status/stage/priority/risk/ + * effort/audit/producer-review/agent/coverage); `glyph` is the exact glyph + * rendered in work-item rows; `meaning` is a short human-readable label. + */ +export interface IconLegendEntry { + category: string; + glyph: string; + meaning: string; +} + +/** + * The complete icon legend for the help overlay. + * + * Every row glyph (see {@link iconLegendGlyphs}, the catalog the row renderer + * emits) must have a legend row – the drift guard in + * `packages/shared/src/icons-legend.test.ts` fails on any unmapped glyph. + * Entries are deliberately authored here so a newly added row glyph is + * flagged until the legend explains it (parent WL-0MTV97GV20088CS7 AC5; the + * recorded mitigation for full automatic derivation). + */ +export const ICON_LEGEND: readonly IconLegendEntry[] = [ + { category: 'status', glyph: '\u{1F513}', meaning: 'Status: open — not started' }, + { category: 'status', glyph: '\u{1F504}', meaning: 'Status: in progress' }, + { category: 'status', glyph: '\u{2714}\u{FE0F}', meaning: 'Status: completed' }, + { category: 'status', glyph: '\u{26D4}', meaning: 'Status: blocked' }, + { category: 'status', glyph: '\u{1F5D1}\u{FE0F}', meaning: 'Status: deleted' }, + { category: 'status', glyph: '\u{1F4AC}', meaning: 'Status: input needed' }, + { category: 'stage', glyph: '\u{1F4A1}', meaning: 'Stage: idea' }, + { category: 'stage', glyph: '\u{1F4E5}', meaning: 'Stage: intake complete' }, + { category: 'stage', glyph: '\u{1F4CB}', meaning: 'Stage: plan complete' }, + { category: 'stage', glyph: '\u{1F6E0}\u{FE0F}', meaning: 'Stage: in progress (legacy)' }, + { category: 'stage', glyph: '\u{1F50D}', meaning: 'Stage: in review' }, + { category: 'stage', glyph: '\u{2714}\u{FE0F}', meaning: 'Stage: completed (legacy)' }, + { category: 'stage', glyph: '\u{2714}\u{FE0F}', meaning: 'Stage: done' }, + { category: 'priority', glyph: '\u{1F6A8}', meaning: 'Priority: critical' }, + { category: 'priority', glyph: '\u{2B50}', meaning: 'Priority: high' }, + { category: 'priority', glyph: '\u{1F7E8}', meaning: 'Priority: medium' }, + { category: 'priority', glyph: '\u{1F422}', meaning: 'Priority: low' }, + { category: 'risk', glyph: '\u{1F7E2}', meaning: 'Risk: low' }, + { category: 'risk', glyph: '\u{1F7E1}', meaning: 'Risk: medium' }, + { category: 'risk', glyph: '\u{1F534}', meaning: 'Risk: high' }, + { category: 'risk', glyph: '\u{1F4A5}', meaning: 'Risk: critical' }, + { category: 'effort', glyph: '\u{1F539}', meaning: 'Effort: small' }, + { category: 'effort', glyph: '\u{1F537}', meaning: 'Effort: medium' }, + { category: 'effort', glyph: '\u{1F536}', meaning: 'Effort: large' }, + { category: 'effort', glyph: '\u{1F4A0}', meaning: 'Effort: extra large' }, + { category: 'audit', glyph: '\u{2705}', meaning: 'Audit passed — ready to close' }, + { category: 'audit', glyph: '\u{274C}', meaning: 'Audit failed — not ready' }, + { category: 'audit', glyph: '\u{2753}', meaning: 'No audit recorded yet' }, + { category: 'audit', glyph: '\u{23F3}', meaning: 'Stale audit (passed)' }, + { category: 'audit', glyph: '\u{26A0}', meaning: 'Stale audit (failed)' }, + { category: 'producer-review', glyph: '\u{274C}', meaning: 'Needs producer review' }, + { category: 'producer-review', glyph: '\u{2705}', meaning: 'Producer review done' }, + { category: 'agent', glyph: '\u{26AA}', meaning: 'Agent idle' }, + { category: 'agent', glyph: '\u{1F7E2}', meaning: 'Work-item pane open' }, + { category: 'agent', glyph: '\u{26D4}', meaning: 'Agent blocked' }, + { category: 'coverage', glyph: '[COVERED]', meaning: 'Child covered by parent audit' }, +]; + +/** + * The catalog of glyphs the work-item row renderer can emit — the single + * source of truth (the icon maps and constants). The help-overlay drift guard + * compares this catalog against {@link ICON_LEGEND}: adding a row glyph here + * without a legend row fails the test. + */ +export function iconLegendGlyphs(): string[] { + const glyphs = new Set<string>(); + const collect = (map: Record<string, string>): void => { + for (const value of Object.values(map)) glyphs.add(value); + }; + collect(STATUS_ICONS); + collect(STAGE_ICONS); + collect(PRIORITY_ICONS); + collect(RISK_ICONS); + collect(EFFORT_ICONS); + glyphs.add(AUDIT_READY); + glyphs.add(AUDIT_NOT_READY); + glyphs.add(AUDIT_UNKNOWN); + glyphs.add(AUDIT_STALE_PASSED); + glyphs.add(AUDIT_STALE_FAILED); + glyphs.add(NEEDS_REVIEW_ICON); + glyphs.add(REVIEW_DONE_ICON); + collect(AGENT_STATE_ICONS); + glyphs.add(AUDIT_COVERED_FALLBACK); + return [...glyphs]; +} + // ── Audit freshness ─────────────────────────────────────────────────── /** @@ -762,9 +852,10 @@ export function getIconPrefixParts( // Column 0: priority icon (new, WL-0MTQYTA20009YXBT) const priorityIconText = priorityIcon(item.priority, { noIcons }); - // Column 1: agent status — fixed-width reserved slot so rows with and - // without an agent keep the remaining icons and the item-ID column at - // identical columns (AC3, WL-0MSBQUJQX005RAT9). + // Column 1: agent slot — fixed-width reserved slot so rows with and + // without an open work-item pane keep the remaining icons and the + // item-ID column at identical columns (AC3, WL-0MSBQUJQX005RAT9; the + // green circle = work-item pane open, WL-0MV1BWETT005GSUJ). const agentIcon = agentStatusIcon(item.agentState, { noIcons }); const agentSlot = agentIcon !== '' ? agentIcon : ' '.repeat(AGENT_SLOT_WIDTH); @@ -781,13 +872,10 @@ export function getIconPrefixParts( // Concatenate core icons without spaces between them const coreIcons = [sIcon, secondIcon, prIcon].filter(Boolean).join(''); - // Column 4: epic icon (child count is no longer shown in prefix) - const epicSuffix = item.issueType === 'epic' ? epicIcon({ noIcons }) : ''; - // Build full prefix and pad to fixed width for alignment. The agent slot // is included in the total, so rows with and without an agent still land // at exactly ICON_PREFIX_WIDTH cells. - let prefix = [priorityIconText, agentSlot, coreIcons, epicSuffix].filter(Boolean).join(''); + let prefix = [priorityIconText, agentSlot, coreIcons].filter(Boolean).join(''); const width = stringDisplayWidth(prefix); if (width < ICON_PREFIX_WIDTH) { prefix = prefix.padEnd(prefix.length + (ICON_PREFIX_WIDTH - width), ' '); @@ -812,7 +900,6 @@ export function getIconPrefixParts( * 2. Status icon * 3. Stage icon (for in_review items, shows audit-aware icon instead) * 4. Producer review flag - * 5. Optional epic icon */ export function getIconPrefix(item: IconPrefixItem, opts?: IconOptions): string { return getIconPrefixParts(item, opts).text; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 63f68b4ed..9bdda67e5 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -174,6 +174,14 @@ export interface UpdateWorkItemInput { * stage matches. Composes with `ifStatus` (both must match). */ ifStage?: string; + /** + * CAS guard (status-neutral claim): when present, the update only applies + * if the item's CURRENT assignee matches. The empty string is a meaningful + * expected value — it matches an unassigned item — and is distinguishable + * from the guard being omitted. Composes with `ifStatus`/`ifStage` in one + * atomic transaction (WL-0MV85WBDB003J35W). + */ + ifAssignee?: string; } export interface WorkItemQuery { status?: WorkItemStatus[]; diff --git a/scripts/benchmark-caching.ts b/scripts/benchmark-caching.ts index aae567c50..6a16dcc54 100644 --- a/scripts/benchmark-caching.ts +++ b/scripts/benchmark-caching.ts @@ -116,7 +116,6 @@ for (let i = 0; i < ITEM_COUNT; i++) { deleteReason: '', risk: '', effort: '', - needsProducerReview: false, }); } // Add some comments diff --git a/scripts/cleanup-assignee-residue.ts b/scripts/cleanup-assignee-residue.ts new file mode 100644 index 000000000..fa809d9ad --- /dev/null +++ b/scripts/cleanup-assignee-residue.ts @@ -0,0 +1,270 @@ +/** + * cleanup-assignee-residue — clear stale assignee holds on audit-hold items + * + * One-time cleanup that clears non-empty `assignee` fields on: + * - completed items (status: 'completed') + * - audit-hold items (stage: 'in_review') + * + * It deliberately skips in-flight items (open, in-progress, blocked, + * input_needed) because those assignees are active scheduling holds. + * + * Ordering: this script runs AFTER the status-neutral claim + assignee-based + * selection change ships (C4/C6), because it relies on `assignee` being the + * scheduling hold rather than the removed `needsProducerReview` flag. + * + * Usage: + * npx tsx scripts/cleanup-assignee-residue.ts [--worklog-dir PATH] [--dry-run] [--json] + * + * --dry-run Print what would change without writing anything. + * --worklog-dir Explicit .worklog directory (defaults to resolution from cwd). + * --json Emit a machine-readable JSON report. + * + * Exit: 0 on success; non-zero only on unexpected errors. + */ + +import * as path from 'path'; +import { existsSync } from 'node:fs'; +import { WorklogDatabase } from '../src/database.js'; +import type { WorkItem, WorkItemStatus } from '@worklog/shared/types'; + +// ── Types ─────────────────────────────────────────────────────────────── + +/** Statuses considered "in-flight" — their assignee is an active hold. */ +export const IN_FLIGHT_STATUSES: ReadonlySet<WorkItemStatus> = new Set<WorkItemStatus>([ + 'open', + 'in-progress', + 'blocked', + 'input_needed', +]); + +export interface ResidueEntry { + id: string; + title: string; + assignee: string; +} + +export interface SkippedEntry { + id: string; + title: string; + reason: string; +} + +export interface CleanupReport { + totalItems: number; + /** Items whose assignee was (or would be) cleared. */ + cleared: ResidueEntry[]; + /** In-flight items deliberately left untouched. */ + skipped: SkippedEntry[]; + /** Items whose assignee was already empty. */ + alreadyClean: number; + /** True when no writes were performed. */ + dryRun: boolean; +} + +// ── Core logic ────────────────────────────────────────────────────────── + +/** + * Decide whether a work item is a cleanup target. + * + * A target is any item with a non-empty assignee that is NOT in-flight and + * is either completed or in the `in_review` stage (audit-hold). + */ +export function isResidueTarget(item: WorkItem): boolean { + if (!item.assignee) return false; + // Never touch deleted items, even when they carry a residual stage. + if (item.status === 'deleted') return false; + // In-flight items are active scheduling holds — never a target. + if (IN_FLIGHT_STATUSES.has(item.status)) return false; + return item.status === 'completed' || item.stage === 'in_review'; +} + +/** + * Scan the database for assignee residue. + * + * When `dryRun` is false, qualifying items are updated to clear their + * assignee. The operation is idempotent: a second run finds no residue + * (every previously-cleared item now has an empty assignee) and performs + * no further writes. + */ +export function runCleanup(db: WorklogDatabase, dryRun: boolean): CleanupReport { + const items = db.list(); + const report: CleanupReport = { + totalItems: items.length, + cleared: [], + skipped: [], + alreadyClean: 0, + dryRun, + }; + + for (const item of items) { + // Already clean — nothing to do. + if (!item.assignee) { + report.alreadyClean++; + continue; + } + + // In-flight items are deliberately left alone: their assignee is an + // active scheduling hold. + if (IN_FLIGHT_STATUSES.has(item.status)) { + report.skipped.push({ + id: item.id, + title: item.title, + reason: `in-flight (status: ${item.status})`, + }); + continue; + } + + if (!isResidueTarget(item)) { + report.skipped.push({ + id: item.id, + title: item.title, + reason: `not an audit-hold (status: ${item.status}, stage: ${item.stage})`, + }); + continue; + } + + if (!dryRun) { + // CAS-guarded clear: only clears if the assignee still matches what + // was scanned, so a concurrent claim can never be clobbered. + db.updateIfMatches(item.id, { assignee: '' }, { assignee: item.assignee }); + } + report.cleared.push({ + id: item.id, + title: item.title, + assignee: item.assignee, + }); + } + + return report; +} + +// ── Reporting ─────────────────────────────────────────────────────────── + +/** Format the cleanup report for human-readable console output. */ +export function formatReport(report: CleanupReport): string { + const label = report.dryRun ? 'WOULD CLEAR' : 'CLEARED'; + const inFlight = report.skipped.filter((s) => s.reason.startsWith('in-flight')).length; + const other = report.skipped.length - inFlight; + + const lines: string[] = []; + lines.push('═══════════════════════════════════════════════════════════'); + lines.push(` Assignee Residue Cleanup ${report.dryRun ? '(DRY RUN)' : ''}`); + lines.push('═══════════════════════════════════════════════════════════'); + lines.push(''); + lines.push(` Total items scanned: ${report.totalItems}`); + lines.push(` ${label}: ${report.cleared.length}`); + lines.push(` Already clean: ${report.alreadyClean}`); + lines.push(` Skipped (in-flight): ${inFlight}`); + lines.push(` Skipped (other): ${other}`); + lines.push(''); + + if (report.cleared.length > 0) { + lines.push(' ── Items with assignee cleared ───────────────────────'); + for (const c of report.cleared) { + lines.push(` ${c.id}: ${c.title} (assignee: "${c.assignee}")`); + } + lines.push(''); + } + + if (report.skipped.length > 0) { + const shown = report.skipped.slice(0, 20); + lines.push(' ── Skipped items ─────────────────────────────────────'); + for (const s of shown) { + lines.push(` ${s.id}: ${s.title} — ${s.reason}`); + } + if (report.skipped.length > shown.length) { + lines.push(` ... and ${report.skipped.length - shown.length} more`); + } + lines.push(''); + } + + lines.push('═══════════════════════════════════════════════════════════'); + return lines.join('\n'); +} + +// ── CLI ───────────────────────────────────────────────────────────────── + +interface CliOptions { + worklogDir: string; + dryRun: boolean; + json: boolean; +} + +export function parseArgs(argv: string[], cwd: string = process.cwd()): CliOptions { + let worklogDir = ''; + let dryRun = false; + let json = false; + + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--dry-run') { + dryRun = true; + } else if (argv[i] === '--json') { + json = true; + } else if (argv[i] === '--worklog-dir' && argv[i + 1]) { + worklogDir = argv[++i]; + } + } + + if (!worklogDir) { + worklogDir = resolveWorklogDir(cwd); + } + + return { worklogDir, dryRun, json }; +} + +/** + * Resolve the `.worklog` directory by walking up from `startDir`. + * Falls back to `<startDir>/.worklog` when none is found. + */ +export function resolveWorklogDir(startDir: string): string { + let dir = path.resolve(startDir); + while (true) { + const candidate = path.join(dir, '.worklog'); + if (existsSync(candidate)) { + return candidate; + } + const parent = path.dirname(dir); + if (parent === dir) { + return path.join(path.resolve(startDir), '.worklog'); + } + dir = parent; + } +} + +function main(): void { + const { worklogDir, dryRun, json } = parseArgs(process.argv.slice(2)); + const dbPath = path.join(worklogDir, 'worklog.db'); + const jsonlPath = path.join(worklogDir, 'worklog-data.jsonl'); + + if (!existsSync(dbPath)) { + console.error(`ERROR: Database not found at ${dbPath}`); + console.error('Ensure the worklog directory exists and is initialized.'); + process.exit(1); + } + + const db = new WorklogDatabase('WL', dbPath, jsonlPath, false, false); + + try { + const report = runCleanup(db, dryRun); + if (json) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(formatReport(report)); + if (dryRun && report.cleared.length > 0) { + console.log('\n Rerun without --dry-run to apply the changes.'); + } + } + } finally { + db.close(); + } +} + +// Only run when executed directly (not when imported by tests). +const isDirectRun = + typeof process !== 'undefined' && + process.argv[1] !== undefined && + /cleanup-assignee-residue\.(ts|js|mjs|cjs)$/.test(process.argv[1]); + +if (isDirectRun) { + main(); +} diff --git a/scripts/install-herdr.sh b/scripts/install-herdr.sh index 6b434ec5b..fae3a9340 100755 --- a/scripts/install-herdr.sh +++ b/scripts/install-herdr.sh @@ -17,7 +17,10 @@ # `herdr plugin link` updates an existing link in place, so a stale # worktree link is corrected automatically on the next build. The # dependency install (step 0) also targets the MAIN checkout for the -# same reason (WL-0MSRLH52R003CK49). +# same reason (WL-0MSRLH52R003CK49). Step 0b then replaces npm's packaged +# COPY of @worklog/shared with a symlink to the live workspace package +# (WL-0MVAY631M004RZ3Q), so a stale vendored copy can never crash the +# pane with an ESM "does not provide an export named ..." error. # - The keybinding block is inserted only when a binding for # `worklog-selection-list.open-worklog-tab` is not already present. # - Legacy `open-worklist` and `open-podcast-editor-tab` prefix+l bindings @@ -110,6 +113,25 @@ if [ -d "${HERDR_DIR}" ] && [ -f "${HERDR_DIR}/package.json" ]; then fi fi +# ── 0b. Link @worklog/shared to the live workspace package ────────────── +# npm installs the `file:` dependency as a packaged COPY that silently goes +# stale whenever packages/shared changes — the plugin then imports a missing +# export and herdr closes the pane on startup (WL-0MVAY631M004RZ3Q). +# Replace the copy with a symlink to packages/shared so the plugin always +# reads current Shared code after a build. Best-effort: a linking failure +# only warns (the src/start.ts launcher still explains the stale state). +if [ -d "${HERDR_DIR}" ] && [ -f "${HERDR_DIR}/scripts/link-shared.mjs" ]; then + if command -v node >/dev/null 2>&1; then + if node "${HERDR_DIR}/scripts/link-shared.mjs" "${HERDR_DIR}"; then + : + else + echo "Warning: 'node link-shared.mjs' failed in ${HERDR_DIR} — @worklog/shared may resolve to a stale copy." >&2 + fi + else + echo "Warning: 'node' not found on PATH — cannot link @worklog/shared in ${HERDR_DIR}." >&2 + fi +fi + # ── 1. Link the plugin (updates an existing link in place) ─────────────── if command -v herdr >/dev/null 2>&1; then if herdr plugin link "${MANIFEST_PATH}"; then diff --git a/skill/pane-triage/tests/test_pane_triage.py b/skill/pane-triage/tests/test_pane_triage.py index 541807253..4cee7502a 100755 --- a/skill/pane-triage/tests/test_pane_triage.py +++ b/skill/pane-triage/tests/test_pane_triage.py @@ -15,7 +15,7 @@ - AC1 workspace resolution (env → ``herdr pane current`` fallback) - AC2 idle classification via shared classifier (bridge output) -- AC3 Idle/Active tables with work-item id + ``needsProducerReview`` +- AC3 Idle/Active tables with work-item id + ``producerHold`` - AC4 last-20-lines log tail per idle pane - AC5 "Recommended to close" excludes invoking pane and review-pending - AC6 approval gating: no approval → zero close calls @@ -78,7 +78,7 @@ def _pane( "reasonCode": reason_code, "reasonSnapshot": { "kind": kind, - "needsProducerReview": needs_producer_review, + "producerHold": needs_producer_review, "isInvokingPane": is_invoking, }, "sessionTail": session_tail or [], diff --git a/src/cli-types.ts b/src/cli-types.ts index be2ae521e..e7c726527 100644 --- a/src/cli-types.ts +++ b/src/cli-types.ts @@ -95,6 +95,12 @@ export interface UpdateOptions { ifStatus?: string; /** CAS guard: only apply the update if the current stage matches */ ifStage?: string; + /** + * CAS guard: only apply the update if the current assignee matches. + * An explicit empty string matches an unassigned item (status-neutral + * audit-tier claim); omitting the flag applies no assignee guard. + */ + ifAssignee?: string; priority?: WorkItemPriority; parent?: string; tags?: string; diff --git a/src/commands/update.ts b/src/commands/update.ts index edce1439c..25312e71d 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -29,6 +29,7 @@ export default function register(ctx: PluginContext): void { .option('-s, --status <status>', 'New status') .option('--if-status <status>', 'Only apply the update if the current status matches (CAS guard)') .option('--if-stage <stage>', 'Only apply the update if the current stage matches (CAS guard)') + .option('--if-assignee <assignee>', 'Only apply the update if the current assignee matches (CAS guard; "" matches an unassigned item)') .option('-p, --priority <priority>', 'New priority') .option('-P, --parent <parentId>', 'New parent ID (use "null" to detach / set parentId to null)') .option('--tags <tags>', 'New tags (comma-separated)') @@ -54,14 +55,14 @@ export default function register(ctx: PluginContext): void { // --no-re-sort: skip auto re-sort // --re-sort-sync: force synchronous re-sort (blocking) // Normalize re-sort flags from commander/options - const normalized = normalizeActionArgs(rawArgs, ['title','description','descriptionFile','status','ifStatus','ifStage','priority','parent','tags','assignee','stage','risk','effort','issueType','createdBy','deletedBy','deleteReason','needsProducerReview','audit','auditText','auditFile','auditFingerprint','doNotDelegate','prefix','noReSort','reSortSync']); + const normalized = normalizeActionArgs(rawArgs, ['title','description','descriptionFile','status','ifStatus','ifStage','ifAssignee','priority','parent','tags','assignee','stage','risk','effort','issueType','createdBy','deletedBy','deleteReason','needsProducerReview','audit','auditText','auditFile','auditFingerprint','doNotDelegate','prefix','noReSort','reSortSync']); // Robust detection of --no-re-sort that accepts multiple forms Commander // may expose (`noReSort`, `reSort: false`) and also checks raw argv. const cliNoReSort = process.argv.includes('--no-re-sort') || process.argv.includes('--noReSort'); const reSortNo = (((normalized.options as any)?.noReSort === true) || ((normalized.options as any)?.reSort === false) || cliNoReSort); const reSortSync = Boolean((normalized.options as any)?.reSortSync); const knownOptionKeys = [ - 'title','description','descriptionFile','status','ifStatus','ifStage','priority','parent','tags','assignee','stage','risk','effort','issueType','createdBy','deletedBy','deleteReason','needsProducerReview','audit','auditText','auditFingerprint','doNotDelegate','prefix','noReSort','reSortSync' + 'title','description','descriptionFile','status','ifStatus','ifStage','ifAssignee','priority','parent','tags','assignee','stage','risk','effort','issueType','createdBy','deletedBy','deleteReason','needsProducerReview','audit','auditText','auditFingerprint','doNotDelegate','prefix','noReSort','reSortSync' ]; const argsHint = rawArgs.map(a => Array.isArray(a) ? `array(${a.length})` : `${typeof a}:${String(a).slice(0,100)}`); if (process.env.WL_DEBUG_UPDATE_ACTION) { @@ -133,6 +134,14 @@ export default function register(ctx: PluginContext): void { // the stored `in-progress`. const ifStatusCandidate = hasProvided('ifStatus') ? options.ifStatus : undefined; const ifStageCandidate = hasProvided('ifStage') ? options.ifStage : undefined; + // `--if-assignee` is a status-neutral CAS guard (WL-0MV85WBDB003J35W). + // An explicit empty string is a meaningful expected value (match an + // unassigned item) and must be preserved as-is — it is distinguishable + // from the flag being omitted via `hasProvided`, which is why this is + // read through `String(...)` rather than a truthiness check. + const ifAssigneeCandidate = hasProvided('ifAssignee') + ? String(options.ifAssignee) + : undefined; if (ifStatusCandidate !== undefined) { const normalized = normalizeStatusValue(String(ifStatusCandidate)); const valid = @@ -235,6 +244,41 @@ export default function register(ctx: PluginContext): void { if (deletedByCandidate !== undefined) updates.deletedBy = deletedByCandidate; if (deleteReasonCandidate !== undefined) updates.deleteReason = deleteReasonCandidate; if (needsProducerReviewCandidate !== undefined) updates.needsProducerReview = needsProducerReviewCandidate; + + // Soft-delete guard (SA-0MUJBLZC60085DI9 AC1): a soft-deleted work + // item (non-empty `deletedBy`) must never be resurrected by an + // update. `wl delete` sets `deletedBy` and may leave the status + // untouched, so without this guard `wl update <id> --status open` + // rewrites the status and the downtime dispatcher re-selects the + // deleted item. Reject any mutation other than an explicit change to + // the delete-attribution fields themselves (`--deleted-by` / + // `--delete-reason`) — the administrative override. The guard runs + // before any write so a rejected id is never partially mutated. + const existingForSoftDeleteGuard = db.get(normalizedId); + if ( + existingForSoftDeleteGuard && + String(existingForSoftDeleteGuard.deletedBy ?? '').trim() !== '' + ) { + // Note: `updates.status`/`updates.stage` are only populated later, + // per-id, by the status/stage validation block — so the candidate + // flags above must be checked directly here. + const mutatesNonDeleteField = + statusCandidate !== undefined || + stageCandidate !== undefined || + Object.keys(updates).some( + (key) => key !== 'deletedBy' && key !== 'deleteReason', + ) || + auditCandidate !== undefined; + if (mutatesNonDeleteField) { + const message = + `Work item ${normalizedId} is soft-deleted ` + + `(deletedBy: ${existingForSoftDeleteGuard.deletedBy}) and cannot be updated. ` + + `Create a new work item instead.`; + results.push({ id: normalizedId, success: false, error: 'deleted-item', message }); + continue; + } + } + if (auditCandidate !== undefined) { const current = db.get(normalizedId); if (!current) { @@ -465,19 +509,26 @@ export default function register(ctx: PluginContext): void { const itemBeforeUpdate = db.get(normalizedId); const oldPriority = (itemBeforeUpdate?.priority) as WorkItemPriority | undefined; // CAS claim path (RCA WL-0MSRBFFLN005W3VT design point 1): when - // --if-status/--if-stage are provided the update applies ONLY if the - // item still matches — the check and the write run in one immediate - // transaction so exactly one concurrent claimer wins. A guard - // mismatch fails per-id with error `stale` (no write); the herdr + // --if-status/--if-stage/--if-assignee are provided the update applies + // ONLY if the item still matches — the check and the write run in one + // immediate transaction so exactly one concurrent claimer wins. A + // guard mismatch fails per-id with error `stale` (no write); the herdr // downtime worker maps that to an aborted dispatch (no pane, no // marker, no success record). let item: WorkItem | null = null; - if (ifStatusCandidate !== undefined || ifStageCandidate !== undefined) { + if ( + ifStatusCandidate !== undefined || + ifStageCandidate !== undefined || + ifAssigneeCandidate !== undefined + ) { const cas = db.updateIfMatches(normalizedId, updates, { ...(ifStatusCandidate !== undefined ? { status: String(ifStatusCandidate) } : {}), ...(ifStageCandidate !== undefined ? { stage: String(ifStageCandidate) } : {}), + ...(ifAssigneeCandidate !== undefined + ? { assignee: ifAssigneeCandidate } + : {}), }); if (!cas.ok) { if (cas.reason === 'not-found') { @@ -488,10 +539,14 @@ export default function register(ctx: PluginContext): void { // already claimed or changed the item. Report a distinct error // code so callers can distinguish a lost race from a real CLI // failure. + const guards = [ + ifStatusCandidate !== undefined ? '--if-status' : null, + ifStageCandidate !== undefined ? '--if-stage' : null, + ifAssigneeCandidate !== undefined ? '--if-assignee' : null, + ].filter(Boolean).join('/'); const message = `Conditional update skipped: ${normalizedId} no longer matches ` + - `--if-status${ifStageCandidate !== undefined ? '/--if-stage' : ''} ` + - `(another process may have claimed it)`; + `${guards} (another process may have claimed it)`; results.push({ id: normalizedId, success: false, error: 'stale', message }); } continue; diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 843664471..aa8c0616e 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -25,14 +25,15 @@ const MIGRATIONS: Array<{ id: string; description: string; safe: boolean; requir id: '20260210-add-needsProducerReview', description: 'Add needsProducerReview INTEGER column to workitems (default 0)', safe: true, - requiredColumn: 'needsProducerReview', + requiredColumn: '__meta:needsProducerReview_added', apply: (db: Database.Database) => { - const cols = db.prepare(`PRAGMA table_info('workitems')`).all() as any[]; - const existingCols = new Set(cols.map(c => String(c.name))); - if (!existingCols.has('needsProducerReview')) { - // Idempotent add column - db.exec(`ALTER TABLE workitems ADD COLUMN needsProducerReview INTEGER NOT NULL DEFAULT 0`); - } + // Historical migration — preserved for history (WL-0MV85WFYU0071YQ9). + // The column has been dropped by 20261015-drop-needsProducerReview; + // this entry must never re-add it, so it only records completion via a + // metadata sentinel (mirrors the add-audit migration's noop pattern). + try { + db.prepare('INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)').run('needsProducerReview_added', '1'); + } catch (_e) { /* best-effort */ } } }, { @@ -200,6 +201,46 @@ const MIGRATIONS: Array<{ id: string; description: string; safe: boolean; requir db.exec(`ALTER TABLE workitems ADD COLUMN auditWaiver TEXT`); } } + }, + { + id: '20261015-drop-needsProducerReview', + description: 'Legacy: Drop needsProducerReview column (reverted — the flag was restored by 20261016-restore-needsProducerReview)', + safe: false, + requiredColumn: '__meta:needsProducerReview_dropped', + apply: (db: Database.Database) => { + // NEUTRALISED (WL-0MVAMS4RO000H3B0 F2): the removal was a wrongful + // deletion (RCA WL-0MVAOYBZM0065AEN); the flag is restored by + // 20261016-restore-needsProducerReview. This historical entry must + // never drop the column again, so it only records completion via a + // metadata sentinel (mirrors the add-audit noop pattern). Existing + // databases that already applied the original drop keep the sentinel + // and the restore migration re-adds the column. + try { + db.prepare('INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)').run('needsProducerReview_dropped', '1'); + } catch (_e) { /* best-effort */ } + } + }, + { + id: '20261016-restore-needsProducerReview', + description: 'Restore needsProducerReview column (re-add after the reverted removal) and convert producer-hold assignees (WL-0MVAMS4RO000H3B0 F2)', + safe: true, + requiredColumn: 'needsProducerReview', + apply: (db: Database.Database) => { + // Re-add the column when it is missing (fresh databases get it from the + // persistent-store schema; already-migrated databases lost it to the + // now-neutralised drop migration). Idempotent. + const cols = db.prepare(`PRAGMA table_info('workitems')`).all() as any[]; + const existingCols = new Set(cols.map(c => String(c.name))); + if (!existingCols.has('needsProducerReview')) { + db.exec(`ALTER TABLE workitems ADD COLUMN needsProducerReview INTEGER NOT NULL DEFAULT 0`); + } + // Q3=A conversion: any surviving producer-hold item (assignee 'Map', + // the retired assignee-hold convention) is marked as needing producer + // review so the review queue is not silently emptied by the restore. + try { + db.exec(`UPDATE workitems SET needsProducerReview = 1 WHERE trim(assignee) = 'Map' AND needsProducerReview = 0`); + } catch (_e) { /* best-effort: columns may be absent on partial schemas */ } + } } ]; diff --git a/src/sync.ts b/src/sync.ts index ee90b6a1c..42e7c4fa9 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -123,10 +123,17 @@ export interface MergeOptions { * defaults. The regression guard uses a broader definition so that * `status: open`, `stage: idea`, `priority: medium` are recognised as defaults * that signal data loss when they overwrite local non-default values. + * + * `assignee` is intentionally NOT a regression-guarded field + * (WL-0MV85WBZ2004VDUQ, epic Q1 option (a)). It is a scheduling hold whose + * clear must propagate cross-root, so an empty value is treated as meaningful + * intent and resolved by timestamp rather than refused as a regression. */ export function isRegressionDefault(value: unknown, field: string): boolean { if (value === undefined || value === '') { - return true; + // `assignee` is a scheduling hold whose clear is meaningful intent + // (WL-0MV85WBZ2004VDUQ, Q1 option (a)) — empty is never a regression. + return field !== 'assignee'; } const str = String(value); switch (field) { @@ -152,6 +159,24 @@ export function isValueRegression(localValue: unknown, remoteValue: unknown, fie return !isRegressionDefault(localValue, field) && isRegressionDefault(remoteValue, field); } +/** + * Merge fields whose empty value carries intent rather than signalling + * absence, so it must NOT take part in the "prefer non-default" heuristic. + * + * `assignee` is a scheduling hold (WL-0MV85WBZ2004VDUQ, epic Q1 option (a)): + * assigning an item holds it from auto-dispatch and clearing the assignee + * releases it. If `''` were treated as a default, a newer remote clear would + * either be refused as a regression (old behaviour) or lose to a stale local + * non-empty value — both resurrect stale claims. Resolving such fields by + * timestamp/tie-break like any other field lets a newer clear win, so it + * propagates cross-root without `--accept-regressions`. + */ +const EMPTY_IS_MEANINGFUL_FIELDS: ReadonlySet<string> = new Set(['assignee']); + +/** True when `field`'s empty value is meaningful (never treated as a default). */ +export function isEmptyMeaningfulField(field: string): boolean { + return EMPTY_IS_MEANINGFUL_FIELDS.has(field); +} /** * Merge two sets of work items with intelligent field-level conflict resolution @@ -603,8 +628,8 @@ function mergeSameTimestampItems( continue; } - const localIsDefault = isDefaultValue(localValue, field, options); - const remoteIsDefault = isDefaultValue(remoteValue, field, options); + const localIsDefault = !isEmptyMeaningfulField(field as string) && isDefaultValue(localValue, field, options); + const remoteIsDefault = !isEmptyMeaningfulField(field as string) && isDefaultValue(remoteValue, field, options); // 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, @@ -733,10 +758,10 @@ function mergeSameTimestampItems( // ── Regression guard (WL-0MSOYWZTH003JFVD) ────────────────────── // Detect when a merge-participating field regresses from a local // non-default value to a remote default (e.g. status: completed → open, - // stage: done → idea, priority: high → medium, assignee: alice → ''). - // The guard refuses the import and reports the conflict unless - // acceptRegressions is true. - const mergeParticipatingFields = ['status', 'stage', 'priority', 'assignee']; + // stage: done → idea, priority: high → medium). The guard refuses the + // import and reports the conflict unless acceptRegressions is true. + // `assignee` is deliberately excluded (WL-0MV85WBZ2004VDUQ, Q1 option (a)). + const mergeParticipatingFields = ['status', 'stage', 'priority']; if (mergeParticipatingFields.includes(field as string) && isValueRegression(localValue, remoteValue, field)) { const acceptRegressions = options?.acceptRegressions ?? false; if (!acceptRegressions) { @@ -861,8 +886,8 @@ function mergeDifferentTimestampItems( } if (!valuesEqual) { - const localIsDefault = isDefaultValue(localValue, field, options); - const remoteIsDefault = isDefaultValue(remoteValue, field, options); + const localIsDefault = !isEmptyMeaningfulField(field as string) && isDefaultValue(localValue, field, options); + const remoteIsDefault = !isEmptyMeaningfulField(field as string) && isDefaultValue(remoteValue, field, options); if (field === 'tags') { const mergedTags = mergeTags(localValue as string[] | undefined, remoteValue as string[] | undefined); @@ -1041,7 +1066,8 @@ function mergeDifferentTimestampItems( } // ── Regression guard (WL-0MSOYWZTH003JFVD) ───────────────────── - const mergeParticipatingFieldsDiff = ['status', 'stage', 'priority', 'assignee']; + // `assignee` is deliberately excluded (WL-0MV85WBZ2004VDUQ, Q1 option (a)). + const mergeParticipatingFieldsDiff = ['status', 'stage', 'priority']; if (mergeParticipatingFieldsDiff.includes(field as string) && isValueRegression(localValue, remoteValue, field)) { const acceptRegressionsDiff = options?.acceptRegressions ?? false; if (!acceptRegressionsDiff) { diff --git a/src/theme.ts b/src/theme.ts index 7c2989b52..7f4943a17 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -51,7 +51,7 @@ export interface IconOptions { const PRIORITY_ICON: Record<string, string> = { critical: '\u{1F6A8}', // 🚨 Rotating light - urgent/danger high: '\u{2B50}', // ⭐ Star - important - medium: '\u{1F4CB}', // 📋 Clipboard - standard task + medium: '\u{1F7E8}', // 🟨 Yellow square - standard task low: '\u{1F422}', // 🐢 Turtle - slow/low priority }; @@ -258,20 +258,6 @@ const EFFORT_LABEL: Record<string, string> = { xlarge: 'Effort: XL (extra large)', }; -// ─── Epic Icons ────────────────────────────────────────────────────────── - -const EPIC_ICON: Record<string, string> = { - epic: '\u{1F3F0}', // 🏰 Castle - large feature with dependencies -}; - -const EPIC_FALLBACK: Record<string, string> = { - epic: '[EPIC]', -}; - -const EPIC_LABEL: Record<string, string> = { - epic: 'Issue Type: Epic', -}; - // ─── Stage Icons ─────────────────────────────────────────────────────── const STAGE_ICON: Record<string, string> = { @@ -601,39 +587,3 @@ export function needsProducerReviewLabel(needsProducerReview: boolean | null | u export function needsProducerReviewFallback(needsProducerReview: boolean | null | undefined): string { return PRODUCER_REVIEW_FALLBACK[producerReviewKey(needsProducerReview)] ?? ''; } - -// ─── Epic Public API ──────────────────────────────────────────────────── - -/** - * Get the icon string (emoji or text fallback) for an epic work item. - * - * Epic icon: 🏰 (castle) — represents a large feature with dependencies. - * Fallback: [EPIC] - * - * @param opts - Options controlling fallback behaviour. - * @returns The icon string (emoji or bracketed text). - */ -export function epicIcon(opts?: IconOptions): string { - if (opts?.noIcons === true) { - return EPIC_FALLBACK.epic; - } - return EPIC_ICON.epic; -} - -/** - * Get the accessible label for the epic icon. - * - * @returns A human-readable label ("Issue Type: Epic"). - */ -export function epicLabel(): string { - return EPIC_LABEL.epic; -} - -/** - * Get the text fallback for the epic icon. - * - * @returns The bracketed text label ("[EPIC]"). - */ -export function epicFallback(): string { - return EPIC_FALLBACK.epic; -} diff --git a/src/version.ts b/src/version.ts index ff0baaba8..04345ea35 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ // Auto-generated; do not edit. -export const WORKLOG_VERSION = '1.0.20'; +export const WORKLOG_VERSION = '1.0.22'; diff --git a/standups/2026_10_14.md b/standups/2026_10_14.md new file mode 100644 index 000000000..11a78ab7d --- /dev/null +++ b/standups/2026_10_14.md @@ -0,0 +1,29 @@ +## ContextHub Standup Report (14th October 2026) + +### Yesterday I completed work on... + + - Herdr selection list: base the in-progress marker on work-item tab existence — As a Herdr operator, I want the selection list's in-progress marker to reflect + - Test: drag-to-scroll unit tests — Vitest additions covering drag-to-scroll in the Herdr worklist list and detail views, written before implementation (test-first). Covers parent ACs 1–6: threshold dead-zone, list drag rows + selection + - Implement: drag state, pure mapper and list scroll — Add the drag-detection state, the pure drag mapper and a selection-preserving list scroll method — the core logic slice for WL-0MSI72S0Y0060BRK, covered by the test-first suite (C1). + - Implement: drag dispatch and hover/drag split — Thread the drag tracker through `handleMouseInput`, dispatch drag actions (restoring the pre-press selection on activation), and split the motion branch of `mapMouseToAction` into hover (button-less) + - Docs, build and verification — Update the user and architecture documentation, rebuild the herdr package (dist sync) and verify the full project suite — the closing slice for WL-0MSI72S0Y0060BRK. + - Follow-up: drag-to-scroll in Herdr worklist — As a Herdr user, I want to drag the list up/down with my finger or pointer so the visible window follows my movement and the selection stays where it was. + +### Open critical items + + - Option A: status-neutral assignee CAS claim for audit tier + remove needsProducerReview (failed audits keep item completed/in_review) — When the herdr downtime worker auto-dispatches an audit of a completed/in_review item, its CAS claim flips `status` to `in_progress` while leaving the stage at `in_review`, creating a hybrid `in-progr + - Herdr downtime audit claim leaves item in-progress/in_review (invalid combo); failed audit coerces to in-progress/plan_complete instead of completed/in_review — As an operator, when herdr auto-dispatches an audit of a `completed`/`in_review` item and the audit fails, the item must remain `completed`/`in_review` so a re-run or manual review can still proceed. + +### Additional focus items for today + + - Don't open pane for recording reviews — As a reviewer, when I press `a-r` and enter a rejection reason, the command runs in the background (no pane opens), the item is rejected, and the list refreshes. + - Finish-gate live-repo guard trips on concurrent dev ref movement in multi-agent repos — As an **implementing agent**, I want the finish-gate full-suite run to stay green while sibling agents advance `dev`, fetch/push to `origin`, or move their own branch refs, so that concurrent activity + - Migrate downtime-worker tests from toHaveBeenCalledWith to observable-outcome assertions — Parent **WL-0MV28X3SL005R5KX** AC4 required assertions to prefer observable + +### Regressions + + - No regressions — no items slipped back from completed. + +### Blockers + + - Blocked on Herdr downtime audit claim leaves item in-progress/in_review (invalid combo); failed audit coerces to in-progress/plan_complete instead of completed/in_review, because its status is blocked \ No newline at end of file diff --git a/tests/README.md b/tests/README.md index 2ab4df8d0..6de16da5b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -78,7 +78,6 @@ Tests live under the top-level `tests/` directory: - **`fresh-install.test.ts`** — Clean install scenario - **`update-batch.test.ts`** — Batch update operations - **`update-do-not-delegate.test.ts`** — Do-not-delegate flag handling -- **`reviewed.test.ts`** — `wl reviewed` toggle - **`misc.test.ts`** — Miscellaneous CLI edge cases - **`helpers-tree-rendering.test.ts`** — Tree display formatting - **`action-opts-normalization.test.ts`** — Option normalization @@ -124,7 +123,7 @@ different layers — they are not duplicates: `src/theme.ts` (a distinct module from `@worklog/shared/icons`). - **`tests/herdr/icons.test.ts`** — herdr display integration tests for the shared `@worklog/shared/icons` functions (status/stage/priority/audit/epic/ - risk/effort/needsProducerReview icons, `stageColor`, general + risk/effort icons, `stageColor`, general `getIconPrefix`). - **`packages/herdr/src/icons.test.ts`** — herdr package unit tests for the shared `@worklog/shared/icons` `iconsEnabled` flag, fixed-width agent-slot diff --git a/tests/api-needs-producer-review-filter.test.ts b/tests/api-needs-producer-review-filter.test.ts index a4effe5a4..838a1ea12 100644 --- a/tests/api-needs-producer-review-filter.test.ts +++ b/tests/api-needs-producer-review-filter.test.ts @@ -1,11 +1,11 @@ /** * API-level filtering of work items by the `needsProducerReview` flag. * - * Migrated from the legacy `test/validator.test.ts` (WL-0MV4UUK120029UR5, - * child of WL-0MV28X3BT0079JM3). The only genuinely unique coverage in that - * legacy file was the REST API `needsProducerReview` filter and its HTTP 400 - * response for an invalid value; the CLI-documentation validator assertion it - * also contained remains covered by `test/validator.test.ts`. + * Test-first contract (item WL-0MVAMS4RO000H3B0, child F1): restored from + * the deleted corpus (`e0f04202^`). RED-pinned via `it.fails` so the + * configured suite stays green while the contract is failing; the + * implementation children (F2 flag + F3 CLI/API) flip `it.fails(` → `it(` + * when the flag, the DB column and the API query param are restored. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; @@ -50,7 +50,7 @@ describe('API needsProducerReview filter', () => { } } - it('filters items by needsProducerReview', async () => { + it('filters items by needsProducerReview (F3 restored)', async () => { const needsReview = db.create({ title: 'Needs review', needsProducerReview: true }); const noReview = db.create({ title: 'No review', needsProducerReview: false }); @@ -63,7 +63,7 @@ describe('API needsProducerReview filter', () => { expect(resFalse.body.map((i: any) => i.id)).toEqual([noReview.id]); }); - it('rejects invalid needsProducerReview values', async () => { + it('rejects invalid needsProducerReview values (F3 restored)', async () => { const res = await getItems('/items?needsProducerReview=maybe'); expect(res.status).toBe(400); expect(res.body.error).toContain('Invalid'); diff --git a/tests/cli/__snapshots__/human-show-list-audit-snapshots.test.ts.snap b/tests/cli/__snapshots__/human-show-list-audit-snapshots.test.ts.snap index 3374ae90b..dbf0b9cd4 100644 --- a/tests/cli/__snapshots__/human-show-list-audit-snapshots.test.ts.snap +++ b/tests/cli/__snapshots__/human-show-list-audit-snapshots.test.ts.snap @@ -9,7 +9,7 @@ exports[`Human snapshots: show and list outputs with audit > renders concise/lis | Field | Value | | --------- | ----- | | ID | TEST-1 | -| Status | 🔓 Open [OPEN] · Stage: Undefined \\| Priority: 📋 medium [MED ] | +| Status | 🔓 Open [OPEN] · Stage: Undefined \\| Priority: 🟨 medium [MED ] | | Type | unknown | | SortIndex | 0 | | Risk | — | @@ -20,7 +20,7 @@ exports[`Human snapshots: show and list outputs with audit > renders concise/lis | Field | Value | | --------- | ----- | | ID | TEST-2 | -| Status | 🔓 Open [OPEN] · Stage: Undefined \\| Priority: 📋 medium [MED ] | +| Status | 🔓 Open [OPEN] · Stage: Undefined \\| Priority: 🟨 medium [MED ] | | Type | unknown | | SortIndex | 0 | | Risk | — | @@ -36,7 +36,7 @@ exports[`Human snapshots: show and list outputs with audit > renders concise/lis | Field | Value | | --------- | ----- | | ID | TEST-1 | - | Status | 🔓 Open [OPEN] · Stage: Undefined \\| Priority: 📋 medium [MED ] | + | Status | 🔓 Open [OPEN] · Stage: Undefined \\| Priority: 🟨 medium [MED ] | | Type | unknown | | SortIndex | 0 | | Risk | — | @@ -51,7 +51,7 @@ exports[`Human snapshots: show and list outputs with audit > renders concise/lis | Field | Value | | --------- | ----- | | ID | TEST-2 | - | Status | 🔓 Open [OPEN] · Stage: Undefined \\| Priority: 📋 medium [MED ] | + | Status | 🔓 Open [OPEN] · Stage: Undefined \\| Priority: 🟨 medium [MED ] | | Type | unknown | | SortIndex | 0 | | Risk | — | diff --git a/tests/cli/audit-waive.test.ts b/tests/cli/audit-waive.test.ts index 5e5e60197..8a32337a2 100644 --- a/tests/cli/audit-waive.test.ts +++ b/tests/cli/audit-waive.test.ts @@ -123,8 +123,7 @@ describe('audit-waive / audit-unwaive', () => { deleteReason: '', risk: '', effort: '', - needsProducerReview: false, - auditWaiver: { reason: 'round-trip me', author: 'producer', waivedAt }, + auditWaiver: { reason: 'round-trip me', author: 'producer', waivedAt }, }; exportToJsonl([item], [], file, [], []); diff --git a/tests/cli/cli-helpers.ts b/tests/cli/cli-helpers.ts index b3d6eeb6b..f2acbc2d2 100644 --- a/tests/cli/cli-helpers.ts +++ b/tests/cli/cli-helpers.ts @@ -425,7 +425,6 @@ export function seedWorkItems( tags?: string[]; assignee?: string; stage?: string; - needsProducerReview?: boolean; githubIssueNumber?: number; githubIssueId?: number; githubIssueUpdatedAt?: string; @@ -463,7 +462,6 @@ export function seedWorkItems( githubIssueNumber: item.githubIssueNumber, githubIssueId: item.githubIssueId, githubIssueUpdatedAt: item.githubIssueUpdatedAt, - needsProducerReview: item.needsProducerReview ?? false, auditWaiver: item.auditWaiver ?? null, audit: item.audit, })); diff --git a/tests/cli/cli-inproc.ts b/tests/cli/cli-inproc.ts index 8f2ae3bda..d43013a75 100644 --- a/tests/cli/cli-inproc.ts +++ b/tests/cli/cli-inproc.ts @@ -39,7 +39,6 @@ import commentCommand from '../../src/commands/comment.js'; import closeCommand from '../../src/commands/close.js'; import recentCommand from '../../src/commands/recent.js'; import pluginsCommand from '../../src/commands/plugins.js'; -import reviewedCommand from '../../src/commands/reviewed.js'; import migrateCommand from '../../src/commands/migrate.js'; import depCommand from '../../src/commands/dep.js'; import reSortCommand from '../../src/commands/re-sort.js'; @@ -69,7 +68,6 @@ const builtInCommands = [ closeCommand, recentCommand, pluginsCommand, - reviewedCommand, migrateCommand, depCommand, reSortCommand, diff --git a/tests/cli/completion.test.ts b/tests/cli/completion.test.ts index 48eeaaba8..04c1c8aa4 100644 --- a/tests/cli/completion.test.ts +++ b/tests/cli/completion.test.ts @@ -17,7 +17,7 @@ const KNOWN_COMMANDS = [ 'init', 'status', 'create', 'list', 'show', 'update', 'delete', 'export', 'import', 'next', 'in-progress', 'sync', 'github', 'comment', 'close', 'recent', 'plugins', 'migrate', 'dep', - 're-sort', 'doctor', 'reviewed', 'search', 'unlock', 'audit', + 're-sort', 'doctor', 'search', 'unlock', 'audit', 'completion', ]; diff --git a/tests/cli/delegate-guard-rails.test.ts b/tests/cli/delegate-guard-rails.test.ts index 3964ae8ca..bdb834e1b 100644 --- a/tests/cli/delegate-guard-rails.test.ts +++ b/tests/cli/delegate-guard-rails.test.ts @@ -129,7 +129,6 @@ function createDelegateTestContext() { deleteReason: '', risk: '', effort: '', - needsProducerReview: false, githubIssueNumber: overrides.githubIssueNumber, ...overrides, }; diff --git a/tests/cli/doctor-upgrade.test.ts b/tests/cli/doctor-upgrade.test.ts index 81bb0bcfe..4f09e213c 100644 --- a/tests/cli/doctor-upgrade.test.ts +++ b/tests/cli/doctor-upgrade.test.ts @@ -110,6 +110,10 @@ describe('doctor upgrade command', () => { const cols = db.prepare(`PRAGMA table_info('workitems')`).all() as Array<{ name: string }>; // After all migrations, audit column should be dropped in favor of audit_results table expect(cols.map(c => c.name)).not.toContain('audit'); + // needsProducerReview is RESTORED (WL-0MVAMS4RO000H3B0 F2): the wrongful + // removal (WL-0MV85WFYU0071YQ9 / RCA WL-0MVAOYBZM0065AEN) was reverted; + // the column must be present after migrating. + expect(cols.map(c => c.name)).toContain('needsProducerReview'); // audit_results table should exist const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='audit_results'").all() as any[]; expect(tables.length).toBe(1); diff --git a/tests/cli/fields-integration.test.ts b/tests/cli/fields-integration.test.ts index 0d9395d04..ba96c2dcb 100644 --- a/tests/cli/fields-integration.test.ts +++ b/tests/cli/fields-integration.test.ts @@ -47,7 +47,7 @@ describe('--fields end-to-end integration', () => { expect(listErr![1]).toBe(searchErr![1]); // The vocabulary lists all fields EXCEPT id (id is always included, // so it is not mentioned in the error message). - const expectedFields = ['title', 'description', 'status', 'stage', 'priority', 'issueType', 'assignee', 'tags', 'createdAt', 'updatedAt', 'parentId', 'needsProducerReview', 'sortIndex']; + const expectedFields = ['title', 'description', 'status', 'stage', 'priority', 'issueType', 'assignee', 'tags', 'createdAt', 'updatedAt', 'parentId', 'sortIndex']; for (const f of expectedFields) { expect(listErr![1]).toContain(f); } diff --git a/tests/cli/interview.test.ts b/tests/cli/interview.test.ts index d2df41389..b4f3e56fe 100644 --- a/tests/cli/interview.test.ts +++ b/tests/cli/interview.test.ts @@ -21,15 +21,7 @@ import registerInterview, { rebuildQAPairs, rebuildDescription, runInterview, - buildProducerReviewExplanation, - buildProducerReviewExplanationResult, tryExtractQuestionsWithLlm, - buildExplanationPrompt, - buildFallbackExplanation, - renderExplanation, - PRODUCER_REVIEW_INSTRUCTION, - MAX_PROMPT_CONTEXT_BYTES, - MAX_AUDIT_RAW_OUTPUT_BYTES, parseExtractedQuestions, buildExtractionPrompt, EXTRACTION_TIMEOUT_MS, @@ -481,14 +473,13 @@ function makeStore(item: any) { }; } -function makeItem(description: string, needsProducerReview = true) { +function makeItem(description: string) { return { id: 'WL-TEST-1', title: 'Sample', description, status: 'open', stage: 'intake_complete', - needsProducerReview, } as any; } @@ -500,7 +491,6 @@ describe('runInterview', () => { }); expect(outcome.noSection).toBe(true); expect(store.current().description).toContain('No clarifying section.'); - expect(store.current().needsProducerReview).toBe(true); }); it('reports no-questions for a prose-only appendix', async () => { @@ -517,28 +507,28 @@ No clarifying questions were asked; the brief was sufficient.`; expect(store.current().description).toBe(desc); }); - it('clears the review flag when every question is already answered', async () => { + it('reports all-answered for a fully answered appendix', async () => { const desc = `# Task ## Appendix: Clarifying questions - Q: "Scope?" — Answer (user): "Repo-wide". Source: reply.`; - const store = makeStore(makeItem(desc, true)); + const store = makeStore(makeItem(desc)); const outcome = await runInterview(store.current(), store, { prompt: async () => 'unused', }); expect(outcome.allAnswered).toBe(true); expect(outcome.recorded).toBe(0); - expect(store.current().needsProducerReview).toBe(false); + expect(store.current().description).toContain('Repo-wide'); }); - it('records a bullet-format answer, preserving Source, and clears the flag', async () => { + it('records a bullet-format answer, preserving Source', async () => { const desc = `# Task ## Appendix: Clarifying questions - Q: "Primary user?" — Answer (user): *(awaiting producer)*. Source: interactive reply.`; - const store = makeStore(makeItem(desc, true)); + const store = makeStore(makeItem(desc)); const prompts: string[] = []; const outcome = await runInterview(store.current(), store, { prompt: async (m: string) => { @@ -554,7 +544,6 @@ No clarifying questions were asked; the brief was sufficient.`; expect(updated.description).toContain('Internal engineers'); expect(updated.description).toContain('Source: interactive reply.'); expect(updated.description).not.toContain('awaiting producer'); - expect(updated.needsProducerReview).toBe(false); }); it('answers an inline placeholder inside its surrounding markup', async () => { @@ -563,7 +552,7 @@ No clarifying questions were asked; the brief was sufficient.`; - **Q:** "Where?" **Answer:** *(awaiting producer)* **Source:** reply.`; - const store = makeStore(makeItem(desc, true)); + const store = makeStore(makeItem(desc)); const outcome = await runInterview(store.current(), store, { prompt: async () => 'Acme org', }); @@ -574,12 +563,12 @@ No clarifying questions were asked; the brief was sufficient.`; expect(updated.description).not.toContain('awaiting producer'); }); - it('persists partial progress and keeps the flag when interrupted', async () => { + it('persists partial progress when interrupted', async () => { const desc = `## Appendix: Clarifying questions - Q: "One?" — Answer: *(awaiting producer)*. Source: reply. - Q: "Two?" — Answer: *(awaiting producer)*. Source: reply.`; - const store = makeStore(makeItem(desc, true)); + const store = makeStore(makeItem(desc)); const answers = ['A1', '']; const outcome = await runInterview(store.current(), store, { prompt: async () => answers.shift() ?? '', @@ -591,28 +580,26 @@ No clarifying questions were asked; the brief was sufficient.`; // The first answer is persisted for resume; the second stays outstanding. expect(updated.description).toContain('A1'); expect(updated.description).toContain('awaiting producer'); - expect(updated.needsProducerReview).toBe(true); }); - it('leaves the review flag set when no question is answered', async () => { + it('records nothing when no answer is given', async () => { const desc = `## Appendix: Clarifying questions - Q: "One?" — Answer: *(awaiting producer)*. Source: reply. - Q: "Two?" — Answer: *(awaiting producer)*. Source: reply.`; - const store = makeStore(makeItem(desc, true)); + const store = makeStore(makeItem(desc)); const outcome = await runInterview(store.current(), store, { prompt: async () => '', }); expect(outcome.recorded).toBe(0); expect(outcome.allAnswered).toBe(false); - expect(store.current().needsProducerReview).toBe(true); }); it('appends an answer scaffold when no marker is present', async () => { const desc = `## Clarifying questions - **Q:** A question with no recorded answer marker?`; - const store = makeStore(makeItem(desc, true)); + const store = makeStore(makeItem(desc)); const outcome = await runInterview(store.current(), store, { prompt: async () => 'Yes', }); @@ -620,7 +607,6 @@ No clarifying questions were asked; the brief was sufficient.`; const updated = store.current(); expect(updated.description).toContain('A question with no recorded answer marker?'); expect(updated.description).toContain('Yes'); - expect(updated.needsProducerReview).toBe(false); }); it('prompts with the full multi-line question and answers an OPEN QUESTION placeholder (WL-0MUKCGV3X0030W6K)', async () => { @@ -630,7 +616,7 @@ No clarifying questions were asked; the brief was sufficient.`; collisions) or something else? — **OPEN QUESTION**, context: not yet answered. Interim assumption: destroy + reuse the shared player-hit lifecycle. Source: wl comment \`AH-C0MUB8JD13004UD9E\`.`; - const store = makeStore(makeItem(desc, true)); + const store = makeStore(makeItem(desc)); const prompts: string[] = []; const outcome = await runInterview(store.current(), store, { prompt: async (m: string) => { @@ -649,7 +635,6 @@ No clarifying questions were asked; the brief was sufficient.`; expect(updated.description).not.toContain('OPEN QUESTION'); // Metadata after the placeholder is preserved for the audit trail. expect(updated.description).toContain('Source: wl comment'); - expect(updated.needsProducerReview).toBe(false); }); }); @@ -697,14 +682,13 @@ This task has interview questions. `.trim(); const id = ctx.utils.createSampleItem({}); - ctx.utils.db.update(id, { description: desc, needsProducerReview: true }); + ctx.utils.db.update(id, { description: desc }); await ctx.runCli(['interview', id]); const updated = ctx.utils.db.get(id); expect(updated).not.toBeNull(); expect(updated!.description).toContain('JSON format.'); - expect(updated!.needsProducerReview).toBe(false); }); it('handles a clarifying section with no body content', async () => { @@ -740,15 +724,6 @@ function comment(author: string, body: string): any { }; } -function flaggedItem(description: string, flagged = true): any { - return { - id: 'WL-TEST-1', - title: 'Sample', - description, - needsProducerReview: flagged, - } as any; -} - /** A chat client stub whose `complete` returns `response` (or throws). */ function fakeChatClient(response: string | Error): any { return { @@ -760,301 +735,6 @@ function fakeChatClient(response: string | Error): any { }; } -describe('buildProducerReviewExplanation', () => { - const comments = [ - comment('bob', 'Second comment\nwith a second line'), - comment('alice', 'First comment'), - ]; - - it('returns null when the item is not flagged for producer review', async () => { - const result = await buildProducerReviewExplanation(flaggedItem('desc', false), { - comments, - chatClient: fakeChatClient('unused'), - }); - expect(result).toBeNull(); - }); - - it('preserves intentional line breaks in the LLM explanation (AC3)', async () => { - const client = fakeChatClient('Reason: AC3 unmet\nAction: fix rendering'); - const result = await buildProducerReviewExplanation(flaggedItem('desc'), { - comments, - chatClient: client, - }); - expect(result).toBe('Reason: AC3 unmet\nAction: fix rendering'); - expect(result!.split('\n').length).toBeGreaterThan(1); - expect(client.complete).toHaveBeenCalledTimes(1); - }); - - it('skips the LLM and uses the two most recent comments when --no-llm', async () => { - const client = fakeChatClient('unused'); - const result = await buildProducerReviewExplanation(flaggedItem('desc'), { - comments, - chatClient: client, - noLlm: true, - }); - expect(client.complete).not.toHaveBeenCalled(); - expect(result).toContain('bob: Second comment'); - expect(result).toContain('alice: First comment'); - }); - - it('falls back silently when the LLM call fails', async () => { - const client = fakeChatClient(new Error('network unavailable')); - const result = await buildProducerReviewExplanation(flaggedItem('desc'), { - comments, - chatClient: client, - }); - expect(client.complete).toHaveBeenCalledTimes(1); - expect(result).toContain('bob: Second comment'); - }); - - it('falls back without calling an unavailable client', async () => { - const client = { available: false, complete: vi.fn() }; - const result = await buildProducerReviewExplanation(flaggedItem('desc'), { - comments, - chatClient: client as any, - }); - expect(client.complete).not.toHaveBeenCalled(); - expect(result).toContain('alice: First comment'); - }); - - it('uses a generic actionable fallback when there are no comments', async () => { - const result = await buildProducerReviewExplanation(flaggedItem('desc'), { - comments: [], - noLlm: true, - }); - expect(result).toContain('WL-TEST-1'); - }); - - it('builds the prompt from the instruction and the two most recent comments', async () => { - const client = fakeChatClient('ok'); - const older = comment('old', 'Ancient history'); - await buildProducerReviewExplanation(flaggedItem('desc'), { - comments: [...comments, older], - chatClient: client, - }); - const prompt = client.complete.mock.calls[0][0] as string; - expect(prompt).toContain(PRODUCER_REVIEW_INSTRUCTION); - expect(prompt).toContain('alice: First comment'); - expect(prompt).toContain('bob: Second comment'); - expect(prompt).not.toContain('Ancient history'); - }); - - it('prompt instruction asks for the reason ("why") as well as the action (AC1)', () => { - const instruction = PRODUCER_REVIEW_INSTRUCTION.toLowerCase(); - expect(instruction).toContain('why'); - expect(instruction).toContain('remove the needsproducerreview flag'); - }); - - it('names the audit reason when the LLM path is disabled (AC1)', async () => { - const auditResult = { - readyToClose: false, - auditedAt: '2026-10-04T21:00:00.000Z', - author: 'audit-runner', - summary: 'AC1 unmet: explanation does not state why', - }; - const result = await buildProducerReviewExplanation(flaggedItem('desc'), { - comments: [], - auditResult, - noLlm: true, - }); - expect(result).toContain('not ready to close'); - expect(result).toContain('AC1 unmet: explanation does not state why'); - }); - - it('surfaces audit evidence in the prompt and preserves multi-line rendering', async () => { - const auditResult = { - readyToClose: false, - auditedAt: '2026-10-04T21:00:00.000Z', - author: 'audit-runner', - summary: 'AC3 unmet: rendering still truncates', - rawOutput: 'VERDICT: not ready\nAC3: unmet', - }; - const client = fakeChatClient('Because AC3 failed\nFix the renderer'); - const result = await buildProducerReviewExplanation(flaggedItem('desc'), { - comments, - auditResult, - chatClient: client, - }); - const prompt = client.complete.mock.calls[0][0] as string; - expect(prompt).toContain(auditResult.summary); - expect(result).toBe('Because AC3 failed\nFix the renderer'); - }); - - describe('renderExplanation (AC3)', () => { - it('preserves line breaks but collapses runs of spaces', () => { - const rendered = renderExplanation('Line one\n\nLine two with spaces'); - expect(rendered).toBe('Line one\n\nLine two with spaces'); - expect(rendered.split('\n').length).toBeGreaterThan(1); - }); - - it('bounds the explanation by length', () => { - const bounded = renderExplanation('x'.repeat(5000), 100, 50); - expect(bounded.length).toBeLessThanOrEqual(100); - }); - - it('bounds the explanation by line count', () => { - const many = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n'); - const bounded = renderExplanation(many, 5000, 10); - expect(bounded.split('\n').length).toBeLessThanOrEqual(10); - expect(bounded.length).toBeLessThanOrEqual(5000); - }); - - it('marks truncation with an ellipsis', () => { - const bounded = renderExplanation('x'.repeat(5000), 50, 50); - expect(bounded.endsWith('…')).toBe(true); - }); - - it('applies the raised default bounds (8192 chars / 512 lines)', () => { - // Single long line: the default character cap is 8192. - const long = renderExplanation('x'.repeat(10000)); - expect(long.length).toBeGreaterThan(8000); - expect(long.length).toBeLessThanOrEqual(8192); - - // Many short lines: the default line cap is 512, well above the old 24. - const manyLines = Array.from({ length: 600 }, (_, i) => `line ${i}`).join('\n'); - const lineBounded = renderExplanation(manyLines); - expect(lineBounded.split('\n').length).toBeGreaterThan(100); - expect(lineBounded.split('\n').length).toBeLessThanOrEqual(512); - }); - }); - - describe('buildFallbackExplanation structured signals (AC4)', () => { - const auditResult = { - readyToClose: false, - auditedAt: '2026-10-04T21:00:00.000Z', - author: 'audit-runner', - summary: 'AC3 unmet: the explanation is still collapsed to one line', - rawOutput: 'VERDICT: not ready\nAC3: unmet', - }; - - it('reports the audit verdict and summary when an audit result exists (a)', () => { - const fallback = buildFallbackExplanation(flaggedItem('desc'), comments, auditResult); - expect(fallback).toContain('not ready to close'); - expect(fallback).toContain(auditResult.summary); - expect(fallback).not.toContain('bob: Second comment'); - }); - - it('reports a durable audit waiver when there is no audit result', () => { - const item = flaggedItem('desc') as any; - item.auditWaiver = { - reason: 'Audit gap deliberately accepted for a docs-only change', - author: 'producer', - waivedAt: '2026-10-04T21:00:00.000Z', - }; - const fallback = buildFallbackExplanation(item, comments, null); - expect(fallback).toContain('Audit gap waived by producer'); - expect(fallback).toContain('docs-only change'); - }); - - it('falls back to the comments when there is no audit evidence (b)', () => { - const fallback = buildFallbackExplanation(flaggedItem('desc'), comments, null); - expect(fallback).toBe('bob: Second comment\nalice: First comment'); - }); - - it('uses a generic actionable line as a last resort (c)', () => { - const fallback = buildFallbackExplanation(flaggedItem('desc'), [], null); - expect(fallback).toContain('WL-TEST-1'); - expect(fallback).toContain('No structured evidence'); - }); - }); - - describe('buildExplanationPrompt audit context (AC2)', () => { - const auditResult = { - readyToClose: false, - auditedAt: '2026-10-04T21:00:00.000Z', - author: 'audit-runner', - summary: 'AC3 unmet: rendering still truncates', - rawOutput: 'VERDICT: not ready\nAC3: unmet', - }; - - it('includes the audit summary as first-class prompt context', () => { - const prompt = buildExplanationPrompt(flaggedItem('desc'), comments, auditResult); - expect(prompt).toContain(auditResult.summary); - expect(prompt).toContain('Latest audit result:'); - expect(prompt).toContain('not ready to close'); - }); - - it('keeps the audit context ahead of a large description', () => { - const big = flaggedItem('z'.repeat(50000)); - const prompt = buildExplanationPrompt(big, [], auditResult); - expect(prompt).toContain(auditResult.summary); - }); - - it('bounds the total prompt context by MAX_PROMPT_CONTEXT_BYTES', () => { - const prefix = `${PRODUCER_REVIEW_INSTRUCTION}.\n\n`; - const prompt = buildExplanationPrompt( - flaggedItem('y'.repeat(50000)), - [comment('a', 'z'.repeat(50000))], - auditResult, - ); - expect(prompt.startsWith(prefix)).toBe(true); - const context = prompt.slice(prefix.length); - expect(Buffer.byteLength(context, 'utf8')).toBeLessThanOrEqual( - MAX_PROMPT_CONTEXT_BYTES, - ); - }); - - it('bounds the raw-output excerpt embedded in the prompt', () => { - const hugeRaw = { ...auditResult, rawOutput: 'r'.repeat(50000) }; - const prompt = buildExplanationPrompt(flaggedItem('desc'), [], hugeRaw); - const marker = 'Raw output (excerpt):'; - const rawIdx = prompt.indexOf(marker); - expect(rawIdx).toBeGreaterThan(-1); - const excerpt = prompt.slice(rawIdx + marker.length); - // The bound plus the small trailing description/comments sections. - expect(Buffer.byteLength(excerpt, 'utf8')).toBeLessThan( - MAX_AUDIT_RAW_OUTPUT_BYTES + 200, - ); - }); - }); -}); - -// ── Fallback signal (WL-0MUX2W8IN005RW66) ──────────────────────────────── - -describe('buildProducerReviewExplanationResult', () => { - const comments = [comment('alice', 'First comment')]; - - it('reports usedLlm true when the LLM returns an explanation', async () => { - const result = await buildProducerReviewExplanationResult(flaggedItem('desc'), { - comments, - chatClient: fakeChatClient('LLM reason'), - }); - expect(result).toEqual({ text: 'LLM reason', usedLlm: true }); - }); - - it('reports usedLlm false when the LLM throws (timeout/error)', async () => { - const result = await buildProducerReviewExplanationResult(flaggedItem('desc'), { - comments, - chatClient: fakeChatClient(new Error('Chat API request timed out')), - }); - expect(result?.usedLlm).toBe(false); - expect(result?.text).toBeTruthy(); - }); - - it('reports usedLlm false when the client is unavailable or disabled', async () => { - const unavailable = await buildProducerReviewExplanationResult(flaggedItem('desc'), { - comments, - chatClient: null, - }); - expect(unavailable?.usedLlm).toBe(false); - - const disabled = await buildProducerReviewExplanationResult(flaggedItem('desc'), { - comments, - chatClient: fakeChatClient('unused'), - noLlm: true, - }); - expect(disabled?.usedLlm).toBe(false); - }); - - it('returns null for an unflagged item', async () => { - const result = await buildProducerReviewExplanationResult(flaggedItem('desc', false), { - comments, - chatClient: fakeChatClient('unused'), - }); - expect(result).toBeNull(); - }); -}); - describe('tryExtractQuestionsWithLlm', () => { it('reports usedLlm true and the parsed questions on success', async () => { const chat = { @@ -1158,9 +838,9 @@ describe('runInterview LLM progress hooks', () => { }); describe('interview --json (non-interactive)', () => { - /** Register the command with a JSON-capturing output and injected comments. */ + /** Register the command with a JSON-capturing output. */ function setup(options: { - item?: { description?: string; needsProducerReview?: boolean }; + item?: { description?: string }; comments?: any[]; auditResult?: any; chatClient?: any; @@ -1176,7 +856,6 @@ describe('interview --json (non-interactive)', () => { ctx.utils.db.update(id, { description: options.item?.description ?? '# Task\n\nNo clarifying section here.', title: 'JSON task', - needsProducerReview: options.item?.needsProducerReview ?? true, }); const baseGetDatabase = ctx.utils.getDatabase; ctx.utils.getDatabase = (prefix?: string) => ({ @@ -1190,12 +869,9 @@ describe('interview --json (non-interactive)', () => { return { ctx, id, jsonOutput }; } - it('returns the explanation and question state without mutating the item', async () => { - const client = fakeChatClient('Clear the flag by doing X.'); + it('reports no-section state without mutating the item', async () => { const { ctx, id, jsonOutput } = setup({ - item: { description: '# Task\n\nNo section.', needsProducerReview: true }, - comments: [comment('producer', 'Please clarify scope.')], - chatClient: client, + item: { description: '# Task\n\nNo section.' }, }); await ctx.runCli(['interview', id, '--json']); @@ -1204,90 +880,14 @@ describe('interview --json (non-interactive)', () => { expect(jsonOutput[0]).toMatchObject({ success: true, workItemId: id, - needsProducerReview: true, - producerReviewExplanation: 'Clear the flag by doing X.', noSection: true, noQuestions: false, }); - // No mutation in JSON mode applies to the flag or the description. + // No mutation in JSON mode. const stored = ctx.utils.db.get(id); - expect(stored.needsProducerReview).toBe(true); expect(stored.description).toContain('No section.'); }); - it('uses the comment fallback with --no-llm and never calls the LLM', async () => { - const client = fakeChatClient('unused'); - const { ctx, id, jsonOutput } = setup({ - item: { description: '# Task\n\nNo section.', needsProducerReview: true }, - comments: [comment('producer', 'Please clarify scope.\nMore detail.')], - chatClient: client, - }); - - await ctx.runCli(['interview', id, '--json', '--no-llm']); - - expect(client.complete).not.toHaveBeenCalled(); - expect(jsonOutput[0].producerReviewExplanation).toBe('producer: Please clarify scope.'); - }); - - it('preserves a multi-line explanation and does not mutate the item (AC5)', async () => { - const client = fakeChatClient('Reason: AC3 unmet\nAction: fix rendering'); - const { ctx, id, jsonOutput } = setup({ - item: { description: '# Task\n\nNo section.', needsProducerReview: true }, - comments: [], - chatClient: client, - }); - - await ctx.runCli(['interview', id, '--json']); - - expect(jsonOutput).toHaveLength(1); - expect(jsonOutput[0].producerReviewExplanation).toBe( - 'Reason: AC3 unmet\nAction: fix rendering', - ); - // Round-trips as valid JSON with the newline preserved. - expect(JSON.parse(JSON.stringify(jsonOutput[0])).producerReviewExplanation).toBe( - 'Reason: AC3 unmet\nAction: fix rendering', - ); - const stored = ctx.utils.db.get(id); - expect(stored.needsProducerReview).toBe(true); - expect(stored.description).toContain('No section.'); - }); - - it('surfaces the audit verdict and summary in --json when the LLM is disabled (AC4)', async () => { - const { ctx, id, jsonOutput } = setup({ - item: { description: '# Task\n\nNo section.', needsProducerReview: true }, - comments: [], - auditResult: { - readyToClose: false, - auditedAt: '2026-10-04T21:00:00.000Z', - author: 'audit-runner', - summary: 'AC3 unmet: rendering still truncates', - }, - chatClient: fakeChatClient('unused'), - }); - - await ctx.runCli(['interview', id, '--json', '--no-llm']); - - expect(jsonOutput[0].producerReviewExplanation).toContain('not ready to close'); - expect(jsonOutput[0].producerReviewExplanation).toContain( - 'AC3 unmet: rendering still truncates', - ); - const stored = ctx.utils.db.get(id); - expect(stored.needsProducerReview).toBe(true); - }); - - it('returns a null explanation for a non-flagged item', async () => { - const { ctx, id, jsonOutput } = setup({ - item: { description: '# Task\n\nNo section.', needsProducerReview: false }, - comments: [comment('producer', 'Hello')], - chatClient: fakeChatClient('unused'), - }); - - await ctx.runCli(['interview', id, '--json']); - - expect(jsonOutput[0].needsProducerReview).toBe(false); - expect(jsonOutput[0].producerReviewExplanation).toBeNull(); - }); - it('reports outstanding-question state without prompting in JSON mode', async () => { const desc = `# Task @@ -1295,9 +895,7 @@ describe('interview --json (non-interactive)', () => { - Q: "Scope?" — Answer: *(awaiting producer)*. Source: reply.`; const { ctx, id, jsonOutput } = setup({ - item: { description: desc, needsProducerReview: true }, - comments: [comment('producer', 'Scope question raised.')], - chatClient: fakeChatClient('unused'), + item: { description: desc }, }); await ctx.runCli(['interview', id, '--json', '--no-llm']); @@ -1309,25 +907,15 @@ describe('interview --json (non-interactive)', () => { outstanding: 1, allAnswered: false, }); - // A flagged item with outstanding questions gets no explanation (only the - // noSection/noQuestions cases do). - expect(jsonOutput[0].producerReviewExplanation).toBeNull(); }); }); -// ── Clear-the-flag interactive prompt ──────────────────────────────────── +// ── No-section / no-questions interactive path ──────────────────────── -describe('interview clear-the-flag prompt', () => { - /** - * Register the command for an interactive run with captured output and a - * scripted prompt pool. The prompt loop is injected so no real readline - * interface is created. - */ +describe('interview no-questions interactive path', () => { function setupInteractive(options: { description?: string; - needsProducerReview?: boolean; answers?: string[]; - comments?: any[]; } = {}) { const ctx = createTestContext(); const messages: string[] = []; @@ -1339,12 +927,6 @@ describe('interview clear-the-flag prompt', () => { ctx.utils.db.update(id, { description: options.description ?? '# Task\n\nNo clarifying section here.', title: 'Prompt task', - needsProducerReview: options.needsProducerReview ?? true, - }); - const baseGetDatabase = ctx.utils.getDatabase; - ctx.utils.getDatabase = (prefix?: string) => ({ - ...baseGetDatabase(prefix), - getCommentsForWorkItem: () => options.comments ?? [], }); registerInterview(ctx as any, { @@ -1357,10 +939,6 @@ describe('interview clear-the-flag prompt', () => { }, close: () => {}, }), - clearPrompt: async (message: string) => { - messages.push(message); - return /^y(es)?$/i.test((answers.shift() ?? '').trim()); - }, }); return { @@ -1371,100 +949,27 @@ describe('interview clear-the-flag prompt', () => { }; } - it('prompts to clear the flag and clears it on yes', async () => { - const t = setupInteractive({ answers: ['y'] }); - try { - await t.ctx.runCli(['interview', t.id]); - expect(t.messages.some(m => /Clear the needsProducerReview flag\? \(y\/N\)/.test(m))).toBe(true); - expect(t.ctx.utils.db.get(t.id).needsProducerReview).toBe(false); - } finally { - t.restore(); - } - }); - - it('leaves the flag set on an explicit no', async () => { - const t = setupInteractive({ answers: ['n'] }); - try { - await t.ctx.runCli(['interview', t.id]); - expect(t.ctx.utils.db.get(t.id).needsProducerReview).toBe(true); - expect(t.messages.some(m => /remains flagged/.test(m))).toBe(true); - } finally { - t.restore(); - } - }); - - it('leaves the flag set on empty input (default No)', async () => { - const t = setupInteractive({ answers: [''] }); - try { - await t.ctx.runCli(['interview', t.id]); - expect(t.ctx.utils.db.get(t.id).needsProducerReview).toBe(true); - } finally { - t.restore(); - } - }); - - it('does not prompt when no explanation was shown (unflagged item)', async () => { - const t = setupInteractive({ needsProducerReview: false, answers: ['y'] }); - try { - await t.ctx.runCli(['interview', t.id]); - expect(t.messages.some(m => /Clear the needsProducerReview flag/.test(m))).toBe(false); - // Unchanged (still false). - expect(t.ctx.utils.db.get(t.id).needsProducerReview).toBe(false); - } finally { - t.restore(); - } - }); - - it('never prompts or mutates in --json mode', async () => { - const ctx = createTestContext(); - const jsonOutput: any[] = []; - const messages: string[] = []; - const originalLog = console.log; - console.log = (...args: any[]) => { messages.push(args.join(' ')); }; - ctx.output = { json: (d: any) => jsonOutput.push(d), success: () => {}, error: () => {} }; - const id = ctx.utils.createSampleItem({}); - ctx.utils.db.update(id, { - description: '# Task\n\nNo clarifying section here.', - needsProducerReview: true, - }); - registerInterview(ctx as any, { chatClientFactory: () => null }); - - try { - await ctx.runCli(['interview', id, '--json']); - expect(jsonOutput).toHaveLength(1); - expect(ctx.utils.db.get(id).needsProducerReview).toBe(true); - expect(messages.some(m => /Clear the needsProducerReview flag/.test(m))).toBe(false); - } finally { - console.log = originalLog; - } - }); - - it('does not prompt when questions remain outstanding', async () => { - const desc = `# Task - -## Appendix: Clarifying questions - -- Q: "Scope?" — Answer: *(awaiting producer)*. Source: reply.`; - const t = setupInteractive({ description: desc, answers: ['answer', 'y'] }); + it('exits cleanly when there is nothing to interview (no clarifying section)', async () => { + const t = setupInteractive(); try { await t.ctx.runCli(['interview', t.id]); - expect(t.messages.some(m => /Clear the needsProducerReview flag/.test(m))).toBe(false); + expect(t.messages.some(m => /Nothing to interview/.test(m))).toBe(true); } finally { t.restore(); } }); - it('preserves the existing allAnswered auto-clear path (no prompt)', async () => { + it('exits cleanly when questions remain outstanding after an interrupted session', async () => { const desc = `# Task ## Appendix: Clarifying questions -- Q: "Scope?" — Answer (user): "Repo-wide". Source: reply.`; - const t = setupInteractive({ description: desc, answers: [] }); +- Q: "One?" — Answer: *(awaiting producer)*. Source: reply. +- Q: "Two?" — Answer: *(awaiting producer)*. Source: reply.`; + const t = setupInteractive({ description: desc, answers: ['answer'] }); try { await t.ctx.runCli(['interview', t.id]); - expect(t.messages.some(m => /Clear the needsProducerReview flag/.test(m))).toBe(false); - expect(t.ctx.utils.db.get(t.id).needsProducerReview).toBe(false); + expect(t.messages.some(m => /still outstanding/.test(m))).toBe(true); } finally { t.restore(); } @@ -1564,7 +1069,6 @@ No questions here — just context prose.`; expect(desc).toContain('## Appendix: Clarifying questions'); expect(desc).toContain('- Q: Who is the user? — Answer (producer): Support engineers'); expect(desc).toContain('- Q: What format? — Answer (producer): JSON'); - expect(store.current().needsProducerReview).toBe(false); }); it('returns the deterministic no-question fallback when the LLM returns []', async () => { @@ -1643,7 +1147,6 @@ No questions here — just context prose.`; const desc = store.current().description; expect(desc).toContain('Keep me?'); expect(desc).not.toContain('Skip me?'); - expect(store.current().needsProducerReview).toBe(true); }); it('feeds the clarifying-section body (not the whole description) to the LLM', async () => { @@ -1694,7 +1197,6 @@ describe('interview --llm command', () => { config?: any; chatClient?: any; answers?: string[]; - needsProducerReview?: boolean; jsonOutput?: any[]; } = {}) { const ctx = createTestContext(); @@ -1714,7 +1216,6 @@ describe('interview --llm command', () => { ctx.utils.db.update(id, { description: options.description ?? '# Task\n\nNo clarifying section here.', title: 'LLM task', - needsProducerReview: options.needsProducerReview ?? true, }); const baseGetDatabase = ctx.utils.getDatabase; ctx.utils.getDatabase = (prefix?: string) => ({ @@ -1732,7 +1233,6 @@ describe('interview --llm command', () => { }, close: () => {}, }), - clearPrompt: async () => false, }); return { ctx, id, messages, restore: () => { console.log = originalLog; } }; } @@ -1746,7 +1246,6 @@ describe('interview --llm command', () => { const stored = t.ctx.utils.db.get(t.id); expect(stored.description).toContain('Who is the user?'); expect(stored.description).toContain('Support engineers'); - expect(stored.needsProducerReview).toBe(false); } finally { t.restore(); } @@ -1757,7 +1256,6 @@ describe('interview --llm command', () => { const t = setup({ chatClient: chat, answers: [''], - needsProducerReview: false, }); try { await t.ctx.runCli(['interview', t.id]); @@ -1789,7 +1287,6 @@ describe('interview --llm command', () => { chatClient: chat, config: { interview: { intelligent: true } }, answers: [''], - needsProducerReview: false, }); try { await t.ctx.runCli(['interview', t.id, '--no-llm']); @@ -1805,7 +1302,6 @@ describe('interview --llm command', () => { const t = setup({ chatClient: chat, answers: [''], - needsProducerReview: false, jsonOutput, }); try { @@ -1841,7 +1337,6 @@ describe('interview LLM progress feedback', () => { function setup(options: { description?: string; - needsProducerReview?: boolean; chatClient?: any; progressStream: ProgressWriteStream; answers?: string[]; @@ -1863,7 +1358,6 @@ describe('interview LLM progress feedback', () => { ctx.utils.db.update(id, { description: options.description ?? '# Task\n\nNo section here.', title: 'Progress task', - needsProducerReview: options.needsProducerReview ?? true, }); const baseGetDatabase = ctx.utils.getDatabase; ctx.utils.getDatabase = (prefix?: string) => ({ @@ -1881,7 +1375,6 @@ describe('interview LLM progress feedback', () => { }, close: () => {}, }), - clearPrompt: async () => false, }); return { ctx, @@ -1978,40 +1471,6 @@ describe('interview LLM progress feedback', () => { } }); - it('prints the fallback notice when the explanation LLM fails', async () => { - const { stream, writes } = makeProgressStream(); - const chat = { - available: true, - complete: vi.fn(async () => { - throw new Error('Chat API request timed out after 15000 ms'); - }), - }; - const t = setup({ chatClient: chat as any, progressStream: stream }); - try { - await t.ctx.runCli(['interview', t.id]); - expect(writes.some(w => w.includes(LLM_FALLBACK_NOTICE))).toBe(true); - } finally { - t.restore(); - } - }); - - it('prints the fallback notice when no client is available (no status)', async () => { - const { stream, writes } = makeProgressStream(); - const t = setup({ - chatClient: null, - progressStream: stream, - needsProducerReview: true, - }); - try { - await t.ctx.runCli(['interview', t.id]); - expect(writes.some(w => w.includes(LLM_FALLBACK_NOTICE))).toBe(true); - // No request was issued, so no "Thinking…" status was shown. - expect(writes.some(w => w.includes(LLM_THINKING_MESSAGE))).toBe(false); - } finally { - t.restore(); - } - }); - it('prints the fallback notice when the extraction LLM fails', async () => { const { stream, writes } = makeProgressStream(); const chat = { @@ -2024,7 +1483,6 @@ describe('interview LLM progress feedback', () => { chatClient: chat as any, progressStream: stream, description: '# Task\n\nNo section here.', - needsProducerReview: false, answers: [''], }); try { @@ -2044,7 +1502,6 @@ describe('interview LLM progress feedback', () => { chatClient: chat as any, progressStream: stream, description: desc, - needsProducerReview: true, }); try { await t.ctx.runCli(['interview', t.id, '--llm']); @@ -2055,3 +1512,185 @@ describe('interview LLM progress feedback', () => { } }); }); + +// ── Restored producer-review flag flow (WL-0MVAMS4RO000H3B0 F1) ───────── +// RED-pinned contract: the flag explain/clear flow was removed with +// `needsProducerReview` (WL-0MV85WFYU0071YQ9). These specs pin the restored +// interface. They are wrapped in `it(...)` so the gated suite stays +// green while the contract is failing; F5 (theme/interview/shortcuts restore) +// flips `it(` → `it(`. +// +// Documented interface (function names/signatures, per the item's AC): +// buildProducerReviewExplanation(item, opts) → Promise<string|null> +// buildProducerReviewExplanationResult(item, opts) → { text, usedLlm } | null +// buildExplanationPrompt(item, comments, auditResult) → string +// buildFallbackExplanation(item, comments, auditResult) → string +// renderExplanation(text, maxChars?, maxLines?) → string +// PRODUCER_REVIEW_INSTRUCTION : string +// MAX_PROMPT_CONTEXT_BYTES : number +// MAX_AUDIT_RAW_OUTPUT_BYTES : number +// (`comment` and `fakeChatClient` helpers are already defined above.) + +function flaggedItem(description: string, flagged = true): any { + return { + id: 'WL-TEST-1', + title: 'Sample', + description, + needsProducerReview: flagged, + } as any; +} + +/** Load the interview module namespace (helpers may not exist yet). */ +async function loadInterview(): Promise<Record<string, any>> { + return import('../../src/commands/interview.js'); +} + +describe('buildProducerReviewExplanation (RED-contract, restored)', () => { + const comments = [ + comment('bob', 'Second comment\nwith a second line'), + comment('alice', 'First comment'), + ]; + + it('returns null when the item is not flagged for producer review', async () => { + const m = await loadInterview(); + const result = await m.buildProducerReviewExplanation(flaggedItem('desc', false), { + comments, + chatClient: fakeChatClient('unused'), + }); + expect(result).toBeNull(); + }); + + it('preserves intentional line breaks in the LLM explanation', async () => { + const m = await loadInterview(); + const client = fakeChatClient('Reason: AC3 unmet\nAction: fix rendering'); + const result = await m.buildProducerReviewExplanation(flaggedItem('desc'), { + comments, + chatClient: client, + }); + expect(result).toBe('Reason: AC3 unmet\nAction: fix rendering'); + expect(result!.split('\n').length).toBeGreaterThan(1); + expect(client.complete).toHaveBeenCalledTimes(1); + }); + + it('skips the LLM and uses the two most recent comments when --no-llm', async () => { + const m = await loadInterview(); + const client = fakeChatClient('unused'); + const result = await m.buildProducerReviewExplanation(flaggedItem('desc'), { + comments, + chatClient: client, + noLlm: true, + }); + expect(client.complete).not.toHaveBeenCalled(); + expect(result).toContain('bob: Second comment'); + expect(result).toContain('alice: First comment'); + }); + + it('falls back silently when the LLM call fails', async () => { + const m = await loadInterview(); + const client = fakeChatClient(new Error('network unavailable')); + const result = await m.buildProducerReviewExplanation(flaggedItem('desc'), { + comments, + chatClient: client, + }); + expect(client.complete).toHaveBeenCalledTimes(1); + expect(result).toContain('bob: Second comment'); + }); + + it('uses a generic actionable fallback when there are no comments', async () => { + const m = await loadInterview(); + const result = await m.buildProducerReviewExplanation(flaggedItem('desc'), { + comments: [], + noLlm: true, + }); + expect(result).toContain('WL-TEST-1'); + }); + + it('builds the prompt from the instruction and the two most recent comments', async () => { + const m = await loadInterview(); + const client = fakeChatClient('ok'); + const older = comment('old', 'Ancient history'); + await m.buildProducerReviewExplanation(flaggedItem('desc'), { + comments: [...comments, older], + chatClient: client, + }); + const prompt = client.complete.mock.calls[0][0] as string; + expect(prompt).toContain(m.PRODUCER_REVIEW_INSTRUCTION); + expect(prompt).toContain('alice: First comment'); + expect(prompt).toContain('bob: Second comment'); + expect(prompt).not.toContain('Ancient history'); + }); +}); + +describe('buildProducerReviewExplanationResult (RED-contract, restored)', () => { + it('reports usedLlm true when the LLM returns an explanation', async () => { + const m = await loadInterview(); + const result = await m.buildProducerReviewExplanationResult(flaggedItem('desc'), { + comments: [comment('alice', 'First comment')], + chatClient: fakeChatClient('LLM reason'), + }); + expect(result).toEqual({ text: 'LLM reason', usedLlm: true }); + }); + + it('reports usedLlm false when the LLM throws (timeout/error)', async () => { + const m = await loadInterview(); + const result = await m.buildProducerReviewExplanationResult(flaggedItem('desc'), { + comments: [comment('alice', 'First comment')], + chatClient: fakeChatClient(new Error('Chat API request timed out')), + }); + expect(result?.usedLlm).toBe(false); + expect(result?.text).toBeTruthy(); + }); + + it('returns null for an unflagged item', async () => { + const m = await loadInterview(); + const result = await m.buildProducerReviewExplanationResult(flaggedItem('desc', false), { + comments: [comment('alice', 'First comment')], + chatClient: fakeChatClient('unused'), + }); + expect(result).toBeNull(); + }); +}); + +describe('renderExplanation + buildFallbackExplanation + buildExplanationPrompt (RED-contract, restored)', () => { + it('renderExplanation preserves line breaks but collapses runs of spaces', async () => { + const m = await loadInterview(); + const rendered = m.renderExplanation('Line one\n\nLine two with spaces'); + expect(rendered).toBe('Line one\n\nLine two with spaces'); + expect(rendered.split('\n').length).toBeGreaterThan(1); + }); + + it('renderExplanation bounds the explanation by length', async () => { + const m = await loadInterview(); + const bounded = m.renderExplanation('x'.repeat(5000), 100, 50); + expect(bounded.length).toBeLessThanOrEqual(100); + }); + + it('buildFallbackExplanation reports the audit verdict and summary when an audit result exists', async () => { + const m = await loadInterview(); + const auditResult = { + readyToClose: false, + auditedAt: '2026-10-04T21:00:00.000Z', + author: 'audit-runner', + summary: 'AC3 unmet: the explanation is still collapsed to one line', + rawOutput: 'VERDICT: not ready\nAC3: unmet', + }; + const fallback = m.buildFallbackExplanation(flaggedItem('desc'), [comment('bob', 'Second comment')], auditResult); + expect(fallback).toContain('not ready to close'); + expect(fallback).toContain(auditResult.summary); + }); + + it('buildExplanationPrompt includes the audit summary as first-class prompt context', async () => { + const m = await loadInterview(); + const auditResult = { + readyToClose: false, + auditedAt: '2026-10-04T21:00:00.000Z', + author: 'audit-runner', + summary: 'AC3 unmet: rendering still truncates', + rawOutput: 'VERDICT: not ready\nAC3: unmet', + }; + const prompt = m.buildExplanationPrompt(flaggedItem('desc'), [comment('alice', 'First comment')], auditResult); + expect(prompt).toContain(auditResult.summary); + expect(prompt).toContain('Latest audit result:'); + expect(prompt).toContain('not ready to close'); + }); +}); diff --git a/tests/cli/issue-status.test.ts b/tests/cli/issue-status.test.ts index 54fc7d0cf..578f46a5e 100644 --- a/tests/cli/issue-status.test.ts +++ b/tests/cli/issue-status.test.ts @@ -25,9 +25,9 @@ describe('CLI Issue Status Tests', () => { describe('list command', () => { beforeEach(() => { seedWorkItems(tempState.tempDir, [ - { title: 'Task 1', status: 'open', priority: 'high', needsProducerReview: true }, + { title: 'Task 1', status: 'open', priority: 'high' }, { title: 'Task 2', status: 'in-progress', priority: 'medium' }, - { title: 'Task 3', status: 'completed', priority: 'low', needsProducerReview: true }, + { title: 'Task 3', status: 'completed', priority: 'low' }, ]); }); @@ -102,61 +102,6 @@ describe('CLI Issue Status Tests', () => { expect(listedIds).not.toContain(unrelatedId); }); - it('should filter by needs-producer-review true', async () => { - const { stdout } = await execAsync(`tsx ${cliPath} --json list --needs-producer-review true`); - - const result = JSON.parse(stdout); - expect(result.success).toBe(true); - expect(result.workItems).toHaveLength(2); - result.workItems.forEach((item: any) => expect(item.needsProducerReview).toBe(true)); - }); - - it('should default needs-producer-review to true when value omitted', async () => { - const { stdout } = await execAsync(`tsx ${cliPath} --json list --needs-producer-review`); - - const result = JSON.parse(stdout); - expect(result.success).toBe(true); - expect(result.workItems).toHaveLength(2); - result.workItems.forEach((item: any) => expect(item.needsProducerReview).toBe(true)); - }); - - it('should filter by needs-producer-review false', async () => { - const { stdout } = await execAsync(`tsx ${cliPath} --json list --needs-producer-review false`); - - const result = JSON.parse(stdout); - expect(result.success).toBe(true); - expect(result.workItems).toHaveLength(1); - result.workItems.forEach((item: any) => expect(item.needsProducerReview).not.toBe(true)); - }); - - it('should accept "yes" as true for needs-producer-review', async () => { - const { stdout } = await execAsync(`tsx ${cliPath} --json list --needs-producer-review yes`); - - const result = JSON.parse(stdout); - expect(result.success).toBe(true); - expect(result.workItems).toHaveLength(2); - result.workItems.forEach((item: any) => expect(item.needsProducerReview).toBe(true)); - }); - - it('should accept "no" as false for needs-producer-review', async () => { - const { stdout } = await execAsync(`tsx ${cliPath} --json list --needs-producer-review no`); - - const result = JSON.parse(stdout); - expect(result.success).toBe(true); - expect(result.workItems).toHaveLength(1); - result.workItems.forEach((item: any) => expect(item.needsProducerReview).not.toBe(true)); - }); - - it('should error for invalid needs-producer-review value', async () => { - try { - await execAsync(`tsx ${cliPath} --json list --needs-producer-review maybe`); - expect.fail('Should have thrown an error'); - } catch (error: any) { - const result = JSON.parse(error.stderr || '{}'); - expect(result.success).toBe(false); - } - }); - it('should include completed items when --stage filter matches them', async () => { // Seed items where a completed item has a specific stage seedWorkItems(tempState.tempDir, [ @@ -617,87 +562,4 @@ describe('CLI Issue Status Tests', () => { expect(stdout).toContain('search'); }); }); - - describe('search --needs-producer-review parsing', () => { - let reviewItem1Id: string; - let reviewItem2Id: string; - let nonReviewItemId: string; - - beforeEach(async () => { - // Create items via CLI so they're in the SQLite database for search - const r1 = JSON.parse((await execAsync(`tsx ${cliPath} --json create -t "Review item 1" -p high`)).stdout); - const r2 = JSON.parse((await execAsync(`tsx ${cliPath} --json create -t "Review item 2" -p medium`)).stdout); - const r3 = JSON.parse((await execAsync(`tsx ${cliPath} --json create -t "Non-review item" -p low`)).stdout); - - reviewItem1Id = r1.workItem.id; - reviewItem2Id = r2.workItem.id; - nonReviewItemId = r3.workItem.id; - - // Set needsProducerReview flags - await execAsync(`tsx ${cliPath} --json update ${reviewItem1Id} --needs-producer-review true`); - await execAsync(`tsx ${cliPath} --json update ${reviewItem2Id} --needs-producer-review true`); - await execAsync(`tsx ${cliPath} --json update ${nonReviewItemId} --needs-producer-review false`); - }); - - it('should filter search results by --needs-producer-review true', async () => { - const { stdout } = await execAsync(`tsx ${cliPath} --json search "item" --needs-producer-review true`); - - const result = JSON.parse(stdout); - expect(result.success).toBe(true); - expect(result.results).toHaveLength(2); - const ids = result.results.map((r: any) => r.id); - expect(ids).toContain(reviewItem1Id); - expect(ids).toContain(reviewItem2Id); - }); - - it('should default --needs-producer-review to true when value omitted', async () => { - const { stdout } = await execAsync(`tsx ${cliPath} --json search "item" --needs-producer-review`); - - const result = JSON.parse(stdout); - expect(result.success).toBe(true); - expect(result.results).toHaveLength(2); - const ids = result.results.map((r: any) => r.id); - expect(ids).toContain(reviewItem1Id); - expect(ids).toContain(reviewItem2Id); - }); - - it('should filter search results by --needs-producer-review false', async () => { - const { stdout } = await execAsync(`tsx ${cliPath} --json search "item" --needs-producer-review false`); - - const result = JSON.parse(stdout); - expect(result.success).toBe(true); - expect(result.results).toHaveLength(1); - expect(result.results[0].id).toBe(nonReviewItemId); - }); - - it('should accept "yes" as true for --needs-producer-review', async () => { - const { stdout } = await execAsync(`tsx ${cliPath} --json search "item" --needs-producer-review yes`); - - const result = JSON.parse(stdout); - expect(result.success).toBe(true); - expect(result.results).toHaveLength(2); - const ids = result.results.map((r: any) => r.id); - expect(ids).toContain(reviewItem1Id); - expect(ids).toContain(reviewItem2Id); - }); - - it('should accept "no" as false for --needs-producer-review', async () => { - const { stdout } = await execAsync(`tsx ${cliPath} --json search "item" --needs-producer-review no`); - - const result = JSON.parse(stdout); - expect(result.success).toBe(true); - expect(result.results).toHaveLength(1); - expect(result.results[0].id).toBe(nonReviewItemId); - }); - - it('should error for invalid --needs-producer-review value', async () => { - try { - await execAsync(`tsx ${cliPath} --json search "item" --needs-producer-review maybe`); - expect.fail('Should have thrown an error'); - } catch (error: any) { - const result = JSON.parse(error.stderr || '{}'); - expect(result.success).toBe(false); - } - }); - }); }); diff --git a/tests/cli/mock-bin/wl b/tests/cli/mock-bin/wl index cdf899127..a5809bb1f 100755 --- a/tests/cli/mock-bin/wl +++ b/tests/cli/mock-bin/wl @@ -125,9 +125,6 @@ if (command === 'update') { if (args.includes('--description')) { item.description = String(parseOption(['--description']) || item.description); } - if (args.includes('--reviewed')) { - item.needsProducerReview = String(parseOption(['--reviewed']) || 'false') === 'true'; - } if (isJson) { jsonOut({ success: true, workItem: item }); } else { diff --git a/tests/cli/reviewed.test.ts b/tests/cli/reviewed.test.ts index cee4e9613..5f8c1ae5b 100644 --- a/tests/cli/reviewed.test.ts +++ b/tests/cli/reviewed.test.ts @@ -1,9 +1,33 @@ +/** + * Contract tests for the `wl reviewed` command (restored flag). + * + * Test-first contract (item WL-0MVAMS4RO000H3B0, child F1): the + * `needsProducerReview` flag and the `reviewed` command were removed + * (WL-0MV85WFYU0071YQ9) and are being restored. These specs are authored + * against the documented interface: + * + * wl reviewed <id> → toggles needsProducerReview + * wl reviewed <id> true|false → sets needsProducerReview explicitly + * + * RED-pinned: they are wrapped in `it.fails(...)` so the gated suite stays + * green while the contract is failing. The implementation child (F3, CLI/API + * surface) flips `it.fails(` → `it(` when `src/commands/reviewed.ts` is + * restored. The module is loaded dynamically so this file compiles/loads + * before the module exists. + */ + import { describe, it, expect } from 'vitest'; -import registerReviewed from '../../src/commands/reviewed.js'; import { createTestContext } from '../test-utils.js'; +/** Resolve `registerReviewed` once the module exists (throws today). */ +async function loadRegisterReviewed(): Promise<(ctx: any) => void> { + const mod: any = await import('../../src/commands/reviewed.js'); + return mod.default; +} + describe('reviewed command', () => { - it('toggles needsProducerReview when value omitted', async () => { + it('toggles needsProducerReview when value omitted (F3 restored)', async () => { + const registerReviewed = await loadRegisterReviewed(); const ctx = createTestContext(); registerReviewed(ctx as any); const id = ctx.utils.createSampleItem(); @@ -14,7 +38,8 @@ describe('reviewed command', () => { expect(Boolean(item.needsProducerReview)).toBe(true); }); - it('sets needsProducerReview when value provided', async () => { + it('sets needsProducerReview when value provided (F3 restored)', async () => { + const registerReviewed = await loadRegisterReviewed(); const ctx = createTestContext(); registerReviewed(ctx as any); const id = ctx.utils.createSampleItem(); diff --git a/tests/cli/sync-assignee-clear.test.ts b/tests/cli/sync-assignee-clear.test.ts new file mode 100644 index 000000000..c8626a72c --- /dev/null +++ b/tests/cli/sync-assignee-clear.test.ts @@ -0,0 +1,176 @@ +/** + * Cross-root sync test: assignee clear propagation (WL-0MV85WBZ2004VDUQ). + * + * Epic Q1 accepted answer (a): `assignee` is a scheduling hold, not a + * protected data field, so it is NOT regression-guarded. A cleared assignee + * (`''`) on a newer remote must propagate into a non-empty local assignee + * WITHOUT `--accept-regressions` — otherwise a released scheduling hold is + * refused as a regression and the stale claim is resurrected cross-root. + * + * These tests drive the REAL `wl sync` command against a mock git remote + * (two worklog roots: repoA = local, repoB = remote) so the chosen rule is + * exercised end-to-end. Other merge fields' regression protection + * (status/stage/priority) is asserted unchanged. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { execWithInput, writeConfig, writeInitSemaphore, cliPath } from './cli-helpers.js'; +import { createTempDir, cleanupTempDir } from '../test-utils.js'; + +const OWN_EMAIL = 'ross@example.com'; +const REMOTE_REF = 'refs/worklog/remotes/origin/worklog/data'; + +function jsonlLine(type: string, data: unknown): string { + return JSON.stringify({ data, type }); +} + +/** Minimal remote workitem matching the export/import schema used by tests. */ +function makeItem(id: string, overrides: Record<string, unknown> = {}): Record<string, unknown> { + return { + id, + title: 'Assignee clear item', + description: '', + status: 'open', + priority: 'medium', + sortIndex: 0, + parentId: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + tags: [], + assignee: '', + stage: '', + issueType: '', + createdBy: '', + deletedBy: '', + deleteReason: '', + risk: '', + effort: '', + ...overrides, + }; +} + +describe('cross-root sync: assignee clear propagation (WL-0MV85WBZ2004VDUQ)', () => { + let repoA: string; // local worklog root + let repoB: string; // remote worklog root (repoA's origin) + + beforeEach(() => { + repoA = createTempDir(); + repoB = createTempDir(); + for (const repo of [repoA, repoB]) { + fs.mkdirSync(path.join(repo, '.git'), { recursive: true }); + } + writeConfig(repoA, 'Assignee Clear Test', 'GUA'); + writeInitSemaphore(repoA); + // repoA's remote points at repoB so `wl sync` reads repoB's data file. + fs.writeFileSync(path.join(repoA, '.git', 'remote_origin'), repoB, 'utf8'); + fs.writeFileSync(path.join(repoA, '.git', 'user-email'), `${OWN_EMAIL}\n`, 'utf8'); + fs.mkdirSync(path.join(repoB, '.worklog'), { recursive: true }); + }); + + afterEach(() => { + cleanupTempDir(repoA); + cleanupTempDir(repoB); + }); + + async function wl(args: string): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { + return execWithInput(`tsx ${cliPath} --worklog-dir ${repoA}/.worklog ${args}`, '', { + cwd: repoA, + timeout: 60000, + }); + } + + async function createLocalItem(assignee: string): Promise<{ id: string; updatedAt: string }> { + const created = await wl(`create --json -t "Assignee clear item" --assignee "${assignee}"`); + expect(created.exitCode, `${created.stdout}\n${created.stderr}`).toBe(0); + const payload = JSON.parse(created.stdout); + const id: string = payload.workItem?.id ?? payload.workItems?.[0].id ?? payload.id; + expect(id).toBeTruthy(); + + const shown = await wl(`show ${id} --json`); + expect(shown.exitCode, `${shown.stdout}\n${shown.stderr}`).toBe(0); + const item = JSON.parse(shown.stdout).workItem; + return { id, updatedAt: item.updatedAt }; + } + + async function seedRemote(data: string): Promise<void> { + fs.writeFileSync(path.join(repoB, '.worklog', 'worklog-data.jsonl'), data, 'utf8'); + } + + async function getItem(id: string): Promise<Record<string, unknown>> { + const shown = await wl(`show ${id} --json`); + expect(shown.exitCode, `${shown.stdout}\n${shown.stderr}`).toBe(0); + return JSON.parse(shown.stdout).workItem; + } + + it('propagates a newer remote empty assignee into a local non-empty assignee (no --accept-regressions)', async () => { + // repoA holds the claim (assignee = Map). + const local = await createLocalItem('Map'); + expect(local.updatedAt).toBeTruthy(); + expect((await getItem(local.id)).assignee).not.toBe(''); + + // repoB cleared the claim at a NEWER timestamp. + await seedRemote( + `${jsonlLine('workitem', makeItem(local.id, { + id: local.id, + title: 'Assignee clear item', + updatedAt: '2099-01-01T00:00:00.000Z', // definitely newer than the local DB write + assignee: '', + }))}\n` + ); + + const synced = await wl('sync --no-push'); + const output = `${synced.stdout}\n${synced.stderr}`; + expect(synced.exitCode, output).toBe(0); + + // The clear propagated: local assignee is now empty. + expect((await getItem(local.id)).assignee).toBe(''); + + // And it was NOT reported as a refused regression. + expect(output.toLowerCase()).not.toMatch(/regression/i); + }, 90000); + + it('does NOT propagate a stale remote empty assignee over a newer local hold', async () => { + // repoA holds the claim at a NEWER timestamp than repoB's stale empty copy. + const local = await createLocalItem('Map'); + await seedRemote( + `${jsonlLine('workitem', makeItem(local.id, { + id: local.id, + title: 'Assignee clear item', + updatedAt: '2000-01-01T00:00:00.000Z', // older than the local claim + assignee: '', + }))}\n` + ); + + const synced = await wl('sync --no-push'); + const output = `${synced.stdout}\n${synced.stderr}`; + expect(synced.exitCode, output).toBe(0); + + // Last-writer-wins keeps the newer local hold. + expect((await getItem(local.id)).assignee).not.toBe(''); + }, 90000); + + it('still refuses a status regression cross-root (status/stage/priority protection unchanged)', async () => { + // repoA item is in-progress; repoB has an OLDER-ish but newer-timestamped + // 'open' copy — the regression guard must keep the local value. + const local = await createLocalItem(''); + await wl(`update ${local.id} --status in-progress`); + await seedRemote( + `${jsonlLine('workitem', makeItem(local.id, { + id: local.id, + title: 'Assignee clear item', + updatedAt: '2099-01-01T00:00:00.000Z', + status: 'open', + assignee: '', + }))}\n` + ); + + const synced = await wl('sync --no-push'); + const output = `${synced.stdout}\n${synced.stderr}`; + expect(synced.exitCode, output).toBe(0); + + // Local in-progress kept despite the newer remote 'open'. + expect((await getItem(local.id)).status).toBe('in-progress'); + }, 90000); +}); diff --git a/tests/cli/sync-vs-mutation.test.ts b/tests/cli/sync-vs-mutation.test.ts index c3147ab68..83518f3ee 100644 --- a/tests/cli/sync-vs-mutation.test.ts +++ b/tests/cli/sync-vs-mutation.test.ts @@ -108,7 +108,7 @@ async function waitForFile(file: string, timeoutMs = 15000): Promise<void> { /** Shape of the subset of `wl show --json` this suite asserts on. */ interface ShowResult { - workItem: { id: string; status: string; stage: string; title?: string; description?: string; needsProducerReview?: boolean; auditWaiver?: { reason: string; author?: string } | null; sortIndex?: number }; + workItem: { id: string; status: string; stage: string; title?: string; description?: string; assignee?: string; auditWaiver?: { reason: string; author?: string } | null; sortIndex?: number }; comments: Array<{ id?: string; author: string; comment: string }>; } @@ -537,8 +537,8 @@ describe('wl sync vs concurrent mutation (lost-update race)', () => { expect(after.workItem.title).toBe('IMPORTED TITLE'); }, 90000); - it('AC1/AC3: a concurrent wl reviewed survives a sync (needsProducerReview flag intact)', async () => { - const id = createItem(s.local, 'Reviewed flag target'); + it('AC1/AC3: a concurrent wl update (--assignee) survives a sync (assignee intact)', async () => { + const id = createItem(s.local, 'Assignee update target'); const seed = runCli(s.local, ['--json', 'sync']); expect(seed.status, seed.stderr).toBe(0); @@ -548,7 +548,7 @@ describe('wl sync vs concurrent mutation (lost-update race)', () => { await waitForFile(s.marker, 20000); const mutation = (async () => { - const rv = spawnCli(s.local, ['--json', 'reviewed', id, 'true'], realGitEnv()); + const rv = spawnCli(s.local, ['--json', 'update', id, '--assignee', 'producer'], realGitEnv()); const rvRes = await rv.done; expect(rvRes.status, rvRes.stderr).toBe(0); })(); @@ -563,9 +563,9 @@ describe('wl sync vs concurrent mutation (lost-update race)', () => { expect(syncRes.status, syncRes.stderr).toBe(0); await mutation; - // The flag set during the parked sync must survive it. + // The assignee set during the parked sync must survive it. const after = showItem(s.local, id); - expect(after.workItem.needsProducerReview).toBe(true); + expect(after.workItem.assignee).toBe('producer'); }, 90000); it('AC1: a concurrent wl audit-waive survives a sync (waiver intact)', async () => { @@ -785,7 +785,7 @@ describe('wl sync vs concurrent mutation (lost-update race)', () => { }); const mutations = Promise.all([ - mutate(['--json', 'reviewed', idA, 'true']), + mutate(['--json', 'update', idA, '--assignee', 'producer']), mutate(['--json', 'comment', 'add', idB, '-a', 'tester', '-c', 'cross-command comment']), mutate(['--json', 'dep', 'add', idC, idD]), mutate(['--json', 're-sort']), @@ -799,7 +799,7 @@ describe('wl sync vs concurrent mutation (lost-update race)', () => { await mutations; // Every command class's mutation must have survived the single sync. - expect(showItem(s.local, idA).workItem.needsProducerReview).toBe(true); + expect(showItem(s.local, idA).workItem.assignee).toBe('producer'); expect( showItem(s.local, idB).comments.some(c => c.comment.includes('cross-command comment')), ).toBe(true); diff --git a/tests/cli/update-batch.test.ts b/tests/cli/update-batch.test.ts index 5e217bab6..292601919 100644 --- a/tests/cli/update-batch.test.ts +++ b/tests/cli/update-batch.test.ts @@ -536,12 +536,12 @@ describe('update batch behaviour', () => { } }); - it('should handle batch update with needs-producer-review flag', async () => { + it('should handle batch update with --assignee', async () => { const id1 = await createItem(); const id2 = await createItem(); const { stdout } = await execAsync( - `tsx ${cliPath} --json update ${id1} ${id2} --needs-producer-review true` + `tsx ${cliPath} --json update ${id1} ${id2} --assignee producer` ); const result = JSON.parse(stdout); @@ -549,7 +549,7 @@ describe('update batch behaviour', () => { expect(result.results).toHaveLength(2); for (const r of result.results) { expect(r.success).toBe(true); - expect(r.workItem.needsProducerReview).toBe(true); + expect(r.workItem.assignee).toBe('producer'); } }); diff --git a/tests/cli/update-if-assignee.test.ts b/tests/cli/update-if-assignee.test.ts new file mode 100644 index 000000000..01b88b738 --- /dev/null +++ b/tests/cli/update-if-assignee.test.ts @@ -0,0 +1,256 @@ +/** + * Contract tests for `wl update --if-assignee` — the atomic assignee CAS + * guard (epic WL-0MV82CHGQ000HAZZ, test-first child WL-0MV85WARS000THEC; + * implementation child WL-0MV85WBDB003J35W, "C2"). + * + * These specs pin the TARGET CLI contract of the audit-tier status-neutral + * claim (C4, WL-0MV85WCJJ0087VMP): + * + * wl update <id> --assignee <agent> --if-status completed + * --if-stage in_review --if-assignee "" + * + * The update applies ONLY while the item's stored assignee still matches + * the guard; a mismatch is a stale/no-op result with NO write at all. The + * empty string is a meaningful expected value (it matches an unassigned + * item) and is distinguishable from the flag being omitted. Exactly one of + * two concurrent `--if-assignee ""` claims wins (RCA WL-0MSRBFFLN005W3VT + * RC-1 single-claim guarantee, preserved by the BEGIN IMMEDIATE CAS). + * + * Test-first discipline: this contract was authored against an unimplemented + * `--if-assignee` (every spec failed: the flag was an UNKNOWN option) and is + * now implemented by C2. The specs are normal `it(...)` assertions that + * genuinely pass against the landed flag, pinning the real semantics. + * + * Covers AC2 (unit semantics at the CLI), AC3 (atomicity) and AC4 + * (status-neutral claim never creates an in-progress/in_review hybrid) of + * WL-0MV85WARS000THEC. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawn } from 'node:child_process'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + cliPath, + execAsync, + enterTempDir, + leaveTempDir, + writeConfig, + writeInitSemaphore, +} from './cli-helpers.js'; +import { resolveTsxBin } from '../test-utils.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..', '..'); +const tsxBin = resolveTsxBin(__dirname); + +/** Parse the JSON worklog output from a run (stdout for success, stderr for stale). */ +function parseCliJson(run: { stdout: string; stderr: string }): any { + const text = (run.stdout || run.stderr || '').trim(); + try { + return JSON.parse(text); + } catch { + // A non-JSON line (e.g. commander "error: unknown option …") is what + // current `dev` emits for --if-assignee — it must fail the spec. + throw new SyntaxError(`No JSON payload in CLI output: ${text.slice(0, 300)}`); + } +} + +/** Run the real CLI in a subprocess (needed for the concurrency spec). */ +function runCliSubprocess( + cwd: string, + args: string[], +): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [tsxBin, cliPath, ...args], { + cwd, + env: { ...(process.env as Record<string, string>) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d: Buffer) => (stdout += d.toString())); + child.stderr.on('data', (d: Buffer) => (stderr += d.toString())); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); + }); +} + +describe('wl update --if-assignee (assignee CAS guard, WL-0MV85WARS000THEC)', () => { + let state: { tempDir: string; originalCwd: string }; + + beforeEach(() => { + state = enterTempDir(); + writeConfig(state.tempDir, 'Test Project', 'TEST'); + writeInitSemaphore(state.tempDir); + }); + + afterEach(() => { + leaveTempDir(state); + }); + + /** Create a fresh item and return its id. */ + async function createItem(title = 'CAS assignee item'): Promise<string> { + const { stdout } = await execAsync( + `tsx ${cliPath} --json create -t "${title}"`, + ); + return JSON.parse(stdout).workItem.id; + } + + /** Move an item into the audit bucket (completed/in_review), assignee cleared. */ + async function seedCompletedInReview(id: string, assignee = ''): Promise<void> { + await execAsync( + `tsx ${cliPath} --json update ${id} --status completed --stage in_review --assignee "${assignee}"`, + ); + } + + /** `wl show` read back as {status,stage,assignee,priority}. */ + async function readBack(id: string): Promise<{ status: string; stage: string; assignee: string; priority: string }> { + const { stdout } = await execAsync(`tsx ${cliPath} --json show ${id}`); + const w = JSON.parse(stdout).workItem; + return { status: w.status, stage: w.stage, assignee: w.assignee ?? '', priority: w.priority ?? 'medium' }; + } + + // ── AC2: exact-match success ──────────────────────────────────────── + + it('applies the update when --if-assignee matches the current assignee', async () => { + const id = await createItem(); // assignee '' by default + const { stdout } = await execAsync( + `tsx ${cliPath} --json update ${id} --assignee AgentA --if-assignee ""`, + ); + const parsed = JSON.parse(stdout); + expect(parsed.success).toBe(true); + expect((await readBack(id)).assignee).toBe('AgentA'); + }); + + it('succeeds with a non-empty expected value on an exact match', async () => { + const id = await createItem(); + await execAsync(`tsx ${cliPath} --json update ${id} --assignee Alice`); + const { stdout } = await execAsync( + `tsx ${cliPath} --json update ${id} --assignee AgentB --if-assignee Alice`, + ); + expect(JSON.parse(stdout).success).toBe(true); + expect((await readBack(id)).assignee).toBe('AgentB'); + }); + + // ── AC2: mismatch is a stale no-op with NO partial write ──────────── + + it('fails stale with no write when the current assignee no longer matches', async () => { + const id = await createItem(); + // Another pane won the claim first. + await execAsync(`tsx ${cliPath} --json update ${id} --assignee Other`); + + const run = await execAsync( + `tsx ${cliPath} --json update ${id} --assignee AgentA --priority high --if-assignee ""`, + ).catch((e) => e as { stdout: string; stderr: string }); + const parsed = parseCliJson(run as { stdout: string; stderr: string }); + expect(parsed.success).toBe(false); + expect(parsed.error).toBe('stale'); + // No partial write: the winner's assignee AND an untouched field survive. + const back = await readBack(id); + expect(back.assignee).toBe('Other'); + expect(back.priority).toBe('medium'); + }); + + // ── AC2: the empty-string guard ───────────────────────────────────── + + it('--if-assignee "" succeeds when the current assignee is empty', async () => { + const id = await createItem(); + const { stdout } = await execAsync( + `tsx ${cliPath} --json update ${id} --assignee AgentA --if-assignee ""`, + ); + expect(JSON.parse(stdout).success).toBe(true); + expect((await readBack(id)).assignee).toBe('AgentA'); + }); + + it('--if-assignee "" fails stale when the assignee is non-empty', async () => { + const id = await createItem(); + await execAsync(`tsx ${cliPath} --json update ${id} --assignee Map`); + + const run = await execAsync( + `tsx ${cliPath} --json update ${id} --assignee AgentA --if-assignee ""`, + ).catch((e) => e as { stdout: string; stderr: string }); + const parsed = parseCliJson(run as { stdout: string; stderr: string }); + expect(parsed.success).toBe(false); + expect(parsed.error).toBe('stale'); + expect((await readBack(id)).assignee).toBe('Map'); + }); + + // ── AC4: status-neutral audit-tier claim ──────────────────────────── + // The item stays completed/in_review throughout — never a hybrid. + + it('audit-tier claim keeps a completed/in_review item at completed/in_review', async () => { + const id = await createItem('Audit me'); + await seedCompletedInReview(id); + + // This is exactly the claim command the status-neutral herdr audit + // tier will run (C4): assignee CAS only — no --status, no --stage. + const { stdout } = await execAsync( + `tsx ${cliPath} --json update ${id} --assignee audit-tier --if-status completed --if-stage in_review --if-assignee ""`, + ); + expect(JSON.parse(stdout).success).toBe(true); + const back = await readBack(id); + expect(back.status).toBe('completed'); + expect(back.stage).toBe('in_review'); + expect(back.assignee).toBe('audit-tier'); + }); + + it('a claimed-and-cleared item reads back completed/in_review with an empty assignee (no hybrid, no residue)', async () => { + const id = await createItem('Audit me'); + await seedCompletedInReview(id); + + // Claim (status-neutral). + await execAsync( + `tsx ${cliPath} --json update ${id} --assignee audit-tier --if-status completed --if-stage in_review --if-assignee ""`, + ); + // Simulated audit Phase-2 failure → guarded rollback clears the assignee + // (--if-assignee <agent>), still without touching status/stage. + const rollback = await execAsync( + `tsx ${cliPath} --json update ${id} --assignee "" --if-assignee audit-tier`, + ).catch((e) => e as { stdout: string; stderr: string }); + const parsed = parseCliJson(rollback as { stdout: string; stderr: string }); + expect(parsed.success).toBe(true); + const back = await readBack(id); + expect(back.status).toBe('completed'); + expect(back.stage).toBe('in_review'); + expect(back.assignee).toBe(''); + }); + + // ── AC3: atomicity — two concurrent claims, exactly one winner ────── + + it('two concurrent --if-assignee "" claims yield exactly one winner', async () => { + const id = await createItem('Race me'); + await seedCompletedInReview(id); + + const claim = (agent: string) => + runCliSubprocess(state.tempDir, [ + '--json', + 'update', + id, + '--assignee', + agent, + '--if-status', + 'completed', + '--if-stage', + 'in_review', + '--if-assignee', + '', + ]); + + const [ra, rb] = await Promise.all([claim('AgentA'), claim('AgentB')]); + const outcomes = [parseCliJson(ra), parseCliJson(rb)]; + const winners = outcomes.filter((o) => o.success === true); + const losers = outcomes.filter((o) => o.success === false); + + expect(winners).toHaveLength(1); + expect(losers).toHaveLength(1); + expect(losers[0].error).toBe('stale'); + // The stored assignee is exactly the winner's agent. + const back = await readBack(id); + expect(['AgentA', 'AgentB']).toContain(back.assignee); + // And the item never left the audit bucket. + expect(back.status).toBe('completed'); + expect(back.stage).toBe('in_review'); + }); +}); diff --git a/tests/cli/update-soft-deleted.test.ts b/tests/cli/update-soft-deleted.test.ts new file mode 100644 index 000000000..209e40c7b --- /dev/null +++ b/tests/cli/update-soft-deleted.test.ts @@ -0,0 +1,137 @@ +/** + * tests/cli/update-soft-deleted.test.ts — `wl update` must not resurrect a + * soft-deleted work item. + * + * A soft delete (`wl delete`) sets `deletedBy` (and `deleteReason`) and may + * leave the item's status untouched. Before this guard, `wl update <id> + * --status <open|in-progress>` rewrote the status of an item whose `deletedBy` + * was set, after which the downtime dispatcher re-selected it and worked it a + * second time (SA-0MUJBLZC60085DI9 — the false-positive test-failure item + * SA-0MUJ14FQQ006R3PD was resurrected this way). + * + * The guard treats a non-empty `deletedBy` as the authoritative deletion + * marker: any mutation other than an explicit change to the delete-attribution + * fields (`--deleted-by`/`--delete-reason`) is rejected per-id with a clear + * error naming the item, and the command exits non-zero. A batch edit still + * applies to live ids. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + cliPath, + execAsync, + enterTempDir, + leaveTempDir, + writeConfig, + writeInitSemaphore, +} from './cli-helpers.js'; + +describe('wl update refuses to resurrect a soft-deleted work item', () => { + let tempState: { tempDir: string; originalCwd: string }; + + beforeEach(() => { + tempState = enterTempDir(); + writeConfig(tempState.tempDir, 'Test Project', 'TEST'); + writeInitSemaphore(tempState.tempDir); + }); + + afterEach(() => { + leaveTempDir(tempState); + }); + + async function createItem(title = 'Soft-delete guard item', flags = ''): Promise<string> { + const { stdout } = await execAsync( + `tsx ${cliPath} --json create -t "${title}" ${flags}` + ); + return JSON.parse(stdout).workItem.id; + } + + async function deleteItem(id: string): Promise<void> { + // Attribute the delete (WL-0MSKZ30SK007K9TO): `wl delete` reads + // `deletedBy` from `git config user.email`, which the test git mock + // serves from `.git/user-email`. Without attribution the soft-delete + // marker (`deletedBy`) stays empty and the guard has nothing to + // key on — this is the exact real-world shape the guard protects. + mkdirSync(join(tempState.tempDir, '.git'), { recursive: true }); + writeFileSync(join(tempState.tempDir, '.git', 'user-email'), 'deleter@example.com'); + await execAsync(`tsx ${cliPath} --json delete ${id} --no-sync`); + } + + async function showItem(id: string): Promise<any> { + const { stdout } = await execAsync(`tsx ${cliPath} --json show ${id}`); + return JSON.parse(stdout).workItem; + } + + it('rejects a status mutation on a soft-deleted item and exits non-zero', async () => { + const id = await createItem(); + await deleteItem(id); + + const run = await execAsync( + `tsx ${cliPath} --json update ${id} --status in_progress`, + ).catch((e) => e as { stdout: string; stderr: string; exitCode?: number }); + + const parsed = JSON.parse((run as { stderr: string }).stderr || (run as { stdout: string }).stdout); + expect(parsed.success).toBe(false); + expect(parsed.error).toBe('deleted-item'); + // The error names the deleted item so the operator can act on it. + expect(parsed.message ?? '').toContain(id); + // Non-zero exit is what callers (and the dispatcher) observe. + expect(run.exitCode).not.toBe(0); + }); + + it('leaves the deleted item untouched after the refusal', async () => { + const id = await createItem(); + await deleteItem(id); + const before = await showItem(id); + + await execAsync(`tsx ${cliPath} --json update ${id} --status in_progress`).catch(() => undefined); + + const after = await showItem(id); + expect(after.status).toBe(before.status); + expect(after.deletedBy).toBe(before.deletedBy); + }); + + it('rejects a stage mutation on a soft-deleted item (no resurrection route)', async () => { + const id = await createItem('Stage guard', '--stage idea'); + await deleteItem(id); + + const run = await execAsync( + `tsx ${cliPath} --json update ${id} --stage plan_complete`, + ).catch((e) => e as { stdout: string; stderr: string }); + + const parsed = JSON.parse((run as { stderr: string }).stderr || (run as { stdout: string }).stdout); + expect(parsed.success).toBe(false); + expect(parsed.error).toBe('deleted-item'); + }); + + it('still updates a live item normally (guard is scoped to deleted items)', async () => { + const id = await createItem(); + const { stdout } = await execAsync( + `tsx ${cliPath} --json update ${id} --status in_progress`, + ); + expect(JSON.parse(stdout).workItem.status).toBe('in-progress'); + }); + + it('batch update applies to the live id and rejects the deleted id', async () => { + const liveId = await createItem('Live item'); + const deletedId = await createItem('Deleted item'); + await deleteItem(deletedId); + + const run = await execAsync( + `tsx ${cliPath} --json update ${liveId} ${deletedId} --status in_progress`, + ).catch((e) => e as { stdout: string; stderr: string }); + + const parsed = JSON.parse((run as { stderr: string }).stderr || (run as { stdout: string }).stdout); + expect(parsed.success).toBe(false); + const results: any[] = parsed.results ?? []; + const live = results.find((r) => r.id === liveId); + const deleted = results.find((r) => r.id === deletedId); + expect(live?.success).toBe(true); + expect(deleted?.success).toBe(false); + expect(deleted?.error).toBe('deleted-item'); + // The live item really moved. + expect((await showItem(liveId)).status).toBe('in-progress'); + }); +}); diff --git a/tests/cli/valid-json-output.test.ts b/tests/cli/valid-json-output.test.ts index 4cc3769aa..68e859a8d 100644 --- a/tests/cli/valid-json-output.test.ts +++ b/tests/cli/valid-json-output.test.ts @@ -317,22 +317,15 @@ describe('valid-json-output', () => { // ────────────────────────────────────────────── describe('management commands', () => { - it('reviewed --json outputs valid JSON with no preamble', async () => { - // reviewed toggles needsProducerReview, then back + it('update --json outputs valid JSON with no preamble', async () => { const { stdout } = await execAsync( - `tsx ${cliPath} --json reviewed ${itemId}`, + `tsx ${cliPath} --json update ${itemId} --assignee producer`, ); - let result = expectValidJson(stdout); + const result = expectValidJson(stdout); expect(result.success).toBe(true); expect(result.workItem).toBeDefined(); expect(result.workItem.id).toBe(itemId); - - // Toggle back to original state - const { stdout: stdout2 } = await execAsync( - `tsx ${cliPath} --json reviewed ${itemId}`, - ); - result = expectValidJson(stdout2); - expect(result.success).toBe(true); + expect(result.workItem.assignee).toBe('producer'); }); it('re-sort --json outputs valid JSON with no preamble', async () => { diff --git a/tests/cli/validate-cli-md.test.ts b/tests/cli/validate-cli-md.test.ts index 9889c1999..4134e4ccc 100644 --- a/tests/cli/validate-cli-md.test.ts +++ b/tests/cli/validate-cli-md.test.ts @@ -2,9 +2,8 @@ * CLI documentation validator integration test. * * Migrated from the legacy `test/validator.test.ts` (WL-0MV4UUK120029UR5, - * child of WL-0MV28X3BT0079JM3) alongside the API `needsProducerReview` - * filter tests, so deleting the legacy file does not drop the CLI-doc - * validator assertion. + * validating the CLI help corpus against the CLI.md reference. + */ import { execaSync } from 'execa'; diff --git a/tests/database.test.ts b/tests/database.test.ts index 020dbd3a4..1fa9a423a 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -245,10 +245,10 @@ describe('WorklogDatabase', () => { describe('list', () => { beforeEach(() => { // Create test data - db.create({ title: 'Task 1', status: 'open', priority: 'high', needsProducerReview: true }); + db.create({ title: 'Task 1', status: 'open', priority: 'high' }); db.create({ title: 'Task 2', status: 'in-progress', priority: 'medium' }); db.create({ title: 'Task 3', status: 'completed', priority: 'low' }); - db.create({ title: 'Task 4', status: 'open', priority: 'high', tags: ['backend'], needsProducerReview: true }); + db.create({ title: 'Task 4', status: 'open', priority: 'high', tags: ['backend'] }); db.create({ title: 'Task 5', status: 'blocked', priority: 'critical', assignee: 'alice' }); }); @@ -344,17 +344,6 @@ describe('WorklogDatabase', () => { expect(items[0].id).toBe(child.id); }); - it('should filter by needsProducerReview true', () => { - const items = db.list({ needsProducerReview: true }); - expect(items).toHaveLength(2); - items.forEach(item => expect(item.needsProducerReview).toBe(true)); - }); - - it('should filter by needsProducerReview false', () => { - const items = db.list({ needsProducerReview: false }); - expect(items).toHaveLength(3); - items.forEach(item => expect(item.needsProducerReview).not.toBe(true)); - }); }); describe('update', () => { @@ -1344,7 +1333,6 @@ describe('WorklogDatabase', () => { issueType: '', risk: '' as const, effort: '' as const, - needsProducerReview: false, createdAt: item.createdAt, updatedAt: originalUpdatedAt, // Pass through the original timestamp }); @@ -1376,7 +1364,6 @@ describe('WorklogDatabase', () => { issueType: '', risk: '' as const, effort: '' as const, - needsProducerReview: false, createdAt: item1.createdAt, updatedAt: originalUpdatedAt1, }); @@ -1392,7 +1379,6 @@ describe('WorklogDatabase', () => { issueType: '', risk: '' as const, effort: '' as const, - needsProducerReview: false, createdAt: item2.createdAt, updatedAt: originalUpdatedAt2, }); @@ -1424,7 +1410,6 @@ describe('WorklogDatabase', () => { issueType: item.issueType, risk: item.risk as '' | 'Low' | 'Medium' | 'High' | 'Critical', effort: item.effort as '' | 'Small' | 'Medium' | 'Large' | 'XLarge', - needsProducerReview: false, createdAt: item.createdAt, updatedAt: originalUpdatedAt, }); @@ -1591,7 +1576,6 @@ describe('WorklogDatabase', () => { deleteReason: '', risk: '' as const, effort: '' as const, - needsProducerReview: false, ...overrides, }; } diff --git a/tests/doctor-foreign-items-apply.test.ts b/tests/doctor-foreign-items-apply.test.ts index 31d54f235..79dc4290f 100644 --- a/tests/doctor-foreign-items-apply.test.ts +++ b/tests/doctor-foreign-items-apply.test.ts @@ -37,7 +37,6 @@ function makeItem(id: string, overrides: Record<string, unknown> = {}): any { deleteReason: '', risk: '', effort: '', - needsProducerReview: false, ...overrides, }; } diff --git a/tests/fts-search.test.ts b/tests/fts-search.test.ts index a50d3094e..6dc06cff0 100644 --- a/tests/fts-search.test.ts +++ b/tests/fts-search.test.ts @@ -186,28 +186,6 @@ describe('FTS Search', () => { }); }); - describe('--needs-producer-review filter', () => { - it('should filter by needsProducerReview true (FTS path)', () => { - db.create({ title: 'Review alpha item', needsProducerReview: true }); - db.create({ title: 'Review alpha item', needsProducerReview: false }); - - const { results } = db.search('review alpha', { needsProducerReview: true }); - expect(results.length).toBe(1); - const item = db.get(results[0].itemId); - expect(item?.needsProducerReview).toBe(true); - }); - - it('should filter by needsProducerReview false (FTS path)', () => { - db.create({ title: 'Review beta item', needsProducerReview: true }); - db.create({ title: 'Review beta item', needsProducerReview: false }); - - const { results } = db.search('review beta', { needsProducerReview: false }); - expect(results.length).toBe(1); - const item = db.get(results[0].itemId); - expect(item?.needsProducerReview).toBe(false); - }); - }); - describe('--deleted filter', () => { it('should exclude items with status deleted by default (FTS path)', () => { // Create an item directly with status 'deleted' — this keeps its diff --git a/tests/grouping-utility.test.ts b/tests/grouping-utility.test.ts index 5acf47242..f7ec39ecd 100644 --- a/tests/grouping-utility.test.ts +++ b/tests/grouping-utility.test.ts @@ -593,7 +593,7 @@ describe('in_review 6-bucket sort (WL-0MSLPM5ZB003TADT)', () => { return { id: 'WL-x', stage: 'in_review', filePaths: [], priority: 'medium', ...overrides }; } - it('inReviewBucket returns correct bucket for each category', () => { + it('inReviewBucket returns all six buckets with needsProducerReview first (F3 restored core grouping)', () => { expect(inReviewBucket({ stage: 'in_review', needsProducerReview: true, auditResult: null, auditedAt: null, updatedAt, @@ -619,7 +619,7 @@ describe('in_review 6-bucket sort (WL-0MSLPM5ZB003TADT)', () => { })).toBe(0); }); - it('orders the six buckets: needsProducerReview → failed fresh → failed stale → no audit → passed stale → passed fresh', () => { + it('orders the six buckets: needsProducerReview → failed fresh → failed stale → no audit → passed stale → passed fresh (F3 restored core grouping)', () => { const items = [ inReviewItem({ id: 'WL-passed-fresh', auditResult: true, auditedAt: freshAuditedAt, updatedAt }), inReviewItem({ id: 'WL-passed-stale', auditResult: true, auditedAt: staleAuditedAt, updatedAt }), @@ -671,13 +671,13 @@ describe('in_review 6-bucket sort (WL-0MSLPM5ZB003TADT)', () => { expect(sorted.map(i => (i as { id: string }).id)).toEqual(['WL-a', 'WL-b', 'WL-c']); }); - it('does not apply bucket sort to non-in_review items', () => { + it('does not apply bucket sort to non-in_review items even when flagged (restored contract WL-0MVAMS4RO000H3B0 F1)', () => { const a = { id: 'WL-a', stage: 'plan_complete', filePaths: [], priority: 'medium', needsProducerReview: true }; const b = { id: 'WL-b', stage: 'plan_complete', filePaths: [], priority: 'medium' }; expect(compareGroupableItems(a, b)).toBeLessThan(0); // WL-a < WL-b lexicographically }); - it('compareInReviewItems returns 0 for non-in_review items', () => { + it('compareInReviewItems returns 0 for non-in_review items even when flagged (restored contract WL-0MVAMS4RO000H3B0 F1)', () => { const a = { id: 'WL-a', stage: 'plan_complete', filePaths: [], priority: 'medium', needsProducerReview: true } as any; const b = { id: 'WL-b', stage: 'intake_complete', filePaths: [], priority: 'medium' } as any; expect(compareInReviewItems(a, b)).toBe(0); diff --git a/tests/herdr/auto-refresh.test.ts b/tests/herdr/auto-refresh.test.ts index 6274a84e4..6b6b90e91 100644 --- a/tests/herdr/auto-refresh.test.ts +++ b/tests/herdr/auto-refresh.test.ts @@ -149,22 +149,25 @@ describe('refresh notification rendering', () => { expect(input).toContain('5'); }); - it('shows auto-refresh indicator in header', () => { + it('shows auto-refresh indicator in header (removal regression: none rendered)', () => { const renderer = createListRenderer(); const items = makeItems(3); const result = renderer( items, 0, 0, defaultTermSize, null, 'list', null, undefined, undefined, 0, false, ); - // Without auto-refresh, no indicator + // The `[auto-refresh on]` header segment was removed entirely + // (WL-0MV85T0LT008UT9H) — never render it, regardless of the setting. expect(result).not.toContain('auto'); }); - it('shows auto-refresh enabled in header when active', () => { + it('no longer renders an auto-refresh header indicator when active', () => { const renderer = createListRenderer(); const items = makeItems(3); const result = renderer( items, 0, 0, defaultTermSize, null, 'list', null, undefined, undefined, 0, true, ); - expect(result).toContain('auto'); + // The setting still drives the scheduler tick (WL-0MSJ1OLTL009N4IQ), but + // the header segment was removed — even `true` renders nothing. + expect(result).not.toContain('auto'); }); }); diff --git a/tests/herdr/auto-sync.test.ts b/tests/herdr/auto-sync.test.ts index 503fb813c..e04e1466d 100644 --- a/tests/herdr/auto-sync.test.ts +++ b/tests/herdr/auto-sync.test.ts @@ -396,6 +396,8 @@ describe('auto-refresh + sync integration', () => { undefined, undefined, 0, true, undefined, undefined, 0, true, ); expect(result).toContain('Work Items'); - expect(result).toContain('auto'); + // The `[auto-refresh on]` header segment was removed (WL-0MV85T0LT008UT9H) + // — an enabled setting never renders an indicator. + expect(result).not.toContain('auto'); }); }); diff --git a/tests/herdr/icons.test.ts b/tests/herdr/icons.test.ts index a2f253ffe..c9025ab7c 100644 --- a/tests/herdr/icons.test.ts +++ b/tests/herdr/icons.test.ts @@ -3,8 +3,8 @@ * system. * * Layer note: exercises the shared `@worklog/shared/icons` functions as used - * by the herdr display layer (status/stage/priority/audit/epic/risk/effort/ - * needsProducerReview icons, `stageColor`, and general `getIconPrefix` + * by the herdr display layer (status/stage/priority/audit/risk/effort + * icons, `stageColor`, and general `getIconPrefix` * behaviour). It is not a duplicate of: * - tests/unit/icons.test.ts — the CLI `src/theme.ts` icon vocabulary; or * - packages/herdr/src/icons.test.ts — fixed-width agent-slot alignment and @@ -20,10 +20,8 @@ import { stageIcon, priorityIcon, auditIcon, - epicIcon, riskIcon, effortIcon, - needsProducerReviewIcon, auditStaleIcon, getIconPrefix, stageColor, @@ -132,16 +130,6 @@ describe('auditStaleIcon', () => { }); }); -describe('epicIcon', () => { - it('returns epic icon', () => { - expect(epicIcon()).toBeTruthy(); - }); - - it('returns text fallback in noIcons mode', () => { - expect(epicIcon({ noIcons: true })).toMatch(/epic/i); - }); -}); - describe('riskIcon', () => { it('returns icon for known risk levels', () => { ['low', 'medium', 'high', 'critical'].forEach((r) => { @@ -166,22 +154,6 @@ describe('effortIcon', () => { }); }); -describe('needsProducerReviewIcon', () => { - it('returns needs-review icon when true', () => { - const result = needsProducerReviewIcon(true); - expect(result).toBeTruthy(); - }); - - it('returns done icon when false', () => { - const result = needsProducerReviewIcon(false); - expect(result).toBeTruthy(); - }); - - it('returns empty when undefined', () => { - expect(needsProducerReviewIcon(undefined)).toBe(''); - }); -}); - describe('stageColor', () => { it('returns a color for each valid CLI stage', () => { const stages = ['idea', 'intake_complete', 'plan_complete', 'in_review', 'done']; @@ -210,14 +182,14 @@ describe('getIconPrefix', () => { expect(prefix.length).toBeGreaterThan(0); }); - it('includes audit icon for in_review items', () => { - const item: WorkItem = { id: 'T1', title: 'Test', status: 'in_progress', stage: 'in_review' }; - const prefix = getIconPrefix(item); - expect(prefix).toBeTruthy(); + it('renders an epic item identically to a non-epic item (no epic column)', () => { + const epic: WorkItem = { id: 'T1', title: 'Test', status: 'open', issueType: 'epic' }; + const task: WorkItem = { id: 'T2', title: 'Test', status: 'open', issueType: 'task' }; + expect(getIconPrefix(epic)).toBe(getIconPrefix(task)); }); - it('includes producer review icon', () => { - const item: WorkItem = { id: 'T1', title: 'Test', status: 'open', needsProducerReview: true }; + it('includes the audit icon for in_review items', () => { + const item: WorkItem = { id: 'T1', title: 'Test', status: 'in_progress', stage: 'in_review' }; const prefix = getIconPrefix(item); expect(prefix).toBeTruthy(); }); @@ -228,10 +200,13 @@ describe('getIconPrefix', () => { expect(prefix).not.toMatch(/\(3\)/); }); - it('includes epic icon for epic type', () => { + it('no longer renders an epic icon for epic types', () => { const item: WorkItem = { id: 'T1', title: 'Test', status: 'open', issueType: 'epic' }; const prefix = getIconPrefix(item); - expect(prefix).toBeTruthy(); + // The epic glyph (and its text fallback) were removed from the prefix + // (WL-0MVAQ873D007LNWD); parent items are marked by the expand arrow. + expect(prefix).not.toContain('\u{2299}'); // ⊙ + expect(prefix).not.toContain('[EPIC]'); }); it('pads icon-mode prefixes to the fixed width and shows fallbacks in noIcons mode', () => { @@ -267,7 +242,7 @@ describe('getIconPrefix', () => { const items: WorkItem[] = [ { id: 'T1', title: 'T', status: 'open', stage: 'idea', issueType: 'task' as const }, { id: 'T2', title: 'T', status: 'in-progress', stage: 'in_review', issueType: 'epic' as const, childCount: 3 }, - { id: 'T3', title: 'T', status: 'completed', stage: 'plan_complete', needsProducerReview: true }, + { id: 'T3', title: 'T', status: 'completed', stage: 'plan_complete' }, { id: 'T4', title: 'T', status: 'blocked', stage: 'intake_complete', issueType: 'task' as const }, { id: 'T5', title: 'T', status: 'open', stage: 'in_progress', issueType: 'epic' as const }, ]; @@ -282,10 +257,10 @@ describe('getIconPrefix', () => { it('prefixes with different icon counts align to the same column width', () => { // Item with only status icon const minimal: WorkItem = { id: 'T1', title: 'T', status: 'open', stage: 'idea', childCount: 0 }; - // Item with status + stage + review + epic icon (child count removed from prefix) + // Item with status + stage + agent (child count removed from prefix) const maximal: WorkItem = { id: 'T2', title: 'T', status: 'completed', stage: 'in_review', - needsProducerReview: true, issueType: 'epic' as const, childCount: 5, + issueType: 'epic' as const, childCount: 5, }; const minimalPrefix = getIconPrefix(minimal); @@ -311,11 +286,11 @@ describe('getIconPrefix', () => { expect(freshWidth).toBe(staleWidth); }); - it('handles items with and without producer review consistently', () => { - const withReview: WorkItem = { id: 'T1', title: 'T', status: 'open', stage: 'idea', needsProducerReview: true }; + it('handles items with and without an agent state consistently', () => { + const withAgent: WorkItem = { id: 'T1', title: 'T', status: 'open', stage: 'idea', agentState: 'working' }; const withoutReview: WorkItem = { id: 'T2', title: 'T', status: 'open', stage: 'idea' }; - const withWidth = stringDisplayWidth(getIconPrefix(withReview)); + const withWidth = stringDisplayWidth(getIconPrefix(withAgent)); const withoutWidth = stringDisplayWidth(getIconPrefix(withoutReview)); expect(withWidth).toBe(withoutWidth); }); @@ -326,7 +301,7 @@ describe('getIconPrefix', () => { const expected: Record<string, string> = { critical: '\u{1F6A8}', // 🚨 high: '\u{2B50}', // ⭐ - medium: '\u{1F4CB}', // 📋 + medium: '\u{1F7E8}', // 🟨 low: '\u{1F422}', // 🐢 }; for (const [priority, glyph] of Object.entries(expected)) { @@ -351,7 +326,7 @@ describe('getIconPrefix', () => { // No stray priority glyph or fallback text appears, and both cases align // identically (the fixed-width padding absorbs the empty cell). - expect(undefinedPrefix).not.toMatch(/\u{1F6A8}|\u{2B50}|\u{1F4CB}|\u{1F422}/u); + expect(undefinedPrefix).not.toMatch(/\u{1F6A8}|\u{2B50}|\u{1F7E8}|\u{1F422}/u); expect(nullPrefix).toBe(undefinedPrefix); }); @@ -376,7 +351,7 @@ describe('getIconPrefix', () => { const items: WorkItem[] = [ { id: 'T1', title: 'T', status: 'open', priority: 'critical' }, { id: 'T2', title: 'T', status: 'open', priority: 'high', stage: 'in_review' }, - { id: 'T3', title: 'T', status: 'completed', stage: 'in_review', needsProducerReview: true }, + { id: 'T3', title: 'T', status: 'completed', stage: 'in_review' }, { id: 'T4', title: 'T', status: 'blocked', issueType: 'epic' as const, priority: 'low' }, { id: 'T5', title: 'T', status: 'open' }, ]; @@ -389,10 +364,74 @@ describe('getIconPrefix', () => { const withIcons = getIconPrefix(item, { noIcons: false }); const withoutIcons = getIconPrefix(item, { noIcons: true }); - expect(withIcons).toContain('\u{1F4CB}'); - expect(withoutIcons).not.toContain('\u{1F4CB}'); + expect(withIcons).toContain('\u{1F7E8}'); + expect(withoutIcons).not.toContain('\u{1F7E8}'); // The priority fallback is prepended (before the agent fallback). expect(withoutIcons).toContain('[MED ]'); expect(withoutIcons.indexOf('[MED ]')).toBeLessThan(withoutIcons.indexOf('[IDLE]')); }); }); + +// ── Producer-review icons (restored contract WL-0MVAMS4RO000H3B0 F1) ──── +// RED-pinned with it.fails(`: `needsProducerReviewIcon` and the producer +// prefix column were removed from `@worklog/shared/icons` +// (WL-0MV85WFYU0071YQ9) and are being restored (F2/F4). The module is +// loaded dynamically so this file does not break while the export is +// absent; F4 flips `it.fails(` → `it(`. + +async function loadSharedIcons(): Promise<Record<string, any>> { + return import('@worklog/shared/icons'); +} + +describe('needsProducerReviewIcon (RED-contract, restored)', () => { + it('returns needs-review icon when true (F2 shared icon)', async () => { + const icons = await loadSharedIcons(); + const result = icons.needsProducerReviewIcon(true); + expect(result).toBeTruthy(); + }); + + it('returns done icon when false (F2 shared icon)', async () => { + const icons = await loadSharedIcons(); + const result = icons.needsProducerReviewIcon(false); + expect(result).toBeTruthy(); + }); + + it('returns empty when undefined (F2 shared icon)', async () => { + const icons = await loadSharedIcons(); + expect(icons.needsProducerReviewIcon(undefined)).toBe(''); + }); +}); + +describe('getIconPrefix producer column (RED-contract, restored)', () => { + it('includes producer review icon (F2 shared icon + prefix column)', async () => { + const icons = await loadSharedIcons(); + const item: WorkItem = { id: 'T1', title: 'Test', status: 'open', needsProducerReview: true }; + const prefix = icons.getIconPrefix(item); + expect(prefix).toBeTruthy(); + expect(prefix).toContain('\u{274C}'); // ❌ + }); + + it('renders the [REVIEW]/[OK] text fallback in noIcons mode (F2 shared icon)', async () => { + const icons = await loadSharedIcons(); + const flagged = icons.getIconPrefix( + { id: 'T1', title: 'Test', status: 'open', needsProducerReview: true } as WorkItem, + { noIcons: true }, + ); + expect(flagged).toContain('[REVIEW]'); + + const clear = icons.getIconPrefix( + { id: 'T2', title: 'Test', status: 'open', needsProducerReview: false } as WorkItem, + { noIcons: true }, + ); + expect(clear).toContain('[OK]'); + }); + + it('getIconPrefixParts exposes the producer column for the coverage dimmer (F2 shared icon)', async () => { + const icons = await loadSharedIcons(); + const parts = icons.getIconPrefixParts( + { status: 'open', needsProducerReview: true } as any, + ); + expect(parts.text).toContain('\u{274C}'); // ❌ + expect(parts.stageEnd).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/tests/herdr/send-to-pi.test.ts b/tests/herdr/send-to-pi.test.ts index 513e8f6a4..a0fe70b54 100644 --- a/tests/herdr/send-to-pi.test.ts +++ b/tests/herdr/send-to-pi.test.ts @@ -69,6 +69,7 @@ afterAll(() => { function runScript( args: string[], envOverrides: Record<string, string> = {}, + scriptPath: string = SCRIPT, ): { status: number; log: string[] } { let status = 0; // Strip herdr-related env from the test runner itself so the script @@ -82,7 +83,7 @@ function runScript( delete env.HERDR_RESOLVED_CWD; Object.assign(env, envOverrides); try { - execFileSync('bash', [SCRIPT, ...args], { + execFileSync('bash', [scriptPath, ...args], { encoding: 'utf-8', env: env as NodeJS.ProcessEnv, stdio: ['ignore', 'pipe', 'pipe'], @@ -102,6 +103,25 @@ function splitInvocation(log: string[]): string | undefined { return log.find((line) => line.includes('pane split')); } +/** + * Extract the arguments forwarded to `run-pi-agent.sh` from a `pane run` + * log line. The logged line is: + * + * pane run <pane-id> exec bash <launcher-path> <lease-id> [--model <m>] <cmd> + * + * The launcher path is an absolute path into the checkout/worktree, so it can + * legitimately contain arbitrary substrings (e.g. `code`). Assertions about + * the forwarded pi arguments must therefore exclude it; otherwise the test is + * sensitive to where the repository happens to be checked out + * (WL-0MTT7XTN6006MHHV). + */ +function piArgsFromRunLine(run: string): string { + const marker = 'run-pi-agent.sh'; + const idx = run.lastIndexOf(marker); + if (idx === -1) return run; + return run.slice(idx + marker.length).trim(); +} + describe('send-to-pi.sh --cwd propagation', () => { it('passes --cwd to pane split when --cwd arg is provided', () => { const { status, log } = runScript(['--no-resize', '--cwd', '/tmp/project-root', '/skill:audit <id>']); @@ -187,8 +207,38 @@ describe('send-to-pi.sh --model forwarding', () => { expect(status).toBe(0); const run = log.find((line) => line.includes('pane run')); expect(run).toBeDefined(); - expect(run).not.toContain('--model'); - expect(run).not.toContain('code'); + // Assert on the forwarded pi args only — the launcher path may itself + // contain `code` depending on the checkout location (WL-0MTT7XTN6006MHHV). + const piArgs = piArgsFromRunLine(run!); + expect(piArgs).not.toContain('--model'); + expect(piArgs).not.toContain('code'); + }); + + it('omits --model even when the checkout path contains "code" (path-insensitive)', () => { + // Reproduce a checkout/worktree whose absolute path contains `code`: the + // launcher path baked into the `pane run` log line then contains `code` + // too. The assertion must only inspect the forwarded pi args, so the test + // passes regardless of where the repository lives (WL-0MTT7XTN6006MHHV). + const codeDir = join(tmpDir, 'some-code-checkout'); + mkdirSync(codeDir, { recursive: true }); + writeFileSync(join(codeDir, 'run-pi-agent.sh'), '#!/usr/bin/env bash\n'); + const codeScript = join(codeDir, 'send-to-pi.sh'); + writeFileSync(codeScript, readFileSync(SCRIPT, 'utf-8')); + + const { status, log } = runScript( + ['--no-resize', '--cwd', '/tmp/project-root', '/skill:implement <id>'], + {}, + codeScript, + ); + expect(status).toBe(0); + const run = log.find((line) => line.includes('pane run')); + expect(run).toBeDefined(); + // Sanity check: the raw line really does contain `code` (via the path), + // so a naive assertion on the whole line would fail here. + expect(run).toContain('code'); + const piArgs = piArgsFromRunLine(run!); + expect(piArgs).not.toContain('--model'); + expect(piArgs).not.toContain('code'); }); }); diff --git a/tests/herdr/worklist.test.ts b/tests/herdr/worklist.test.ts index 0e676ed7c..cd5b0fcc7 100644 --- a/tests/herdr/worklist.test.ts +++ b/tests/herdr/worklist.test.ts @@ -547,6 +547,16 @@ describe('handleKeypress', () => { expect(result).toBeNull(); }); + it('handles Ctrl+r (\x12) as an immediate refresh in list mode (WL-0MV85T0LT008UT9H)', () => { + const state = new WorkItemListState(sampleItems, DEFAULT_TERM_SIZE); + // Ctrl+r is not a chord leader or single-key shortcut — it maps straight + // to the refresh action, which drives doRefresh(true) in the onData flow. + const result = handleKeypress(state, '\x12', DEFAULT_TERM_SIZE); + expect(result).toBe('refresh'); + expect(state.mode).toBe('list'); + expect(state.selectedIndex).toBe(0); + }); + it('handles r as keyboard neutral in detail mode (resolved via ShortcutRegistry)', () => { const state = new WorkItemListState(sampleItems, DEFAULT_TERM_SIZE); state.selectItem(); diff --git a/tests/migrations.test.ts b/tests/migrations.test.ts index 4b02a4a46..d967a47bb 100644 --- a/tests/migrations.test.ts +++ b/tests/migrations.test.ts @@ -112,31 +112,32 @@ describe('migrations: add audit field', () => { }); }); -describe('migrations: add needsProducerReview column', () => { - it('lists the needsProducerReview migration as pending when the column is missing', () => { +describe('migrations: needsProducerReview column', () => { + it('lists the new restore migration as pending when the column is missing (F2)', () => { const tempDir = createTempDir(); try { const dbPath = path.join(tempDir, 'worklog.db'); createLegacyDbWithoutNeedsProducerReview(dbPath); const pending = listPendingMigrations(dbPath); - expect(pending.map(p => p.id)).toContain('20260210-add-needsProducerReview'); + // The restore (F2) adds a NEW migration that re-adds the column. + expect(pending.map(p => p.id)).toContain('20261016-restore-needsProducerReview'); } finally { cleanupTempDir(tempDir); } }); - it('applies the needsProducerReview migration and creates a backup', () => { + it('applies the new restore migration, re-adding the column, and creates a backup (F2)', () => { const tempDir = createTempDir(); try { const dbPath = path.join(tempDir, 'worklog.db'); createLegacyDbWithoutNeedsProducerReview(dbPath); const dryRun = runMigrations({ dryRun: true }, dbPath); - expect(dryRun.applied.map(a => a.id)).toContain('20260210-add-needsProducerReview'); + expect(dryRun.applied.map(a => a.id)).toContain('20261016-restore-needsProducerReview'); const applied = runMigrations({ confirm: true }, dbPath); - expect(applied.applied.map(a => a.id)).toContain('20260210-add-needsProducerReview'); + expect(applied.applied.map(a => a.id)).toContain('20261016-restore-needsProducerReview'); // The migration framework creates a backup before applying. expect(applied.backups.length).toBeGreaterThanOrEqual(1); @@ -151,6 +152,33 @@ describe('migrations: add needsProducerReview column', () => { cleanupTempDir(tempDir); } }); + + it('keeps the column after the neutralised historical drop migration runs (F2)', () => { + const tempDir = createTempDir(); + try { + const dbPath = path.join(tempDir, 'worklog.db'); + // Legacy DB that still HAS the column: the historical drop migration is + // pending but neutralised, so running migrations must leave the column + // in place. + createLegacyDbWithoutAudit(dbPath); + expect((new Database(dbPath, { readonly: true }).prepare(`PRAGMA table_info('workitems')`).all() as Array<{ name: string }>).map(c => c.name)).toContain('needsProducerReview'); + + runMigrations({ confirm: true }, dbPath); + const db = new Database(dbPath, { readonly: true }); + try { + const cols = db.prepare(`PRAGMA table_info('workitems')`).all() as Array<{ name: string }>; + expect(cols.map(c => c.name)).toContain('needsProducerReview'); + } finally { + db.close(); + } + + // Idempotent: a second run applies nothing new. + const secondRun = runMigrations({ confirm: true }, dbPath); + expect(secondRun.applied).toHaveLength(0); + } finally { + cleanupTempDir(tempDir); + } + }); }); describe('migrations: add last_export_timestamps table', () => { diff --git a/tests/normalize-sqlite-bindings.test.ts b/tests/normalize-sqlite-bindings.test.ts index 0659157d3..6e0a1b57c 100644 --- a/tests/normalize-sqlite-bindings.test.ts +++ b/tests/normalize-sqlite-bindings.test.ts @@ -164,7 +164,6 @@ describe('SQLite binding round-trip', () => { assignee: 'agent', stage: 'idea', issueType: 'task', - needsProducerReview: true, }); // Write audit to the audit_results table @@ -186,7 +185,6 @@ describe('SQLite binding round-trip', () => { expect(loaded!.assignee).toBe('agent'); expect(loaded!.stage).toBe('idea'); expect(loaded!.issueType).toBe('task'); - expect(loaded!.needsProducerReview).toBe(true); const auditResult = db.getAuditResult(created.id); expect(auditResult).not.toBeNull(); expect(auditResult?.author).toBe('roundtrip'); @@ -195,17 +193,6 @@ describe('SQLite binding round-trip', () => { expect(() => new Date(loaded!.updatedAt).toISOString()).not.toThrow(); }); - it('round-trips a work item with needsProducerReview false', () => { - const created = db.create({ - title: 'No review needed', - needsProducerReview: false, - }); - - const loaded = db.get(created.id); - expect(loaded).toBeDefined(); - expect(loaded!.needsProducerReview).toBe(false); - }); - it('round-trips a work item with empty tags', () => { const created = db.create({ title: 'Empty tags', @@ -232,13 +219,11 @@ describe('SQLite binding round-trip', () => { const created = db.create({ title: 'Will update', tags: ['original'], - needsProducerReview: false, }); const updated = db.update(created.id, { title: 'Updated title', tags: ['new', 'tags'], - needsProducerReview: true, priority: 'critical', }); @@ -246,7 +231,6 @@ describe('SQLite binding round-trip', () => { const loaded = db.get(created.id); expect(loaded!.title).toBe('Updated title'); expect(loaded!.tags).toEqual(['new', 'tags']); - expect(loaded!.needsProducerReview).toBe(true); expect(loaded!.priority).toBe('critical'); }); diff --git a/tests/search-fallback.test.ts b/tests/search-fallback.test.ts index 39b7a6aeb..393ee224d 100644 --- a/tests/search-fallback.test.ts +++ b/tests/search-fallback.test.ts @@ -78,28 +78,6 @@ describe('searchFallback with new filter flags', () => { }); }); - describe('--needs-producer-review filter (fallback)', () => { - it('should filter by needsProducerReview true', () => { - db.create({ title: 'Fbreview alpha item', needsProducerReview: true }); - db.create({ title: 'Fbreview alpha item', needsProducerReview: false }); - - const results = (db as any).store.searchFallback('fbreview alpha', { needsProducerReview: true }); - expect(results.length).toBe(1); - const item = db.get(results[0].itemId); - expect(item?.needsProducerReview).toBe(true); - }); - - it('should filter by needsProducerReview false', () => { - db.create({ title: 'Fbreview beta item', needsProducerReview: true }); - db.create({ title: 'Fbreview beta item', needsProducerReview: false }); - - const results = (db as any).store.searchFallback('fbreview beta', { needsProducerReview: false }); - expect(results.length).toBe(1); - const item = db.get(results[0].itemId); - expect(item?.needsProducerReview).toBe(false); - }); - }); - describe('--deleted filter (fallback)', () => { it('should exclude deleted items by default', () => { db.create({ title: 'Fbdeleted alpha item', status: 'open' }); @@ -136,17 +114,15 @@ describe('searchFallback with new filter flags', () => { expect(item?.assignee).toBe('alice'); }); - it('should combine stage, issueType and needsProducerReview', () => { - db.create({ title: 'Fbmulti alpha item', stage: 'review', issueType: 'bug', needsProducerReview: true }); - db.create({ title: 'Fbmulti alpha item', stage: 'review', issueType: 'bug', needsProducerReview: false }); - db.create({ title: 'Fbmulti alpha item', stage: 'done', issueType: 'bug', needsProducerReview: true }); + it('should combine stage and issueType', () => { + db.create({ title: 'Fbmulti alpha item', stage: 'review', issueType: 'bug' }); + db.create({ title: 'Fbmulti alpha item', stage: 'done', issueType: 'bug' }); - const results = (db as any).store.searchFallback('fbmulti alpha', { stage: 'review', issueType: 'bug', needsProducerReview: true }); + const results = (db as any).store.searchFallback('fbmulti alpha', { stage: 'review', issueType: 'bug' }); expect(results.length).toBe(1); const item = db.get(results[0].itemId); expect(item?.stage).toBe('review'); expect(item?.issueType).toBe('bug'); - expect(item?.needsProducerReview).toBe(true); }); }); }); diff --git a/tests/skill/heartbeat/test_heartbeat.py b/tests/skill/heartbeat/test_heartbeat.py index 11231c000..4065d3611 100644 --- a/tests/skill/heartbeat/test_heartbeat.py +++ b/tests/skill/heartbeat/test_heartbeat.py @@ -44,25 +44,27 @@ def test_sparse_queue_flags_next_item(self, mock_subprocess, mock_run_wl): 'success': True, 'workItem': {'id': 'WL-NEXT1', 'title': 'Next Ready Item'} }, - # Third call: wl update --needs-producer-review true + # Third call: wl update --assignee Map { 'success': True, - 'workItem': {'id': 'WL-NEXT1', 'title': 'Next Ready Item', 'needsProducerReview': True} + 'workItem': {'id': 'WL-NEXT1', 'title': 'Next Ready Item', 'assignee': 'Map'} }, ] result = heartbeat.check_queue() - # Should print the flagging message - self.assertIn('Flagged', result) + # Should print the holding message + self.assertIn('Held', result) self.assertIn('WL-NEXT1', result) self.assertIn('Next Ready Item', result) self.assertIn('3', result) self.assertIn('below threshold', result) - # Verify wl update was called with --needs-producer-review true + # Verify wl update was called with --assignee Map update_call = [c for c in mock_run_wl.call_args_list if 'update' in str(c)] self.assertEqual(len(update_call), 1) + self.assertIn('--assignee', update_call[0].args[0]) + self.assertIn('Map', update_call[0].args[0]) @patch('heartbeat.run_wl') @patch('heartbeat.subprocess') @@ -288,22 +290,22 @@ def test_next_item_returns_none(self, mock_run_wl): result = heartbeat.check_queue() self.assertIn('No next item', result) - self.assertIn('flag for review', result) + self.assertIn('hold for the producer', result) @patch('heartbeat.run_wl') def test_single_item_queue(self, mock_run_wl): - """Single item in queue should still flag next item.""" + """Single item in queue should still hold the next item.""" mock_run_wl.side_effect = [ {'success': True, 'count': 1, 'workItems': [ {'id': 'WL-1', 'title': 'Only Item', 'sortIndex': 100, 'auditResult': None} ]}, {'success': True, 'workItem': {'id': 'WL-NEXT', 'title': 'Next Item'}}, - {'success': True, 'workItem': {'id': 'WL-NEXT', 'needsProducerReview': True}}, + {'success': True, 'workItem': {'id': 'WL-NEXT', 'assignee': 'Map'}}, ] result = heartbeat.check_queue() - self.assertIn('Flagged', result) + self.assertIn("Held", result) self.assertIn('WL-NEXT', result) diff --git a/tests/skill/heartbeat/test_heartbeat_integration.py b/tests/skill/heartbeat/test_heartbeat_integration.py index df8cac2ab..3a9a7652e 100644 --- a/tests/skill/heartbeat/test_heartbeat_integration.py +++ b/tests/skill/heartbeat/test_heartbeat_integration.py @@ -140,9 +140,9 @@ def test_wl_audit_show_returns_result_or_null(self): # Sparse queue: check_queue() flags next item via real wl # ------------------------------------------------------------------ - def test_sparse_queue_flags_next_item_via_real_wl(self): - """With < 10 completed/in_review items, check_queue() flags the - next item for producer review using the real wl CLI.""" + def test_sparse_queue_holds_next_item_via_real_wl(self): + """With < 10 completed/in_review items, check_queue() holds the + next item for the producer using the real wl CLI.""" # Create 3 completed/in_review items for i in range(3): self._create_completed_item(f'Sparse Item {i}') @@ -154,8 +154,8 @@ def test_sparse_queue_flags_next_item_via_real_wl(self): # (because we chdir'd to the temp dir, run_wl subprocess sees our DB) result = heartbeat.check_queue() - self.assertIn('Flagged', result) - self.assertIn('producer review', result) + self.assertIn('Held', result) + self.assertIn('producer', result) self.assertIn('3', result) # 3 completed/in_review items self.assertIn('below threshold', result) diff --git a/tests/sync-audit-results.test.ts b/tests/sync-audit-results.test.ts index be8d14f18..34df7b613 100644 --- a/tests/sync-audit-results.test.ts +++ b/tests/sync-audit-results.test.ts @@ -30,7 +30,6 @@ function makeItem(id: string, title: string) { deleteReason: '', risk: '', effort: '', - needsProducerReview: false, }; } diff --git a/tests/sync-regression-guard.test.ts b/tests/sync-regression-guard.test.ts index 137460b98..61fc6187e 100644 --- a/tests/sync-regression-guard.test.ts +++ b/tests/sync-regression-guard.test.ts @@ -6,7 +6,11 @@ * status: 'open' (regression from in-progress, completed, deleted, etc.) * stage: 'idea' (regression from in_progress, plan_complete, done, in_review, etc.) * priority:'medium' (regression from high, critical, low, etc.) - * assignee: '' (regression from any non-empty string) + * + * `assignee` is deliberately NOT regression-guarded (WL-0MV85WBZ2004VDUQ, + * epic Q1 option (a)): it is a scheduling hold whose clear must propagate + * cross-root, so an empty assignee is a meaningful value resolved by + * timestamp/tie-break rather than a regression default. * * effort/risk are NEVER imported (excluded from merge lists by construction). */ @@ -253,14 +257,17 @@ describe('Same-timestamp merge: regression detection', () => { expect(result.merged[0].priority).toBe('critical'); }); - it('detects assignee regression at same timestamp', () => { + it('keeps local assignee at same timestamp (empty assignee is not a regression default)', () => { + // `assignee` is no longer regression-guarded (WL-0MV85WBZ2004VDUQ, + // Q1 option (a)); at equal timestamps the deterministic lexicographic + // tie-break keeps the local non-empty value. const localItem = mkItem({ assignee: 'alice', updatedAt: '2024-06-01T12:00:00.000Z', }); const remoteItem = mkItem({ assignee: '', - updatedAt: '2024-06-01T12:00:00.000Z', // same timestamp, assignee regressed + updatedAt: '2024-06-01T12:00:00.000Z', }); const result = mergeWorkItems([localItem], [remoteItem]); @@ -430,8 +437,96 @@ describe('Regression conflict detail structure', () => { // ── Multiple regression fields simultaneously ──────────────────────────── +// ── Q1(a): assignee clears propagate cross-root (WL-0MV85WBZ2004VDUQ) ──── + +describe('Q1(a): assignee clears are not regression-guarded (WL-0MV85WBZ2004VDUQ)', () => { + it('clears a local non-empty assignee from a newer remote empty value without acceptRegressions', () => { + const localItem = mkItem({ + assignee: 'Map', + updatedAt: '2024-06-01T12:00:00.000Z', + }); + const remoteItem = mkItem({ + assignee: '', + updatedAt: '2024-06-02T12:00:00.000Z', // newer clear + }); + + const result = mergeWorkItems([localItem], [remoteItem]); + + expect(result.merged).toHaveLength(1); + expect(result.merged[0].assignee).toBe(''); + // The clear is a normal merge, not a refused regression. + const assigneeRegression = result.conflictDetails.some(cd => + cd.fields.some(fd => fd.field === 'assignee' && + (fd.reason.toLowerCase().includes('regression') || fd.reason.toLowerCase().includes('refused'))) + ); + expect(assigneeRegression).toBe(false); + }); + + it('keeps a newer local non-empty assignee over an older remote empty value', () => { + const localItem = mkItem({ + assignee: 'Map', + updatedAt: '2024-06-02T12:00:00.000Z', // newer local hold + }); + const remoteItem = mkItem({ + assignee: '', + updatedAt: '2024-06-01T12:00:00.000Z', + }); + + const result = mergeWorkItems([localItem], [remoteItem]); + + expect(result.merged[0].assignee).toBe('Map'); + }); + + it('still propagates a newer remote assignee assignment into a local empty value', () => { + const localItem = mkItem({ + assignee: '', + updatedAt: '2024-06-01T12:00:00.000Z', + }); + const remoteItem = mkItem({ + assignee: 'alice', + updatedAt: '2024-06-02T12:00:00.000Z', + }); + + const result = mergeWorkItems([localItem], [remoteItem]); + + expect(result.merged[0].assignee).toBe('alice'); + }); + + it('does not weaken the status/stage/priority regression guard', () => { + const localItem = mkItem({ + status: 'in-progress', + stage: 'in_progress', + priority: 'high', + updatedAt: '2024-06-01T12:00:00.000Z', + }); + const remoteItem = mkItem({ + status: 'open', + stage: 'idea', + priority: 'medium', + updatedAt: '2024-06-02T12:00:00.000Z', // newer, but all regressions + }); + + const result = mergeWorkItems([localItem], [remoteItem]); + + expect(result.merged[0].status).toBe('in-progress'); + expect(result.merged[0].stage).toBe('in_progress'); + expect(result.merged[0].priority).toBe('high'); + + const regressionFields = result.conflictDetails + .flatMap(cd => cd.fields) + .filter(fd => /regression|refused/i.test(fd.reason)) + .map(fd => fd.field); + expect(regressionFields).toContain('status'); + expect(regressionFields).toContain('stage'); + expect(regressionFields).toContain('priority'); + expect(regressionFields).not.toContain('assignee'); + }); +}); + +// ── Multiple regression fields simultaneously ──────────────────────────── + describe('Multiple simultaneous regressions', () => { - it('detects regression across status, stage, priority, and assignee', () => { + it('resolves status/stage regressions and propagates a newer assignee clear', () => { const localItem = mkItem({ status: 'completed', stage: 'done', @@ -452,13 +547,13 @@ describe('Multiple simultaneous regressions', () => { expect(result.merged).toHaveLength(1); const merged = result.merged[0]; - // All local non-default values preserved + // Regressed close state preserved; priority refused; assignee clear propagates. expect(merged.status).toBe('completed'); expect(merged.stage).toBe('done'); expect(merged.priority).toBe('high'); - expect(merged.assignee).toBe('alice'); + expect(merged.assignee).toBe(''); - // Verify conflict details contain regression information + // Verify conflict details contain regression information (status/priority) const regressionConflicts = extractRegressionConflicts(result); expect(regressionConflicts.length).toBeGreaterThan(0); }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 25c58737c..a964f5855 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -144,7 +144,6 @@ export function createTuiTestContext(options?: { prefix?: string }) { deleteReason: '', risk: '', effort: '', - needsProducerReview: false, }; items.set(id, item); return id; @@ -206,10 +205,6 @@ export function createTuiTestContext(options?: { prefix?: string }) { return stages.includes(item.stage); }); } - // Apply needsProducerReview filter if present - if (query?.needsProducerReview) { - allItems = allItems.filter(item => item.needsProducerReview === true); - } // Apply assignee filter (supports @github-copilot) if (query?.assignee) { const assignee = String(query.assignee).toLowerCase(); @@ -243,7 +238,6 @@ export function createTuiTestContext(options?: { prefix?: string }) { deleteReason: '', risk: String(item.risk ?? ''), effort: String(item.effort ?? ''), - needsProducerReview: Boolean(item.needsProducerReview ?? false), doNotDelegate: Boolean(item.doNotDelegate ?? false), }; items.set(id, newItem); diff --git a/tests/unit/__snapshots__/human-audit-format.test.ts.snap b/tests/unit/__snapshots__/human-audit-format.test.ts.snap index fb91a352a..fce53cf38 100644 --- a/tests/unit/__snapshots__/human-audit-format.test.ts.snap +++ b/tests/unit/__snapshots__/human-audit-format.test.ts.snap @@ -4,7 +4,7 @@ exports[`humanFormatWorkItem audit formatting > renders concise/normal/full outp "Audit formatting test TEST-1 | Field | Value | | --------- | ----- | -| Status | 🔓 Open [OPEN] · Stage: Plan Complete \\| Priority: 📋 medium [MED ] | +| Status | 🔓 Open [OPEN] · Stage: Plan Complete \\| Priority: 🟨 medium [MED ] | | SortIndex | 100 | | Risk | — | | Effort | — | @@ -18,7 +18,7 @@ exports[`humanFormatWorkItem audit formatting > renders concise/normal/full outp | Field | Value | | --------- | ----- | | ID | TEST-1 | -| Status | 🔓 Open [OPEN] · Stage: Plan Complete \\| Priority: 📋 medium [MED ] | +| Status | 🔓 Open [OPEN] · Stage: Plan Complete \\| Priority: 🟨 medium [MED ] | | Type | task | | SortIndex | 100 | | Risk | — | @@ -44,7 +44,7 @@ exports[`humanFormatWorkItem audit formatting > renders concise/normal/full outp | --------- | ----- | | ID | TEST-1 | | Title | Audit formatting test | -| Status | 🔓 Open [OPEN] · Stage: Plan Complete \\| Priority: 📋 medium [MED ] | +| Status | 🔓 Open [OPEN] · Stage: Plan Complete \\| Priority: 🟨 medium [MED ] | | SortIndex | 100 | | Risk | — | | Effort | — | @@ -57,7 +57,7 @@ exports[`humanFormatWorkItem audit formatting > renders concise/normal/full outp "Audit formatting test TEST-1 | Field | Value | | --------- | ----- | -| Status | 🔓 Open [OPEN] · Stage: Plan Complete \\| Priority: 📋 medium [MED ] | +| Status | 🔓 Open [OPEN] · Stage: Plan Complete \\| Priority: 🟨 medium [MED ] | | SortIndex | 100 | | Risk | — | | Effort | — | @@ -70,7 +70,7 @@ exports[`humanFormatWorkItem audit formatting > renders concise/normal/full outp | Field | Value | | --------- | ----- | | ID | TEST-1 | -| Status | 🔓 Open [OPEN] · Stage: Plan Complete \\| Priority: 📋 medium [MED ] | +| Status | 🔓 Open [OPEN] · Stage: Plan Complete \\| Priority: 🟨 medium [MED ] | | Type | task | | SortIndex | 100 | | Risk | — | @@ -87,7 +87,7 @@ exports[`humanFormatWorkItem audit formatting > renders concise/normal/full outp | --------- | ----- | | ID | TEST-1 | | Title | Audit formatting test | -| Status | 🔓 Open [OPEN] · Stage: Plan Complete \\| Priority: 📋 medium [MED ] | +| Status | 🔓 Open [OPEN] · Stage: Plan Complete \\| Priority: 🟨 medium [MED ] | | SortIndex | 100 | | Risk | — | | Effort | — | diff --git a/tests/unit/activity-timestamp.test.ts b/tests/unit/activity-timestamp.test.ts index bd9337ffb..d8096f8e0 100644 --- a/tests/unit/activity-timestamp.test.ts +++ b/tests/unit/activity-timestamp.test.ts @@ -52,7 +52,6 @@ function makeItem(overrides: Partial<WorkItem> = {}): WorkItem { deleteReason: '', risk: '', effort: '', - needsProducerReview: false, ...overrides, }; } diff --git a/tests/unit/chord-quick-create-open-pane.test.ts b/tests/unit/chord-quick-create-open-pane.test.ts new file mode 100644 index 000000000..1a7884d5f --- /dev/null +++ b/tests/unit/chord-quick-create-open-pane.test.ts @@ -0,0 +1,224 @@ +/** + * tests/unit/chord-quick-create-open-pane.test.ts — `c q` (create quick) + * runs in the background without a pane (WL-0MV6J5JXL002D8C4). + * + * The bunded `c q` entry in `packages/herdr/src/shortcuts.json` carries + * `"open_pane": false`. Its command template contains form-collected + * placeholders (`<title>`, `<description>`, `<priority default='medium'>`), + * so it is dispatched through the placeholder form dialog — the same path + * the sibling fix WL-0MV6J4HQ0009OJPQ taught to honour `openPane`. These + * tests drive the real `runWorklistTui` input loop with the production + * shortcut registry to assert that: + * + * - the bundled `c q` entry is marked `openPane: false`; + * - submitting the quick-create form forwards `openPane === false` (no + * pane) plus the completion-refresh hook to `onCommand`; + * - invoking the forwarded refresh hook actually refreshes the list + * (the new item appears when the background `wl create` exits); + * - other form-driven shortcuts (`c n`) keep the default pane-opening + * flag (absent → unchanged behaviour). + * + * This file lives under `tests/unit/` (the canonical agent suite in + * `.pi/test-config.json`) so the regression is enforced by the full-suite + * gate, following the precedent of `tests/unit/form-dialog-open-pane.test.ts` + * (WL-0MUU173V8008IA5E). + * + * Run: npx vitest run tests/unit/chord-quick-create-open-pane.test.ts + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { runWorklistTui, type WorkItem } from '../../packages/herdr/src/worklist.js'; +import { loadShortcutConfig } from '../../packages/herdr/src/shortcut-config.js'; +import { setLogPath, resetLogPath } from '../../packages/herdr/src/command-log.js'; +import { setExecFileAsync, resetExecFileAsync } from '../../packages/herdr/src/fetcher.js'; + +type StdinListener = (chunk: Buffer) => void; + +const ITEMS: WorkItem[] = [{ id: 'TEST-1', title: 'Item TEST-1', status: 'open' }]; + +const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 5)); + +let logDir: string; + +beforeEach(() => { + // Isolate the command log so a dispatched form command never touches the + // user's real ~/.config/herdr log. + logDir = mkdtempSync(join(tmpdir(), 'herdr-cq-open-pane-')); + setLogPath(join(logDir, 'cmdlog.json')); +}); + +afterEach(() => { + resetLogPath(); + resetExecFileAsync(); + rmSync(logDir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +/** + * Fake-TTY harness (mirrors form-dialog-open-pane.test.ts): swap + * `process.stdin` for a fake TTY that records data listeners and capture + * `process.stdout` writes. All `wl` subprocesses are stubbed via + * `setExecFileAsync` so the loop never spawns a real CLI. + */ +function setupFakeTty(): { + dataListeners: StdinListener[]; + writes: string[]; + restore: () => void; +} { + const dataListeners: StdinListener[] = []; + const fakeStdin = { + isTTY: true, + setRawMode: vi.fn(), + resume: vi.fn(), + pause: vi.fn(), + on: (ev: string, cb: StdinListener) => { + if (ev === 'data') dataListeners.push(cb); + }, + removeListener: (ev: string, cb: StdinListener) => { + if (ev === 'data') { + const i = dataListeners.indexOf(cb); + if (i >= 0) dataListeners.splice(i, 1); + } + }, + }; + const writes: string[] = []; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: any) => { + writes.push(String(chunk)); + return true; + }) as any); + const origStdin = (process as any).stdin; + Object.defineProperty(process, 'stdin', { value: fakeStdin, configurable: true, writable: true }); + setExecFileAsync((async () => ({ stdout: '{"count": 0}' })) as any); + return { + dataListeners, + writes, + restore: () => { + Object.defineProperty(process, 'stdin', { value: origStdin, configurable: true, writable: true }); + writeSpy.mockRestore(); + resetExecFileAsync(); + }, + }; +} + +/** + * Drive the real `c q` flow against the production shortcut registry: + * complete the chord, wait for the quick-create form, fill title + + * description (priority keeps its inline default), submit, and return the + * `onCommand` spy. `fetcher` is the list-refresh data source (counted for + * the completion-refresh assertion) and `withOwnOnRefresh` controls whether + * the test injects its own refresh hook (isolating the forwarded-arg + * assertion) or lets the TUI wire its internal `doRefresh`. + */ +async function driveQuickCreate( + options: { fetcher?: ReturnType<typeof vi.fn>; withOwnOnRefresh?: boolean } = {}, +): Promise<{ onCommand: ReturnType<typeof vi.fn>; onRefresh: ReturnType<typeof vi.fn> }> { + const { fetcher = vi.fn(async () => ITEMS), withOwnOnRefresh = false } = options; + const { dataListeners, writes, restore } = setupFakeTty(); + const onCommand = vi.fn(); + const ownOnRefresh = vi.fn(); + const press = async (key: string): Promise<void> => { + dataListeners.forEach(cb => cb(Buffer.from(key))); + await tick(); + }; + let tui: Promise<WorkItem | undefined> | undefined; + try { + tui = runWorklistTui(fetcher as any, ITEMS, loadShortcutConfig(), { + autoRefresh: false, + autoSync: false, + onCommand, + ...(withOwnOnRefresh ? { onRefresh: ownOnRefresh } : {}), + }); + await vi.waitFor(() => { + expect(dataListeners.length).toBeGreaterThan(0); + }); + // Complete the `c q` chord → the quick-create form dialog opens. + await press('c'); + await press('q'); + await vi.waitFor(() => { + expect(writes.join('')).toContain('Command Input'); + }); + // Fill the form: title, tab to description, priority keeps its inline + // default 'medium' (matches chord-quick-create.test.ts). + for (const ch of 'My title') await press(ch); + await press('\t'); + for (const ch of 'My desc') await press(ch); + await press('\r'); // submit + await vi.waitFor(() => { + expect(onCommand).toHaveBeenCalled(); + }); + return { onCommand, onRefresh: ownOnRefresh }; + } finally { + dataListeners.forEach(cb => cb(Buffer.from('q'))); + if (tui) await tui.catch(() => undefined); + restore(); + } +} + +describe('c q quick create runs without a pane (WL-0MV6J5JXL002D8C4)', () => { + it('marks the bundled c-q entry open_pane: false (config opt-in)', () => { + const registry = loadShortcutConfig(); + const entry = registry.lookupChordEntry(['c', 'q'], 'list'); + expect(entry).toBeDefined(); + expect(entry?.openPane).toBe(false); + expect(entry?.command).toBe( + "!!wl create -t <title> -d <description> -p <priority default='medium'>", + ); + }); + + it('dispatches wl create with openPane === false and the refresh hook on form submit', async () => { + const { onCommand, onRefresh } = await driveQuickCreate({ withOwnOnRefresh: true }); + expect(onCommand).toHaveBeenCalledTimes(1); + // Form-dialog dispatch forwards the inherited open_pane flag (3rd arg + // false → background, no pane) plus the completion-refresh hook (4th + // arg) so the list updates when `wl create` exits (WL-0MV6J4HQ0009OJPQ, + // WL-0MT1KB70U0012X6T). Shell-route values are POSIX single-quoted. + expect(onCommand).toHaveBeenCalledWith( + "!!wl create -t 'My title' -d 'My desc' -p 'medium'", + undefined, // model + false, // openPane → no pane opens + onRefresh, + ); + }); + + it('refreshes the list when the background create completes (completion refresh)', async () => { + const fetcher = vi.fn(async () => ITEMS); + const { onCommand } = await driveQuickCreate({ fetcher }); + const call = onCommand.mock.calls[0] as [ + string, + unknown, + unknown, + () => Promise<void>, + ]; + // initialItems is supplied → no startup fetch; the ONLY refresh must be + // the one the forwarded completion hook triggers. + expect(fetcher).not.toHaveBeenCalled(); + expect(typeof call[3]).toBe('function'); + await call[3](); // background `wl create` exited → onRefresh fires + await vi.waitFor(() => { + expect(fetcher).toHaveBeenCalled(); + }); + }); + + it('leaves other form-driven shortcuts on the default pane-opening path', () => { + const registry = loadShortcutConfig(); + // c-n (create intake) is form-driven (<description> placeholder) but + // does NOT opt in — the flag must stay absent so it opens a pane + // exactly as before (AC5). + expect(registry.lookupChordEntry(['c', 'n'], 'list')?.openPane).toBeUndefined(); + // And no bundled pane-opening shortcut was accidentally flagged false + // beyond the documented quiet set (covers AC5 wholesale). + const quiet = new Set([ + 'a,y', 'a,r', 'c,q', 'u,p,l', 'u,p,m', 'u,p,h', 'u,p,c', 'x,c', 'x,d', + ]); + for (const entry of registry.getEntries()) { + const key = entry.chord.join(','); + if (entry.openPane === false) { + expect(quiet.has(key)).toBe(true); + } + } + }); +}); diff --git a/tests/unit/cleanup-assignee-residue.test.ts b/tests/unit/cleanup-assignee-residue.test.ts new file mode 100644 index 000000000..22d809623 --- /dev/null +++ b/tests/unit/cleanup-assignee-residue.test.ts @@ -0,0 +1,288 @@ +/** + * Tests for scripts/cleanup-assignee-residue.ts (WL-0MV85WFDS0036INQ) + * + * Lives under tests/unit/ (rather than the predicted root path) so it runs in + * the canonical suite declared by .pi/test-config.json — `tests/cli` and + * `tests/unit`. Root-level tests exist but are not part of that suite. + * + * Exercises the one-time assignee-residue cleanup against a fixture database: + * - audit-hold residue (completed / in_review) is cleared + * - in-flight claims (open / in-progress / blocked / input_needed) are untouched + * - already-clean items are untouched + * - dry-run mode reports without writing + * - re-running is a no-op (idempotent) + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import { WorklogDatabase } from '../../src/database.js'; +import { + runCleanup, + isResidueTarget, + formatReport, + parseArgs, + resolveWorklogDir, + IN_FLIGHT_STATUSES, +} from '../../scripts/cleanup-assignee-residue.js'; +import { + createTempDir, + cleanupTempDir, + createTempJsonlPath, + createTempDbPath, +} from '../test-utils.js'; + +describe('cleanup-assignee-residue', () => { + 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, false); + }); + + afterEach(() => { + db.close(); + cleanupTempDir(tempDir); + }); + + /** Seed the fixture database with the three categories. */ + function seedFixture(): void { + // ── Audit-hold residue: must be cleared ────────────────────────── + db.create({ + title: 'Completed item with stale assignee', + status: 'completed', + stage: 'in_review', + assignee: 'implement', + }); + db.create({ + title: 'Completed item with a different stale assignee', + status: 'completed', + assignee: 'producer', + }); + db.create({ + title: 'In-review audit-hold item', + status: 'completed', + stage: 'in_review', + assignee: 'audit-agent', + }); + db.create({ + title: 'Deleted item with residual in_review stage', + status: 'deleted', + stage: 'in_review', + assignee: 'stale-agent', + }); + + // ── In-flight claims: must be untouched ────────────────────────── + const openItem = db.create({ + title: 'Open in-flight claim', + status: 'open', + stage: 'plan_complete', + assignee: 'implement', + }); + expect(openItem).toBeDefined(); + + db.create({ + title: 'In-progress in-flight claim', + status: 'in-progress', + stage: 'in_progress', + assignee: 'implement', + }); + db.create({ + title: 'Blocked in-flight claim', + status: 'blocked', + stage: 'plan_complete', + assignee: 'implement', + }); + db.create({ + title: 'Input-needed in-flight claim', + status: 'input_needed', + stage: 'intake_complete', + assignee: 'producer', + }); + + // ── Already-clean items: must be untouched ─────────────────────── + db.create({ + title: 'Completed item already clean', + status: 'completed', + stage: 'in_review', + assignee: '', + }); + db.create({ + title: 'Open item already clean', + status: 'open', + assignee: '', + }); + } + + describe('isResidueTarget', () => { + it('targets a completed item with a non-empty assignee', () => { + const item = db.create({ title: 'x', status: 'completed', assignee: 'a' }); + expect(isResidueTarget(item)).toBe(true); + }); + + it('targets an in_review-stage completed item with a non-empty assignee', () => { + const item = db.create({ title: 'x', status: 'completed', stage: 'in_review', assignee: 'a' }); + expect(isResidueTarget(item)).toBe(true); + }); + + it('never targets a deleted item even with a residual in_review stage', () => { + const item = db.create({ title: 'x', status: 'deleted', stage: 'in_review', assignee: 'a' }); + expect(isResidueTarget(item)).toBe(false); + }); + + it.each(['open', 'in-progress', 'blocked', 'input_needed'] as const)( + 'never targets in-flight status %s', + (status) => { + const item = db.create({ title: 'x', status, assignee: 'a' }); + expect(isResidueTarget(item)).toBe(false); + }, + ); + + it('does not target an item with an empty assignee', () => { + const item = db.create({ title: 'x', status: 'completed', assignee: '' }); + expect(isResidueTarget(item)).toBe(false); + }); + + it('does not target a non-hold open item', () => { + const item = db.create({ title: 'x', status: 'open', stage: 'plan_complete', assignee: 'a' }); + expect(isResidueTarget(item)).toBe(false); + }); + }); + + describe('runCleanup (dry-run)', () => { + it('reports affected items and counts without writing', () => { + seedFixture(); + + const report = runCleanup(db, true); + + expect(report.dryRun).toBe(true); + expect(report.cleared).toHaveLength(3); + expect(report.cleared.map((c) => c.id).sort()).toEqual( + db + .list() + .filter((i) => isResidueTarget(i)) + .map((i) => i.id) + .sort(), + ); + + // No writes happened: every assignee is still present. + const unchanged = db.list(); + expect(unchanged.find((i) => i.title === 'Completed item with stale assignee')!.assignee).toBe( + 'implement', + ); + expect(unchanged.find((i) => i.title === 'In-review audit-hold item')!.assignee).toBe( + 'audit-agent', + ); + }); + + it('returns a JSON-serialisable, human-readable report', () => { + seedFixture(); + const report = runCleanup(db, true); + const text = formatReport(report); + expect(text).toContain('DRY RUN'); + expect(text).toContain('WOULD CLEAR: 3'); + expect(JSON.parse(JSON.stringify(report)).cleared).toHaveLength(3); + }); + }); + + describe('runCleanup (apply)', () => { + it('clears audit-hold residue only', () => { + seedFixture(); + + const report = runCleanup(db, false); + expect(report.cleared).toHaveLength(3); + + const byTitle = new Map(db.list().map((i) => [i.title, i])); + + // Cleared. + expect(byTitle.get('Completed item with stale assignee')!.assignee).toBe(''); + expect(byTitle.get('Completed item with a different stale assignee')!.assignee).toBe(''); + expect(byTitle.get('In-review audit-hold item')!.assignee).toBe(''); + + // In-flight untouched. + expect(byTitle.get('Open in-flight claim')!.assignee).toBe('implement'); + expect(byTitle.get('In-progress in-flight claim')!.assignee).toBe('implement'); + expect(byTitle.get('Blocked in-flight claim')!.assignee).toBe('implement'); + expect(byTitle.get('Input-needed in-flight claim')!.assignee).toBe('producer'); + + // Already-clean untouched. + expect(byTitle.get('Completed item already clean')!.assignee).toBe(''); + expect(byTitle.get('Open item already clean')!.assignee).toBe(''); + }); + + it('is idempotent — a second run clears nothing and writes nothing', () => { + seedFixture(); + + const first = runCleanup(db, false); + expect(first.cleared).toHaveLength(3); + + const afterFirst = new Map(db.list().map((i) => [i.id, i])); + + const second = runCleanup(db, false); + expect(second.cleared).toHaveLength(0); + // Only the 2 already-clean fixtures plus the 3 just-cleared items are + // now clean; in-flight and deleted items keep their assignees. + expect(second.alreadyClean).toBe(2 + first.cleared.length); + + // Re-running never re-timestamps previously-cleared items. + for (const item of db.list()) { + const before = afterFirst.get(item.id)!; + expect(item.updatedAt).toBe(before.updatedAt); + } + }); + + it('does not touch items that are neither completed nor in_review', () => { + const item = db.create({ + title: 'Open plan item with assignee', + status: 'open', + stage: 'plan_complete', + assignee: 'implement', + }); + + const report = runCleanup(db, false); + + expect(report.cleared).toHaveLength(0); + expect(report.skipped.some((s) => s.id === item.id)).toBe(true); + expect(db.get(item.id)!.assignee).toBe('implement'); + }); + }); + + describe('IN_FLIGHT_STATUSES', () => { + it('covers exactly the non-terminal, non-audit-hold statuses', () => { + expect([...IN_FLIGHT_STATUSES].sort()).toEqual( + ['blocked', 'in-progress', 'input_needed', 'open'].sort(), + ); + }); + }); + + describe('CLI helpers', () => { + it('parses --worklog-dir, --dry-run and --json', () => { + const opts = parseArgs(['--worklog-dir', '/tmp/wl', '--dry-run', '--json']); + expect(opts.worklogDir).toBe('/tmp/wl'); + expect(opts.dryRun).toBe(true); + expect(opts.json).toBe(true); + }); + + it('resolves the .worklog directory by walking up', () => { + const nested = `${tempDir}/a/b/c`; + fs.mkdirSync(nested, { recursive: true }); + fs.mkdirSync(`${tempDir}/.worklog`, { recursive: true }); + expect(resolveWorklogDir(nested)).toBe(`${tempDir}/.worklog`); + }); + + it('falls back to <cwd>/.worklog when no .worklog exists', () => { + const isolated = `${tempDir}/no-worklog`; + fs.mkdirSync(isolated, { recursive: true }); + const resolved = resolveWorklogDir(isolated); + // Walk may pass the temp dir; the fallback appends .worklog to the start. + expect(resolved.endsWith('.worklog')).toBe(true); + }); + }); +}); diff --git a/tests/unit/colour-mapping.test.ts b/tests/unit/colour-mapping.test.ts index 4c5a452b6..6ba2c4eb9 100644 --- a/tests/unit/colour-mapping.test.ts +++ b/tests/unit/colour-mapping.test.ts @@ -22,7 +22,6 @@ function createMockWorkItem(overrides: Partial<WorkItem> = {}): WorkItem { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), assignee: '', - needsProducerReview: false, ...overrides, }; } diff --git a/tests/unit/downtime-worker-assignee-hold.test.ts b/tests/unit/downtime-worker-assignee-hold.test.ts new file mode 100644 index 000000000..04f341ff8 --- /dev/null +++ b/tests/unit/downtime-worker-assignee-hold.test.ts @@ -0,0 +1,301 @@ +/** + * tests/unit/downtime-worker-assignee-hold.test.ts — the downtime dispatcher + * must never dispatch an item held by a non-empty assignee. + * + * An assignee (human or agent) means the item is already being worked, so it + * must not be auto-dispatched at any tier (WL-0MV85WCJJ0087VMP). The + * audit-tier claim additionally CASes on the empty assignee + * (`--if-assignee ""`), so a held item would fail stale there; this guard + * excludes it up front at every selection path: the tier `select*` functions, + * the Herdr-head `classifyItemForDispatch`, and the parse helpers that carry + * the field to those filters. Assigning an item to the producer therefore + * makes it non-dispatchable everywhere. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + classifyItemForDispatch, + dispatchDowntimeWork, + isAssigneeHeld, + parseAuditCandidatesOutput, + parseCriticalCandidatesOutput, + parseImplementCandidatesOutput, + parseNextCandidatesOutput, + selectAuditCandidate, + selectCriticalCandidate, + selectImplementCandidate, + selectNextCandidate, + type AuditCandidate, + type CriticalCandidate, + type DowntimeCandidate, + type DowntimeHerdrItem, + type ImplementCandidate, +} from '../../packages/herdr/src/downtime-worker.js'; +import { makeDeps } from '../../packages/herdr/src/downtime-worker.fixtures.js'; + +const HELD_BY = 'producer'; + +describe('isAssigneeHeld', () => { + it('treats a non-empty assignee as held', () => { + expect(isAssigneeHeld({ assignee: HELD_BY })).toBe(true); + }); + + it('treats empty/whitespace/absent assignee as free', () => { + expect(isAssigneeHeld({ assignee: '' })).toBe(false); + expect(isAssigneeHeld({ assignee: ' ' })).toBe(false); + expect(isAssigneeHeld({})).toBe(false); + expect(isAssigneeHeld(undefined)).toBe(false); + expect(isAssigneeHeld(null)).toBe(false); + }); +}); + +describe('assignee-hold exclusion at candidate selection (WL-0MV85WCJJ0087VMP)', () => { + const audit = (overrides: Partial<AuditCandidate> = {}): AuditCandidate => ({ + id: 'AUD-1', + title: 'Audit', + updatedAt: new Date(Date.now() - 1000).toISOString(), + ...overrides, + }); + const implement = (overrides: Partial<ImplementCandidate> = {}): ImplementCandidate => ({ + id: 'IMP-1', + title: 'Implement', + status: 'open', + risk: 'low', + effort: 'small', + sortIndex: 1, + ...overrides, + }); + const next = (overrides: Partial<DowntimeCandidate> = {}): DowntimeCandidate => ({ + id: 'NEXT-1', + title: 'Next', + stage: 'intake_complete', + status: 'open', + sortIndex: 1, + ...overrides, + }); + const critical = (overrides: Partial<CriticalCandidate> = {}): CriticalCandidate => ({ + id: 'CRIT-1', + title: 'Critical', + status: 'open', + stage: 'idea', + sortIndex: 1, + ...overrides, + }); + + it('selectAuditCandidate skips an assignee-held candidate', () => { + const held = audit({ id: 'AUD-HELD', assignee: HELD_BY, sortIndex: 1 }); + const free = audit({ id: 'AUD-FREE', sortIndex: 10 }); + expect(selectAuditCandidate([held, free])?.id).toBe('AUD-FREE'); + expect(selectAuditCandidate([held])).toBeNull(); + }); + + it('selectImplementCandidate skips an assignee-held candidate', () => { + const held = implement({ id: 'IMP-HELD', assignee: HELD_BY, sortIndex: 1 }); + const free = implement({ id: 'IMP-FREE', sortIndex: 10 }); + expect(selectImplementCandidate([held, free])?.id).toBe('IMP-FREE'); + expect(selectImplementCandidate([held])).toBeNull(); + }); + + it('selectNextCandidate skips an assignee-held candidate', () => { + const held = next({ id: 'NEXT-HELD', assignee: HELD_BY, sortIndex: 1 }); + const free = next({ id: 'NEXT-FREE', sortIndex: 10 }); + expect(selectNextCandidate([held, free])?.id).toBe('NEXT-FREE'); + expect(selectNextCandidate([held])).toBeNull(); + }); + + it('selectCriticalCandidate skips an assignee-held candidate', () => { + const held = critical({ id: 'CRIT-HELD', assignee: HELD_BY, sortIndex: 1 }); + const free = critical({ id: 'CRIT-FREE', sortIndex: 10 }); + expect(selectCriticalCandidate([held, free])?.id).toBe('CRIT-FREE'); + expect(selectCriticalCandidate([held])).toBeNull(); + }); + + it('classifyItemForDispatch returns null for an assignee-held item', () => { + expect( + classifyItemForDispatch({ id: 'X', status: 'open', stage: 'idea', assignee: HELD_BY }), + ).toBeNull(); + // The same item without the assignee hold is dispatchable. + expect(classifyItemForDispatch({ id: 'X', status: 'open', stage: 'idea' })).toBe('intake'); + }); + + it('classifyItemForDispatch returns null for an assignee-held audit item', () => { + const held: Parameters<typeof classifyItemForDispatch>[0] = { + id: 'AUD-X', + status: 'completed', + stage: 'in_review', + updatedAt: new Date().toISOString(), + assignee: HELD_BY, + }; + expect(classifyItemForDispatch(held)).toBeNull(); + // Unassigned, the same item classifies as an audit candidate. + expect(classifyItemForDispatch({ ...held, assignee: '' })).toBe('audit'); + }); +}); + +describe('assignee hold carried by parsed output (WL-0MV85WCJJ0087VMP)', () => { + it('parseNextCandidatesOutput carries assignee and the selector skips it', () => { + const stdout = JSON.stringify({ + workItems: [ + { workItem: { id: 'N1', title: 'held', status: 'open', stage: 'intake_complete', assignee: HELD_BY } }, + { workItem: { id: 'N2', title: 'free', status: 'open', stage: 'intake_complete' } }, + ], + }); + const candidates = parseNextCandidatesOutput(stdout, 'intake_complete')!; + expect(candidates).not.toBeNull(); + expect(candidates[0].assignee).toBe(HELD_BY); + expect(selectNextCandidate(candidates)?.id).toBe('N2'); + }); + + it('parseImplementCandidatesOutput carries assignee and the selector skips it', () => { + const stdout = JSON.stringify({ + workItems: [ + { workItem: { id: 'I1', title: 'held', status: 'open', risk: 'low', effort: 'small', assignee: HELD_BY } }, + { workItem: { id: 'I2', title: 'free', status: 'open', risk: 'low', effort: 'small' } }, + ], + }); + const candidates = parseImplementCandidatesOutput(stdout)!; + expect(candidates).not.toBeNull(); + expect(candidates[0].assignee).toBe(HELD_BY); + expect(selectImplementCandidate(candidates)?.id).toBe('I2'); + }); + + it('parseCriticalCandidatesOutput carries assignee and the selector skips it', () => { + const stdout = JSON.stringify({ + workItems: [ + { id: 'C1', title: 'held', status: 'open', stage: 'idea', assignee: HELD_BY }, + { id: 'C2', title: 'free', status: 'open', stage: 'idea' }, + ], + }); + const candidates = parseCriticalCandidatesOutput(stdout)!; + expect(candidates).not.toBeNull(); + expect(candidates[0].assignee).toBe(HELD_BY); + expect(selectCriticalCandidate(candidates as never)?.id).toBe('C2'); + }); + + it('parseAuditCandidatesOutput carries assignee and the selector skips it', () => { + const stdout = JSON.stringify({ + workItems: [ + { id: 'A1', title: 'held', assignee: HELD_BY }, + { id: 'A2', title: 'free' }, + ], + }); + const candidates = parseAuditCandidatesOutput(stdout)!; + expect(candidates).not.toBeNull(); + expect(candidates[0].assignee).toBe(HELD_BY); + expect(selectAuditCandidate(candidates)?.id).toBe('A2'); + }); +}); + +describe('Herdr-head dispatch never dispatches an assignee-held item (WL-0MV85WCJJ0087VMP)', () => { + const heldCritical: DowntimeHerdrItem = { + id: 'CRIT-HELD', + title: 'Assignee-held critical idea', + status: 'open', + stage: 'idea', + priority: 'critical', + sortIndex: 1, + assignee: HELD_BY, + }; + const freeImplement: DowntimeHerdrItem = { + id: 'IMP-FREE', + title: 'Free implement item', + status: 'open', + stage: 'plan_complete', + priority: 'medium', + risk: 'Low', + effort: 'S', + sortIndex: 100, + }; + + it('skips the assignee-held head item and dispatches the next free item', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [heldCritical, freeImplement] }), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.candidate?.id).toBe('IMP-FREE'); + expect(outcome.candidate?.id).not.toBe('CRIT-HELD'); + expect(deps.claimItem).not.toHaveBeenCalledWith( + 'CRIT-HELD', expect.anything(), expect.anything(), + ); + }); + + it('does not dispatch when the head contains only an assignee-held item', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [heldCritical] }), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(deps.claimItem).not.toHaveBeenCalled(); + }); +}); + +describe('clearing the assignee releases the hold (WL-0MV85WCJJ0087VMP)', () => { + const implement = (overrides: Partial<ImplementCandidate> = {}): ImplementCandidate => ({ + id: 'IMP-1', + title: 'Implement', + status: 'open', + risk: 'low', + effort: 'small', + sortIndex: 1, + ...overrides, + }); + const audit = (overrides: Partial<AuditCandidate> = {}): AuditCandidate => ({ + id: 'AUD-1', + title: 'Audit', + updatedAt: new Date(Date.now() - 1000).toISOString(), + ...overrides, + }); + + it('makes an implement candidate dispatchable again once the claim is cleared', () => { + const held = implement({ id: 'IMP-1', assignee: 'herdr-123', sortIndex: 1 }); + expect(selectImplementCandidate([held])).toBeNull(); + + const released: ImplementCandidate = { ...held, assignee: '' }; + expect(isAssigneeHeld(released)).toBe(false); + expect(selectImplementCandidate([released])?.id).toBe('IMP-1'); + }); + + it('makes an audit candidate dispatchable again once the claim is cleared', () => { + const held = audit({ id: 'AUD-1', assignee: 'herdr-123', sortIndex: 1 }); + expect(selectAuditCandidate([held])).toBeNull(); + + const released: AuditCandidate = { ...held, assignee: '' }; + expect(selectAuditCandidate([released])?.id).toBe('AUD-1'); + }); + + it('makes a classifyItemForDispatch item dispatchable again once the producer hold is cleared', () => { + const held: Parameters<typeof classifyItemForDispatch>[0] = { + id: 'PROD-1', + status: 'open', + stage: 'idea', + assignee: 'herdr-123', + }; + expect(classifyItemForDispatch(held)).toBeNull(); + // Clearing the claim releases the item for its intake tier. + expect(classifyItemForDispatch({ ...held, assignee: '' })).toBe('intake'); + }); + + it('releases a producer-held next candidate for auto-dispatch', () => { + const producerHeld: DowntimeCandidate = { + id: 'NEXT-1', + title: 'Producer-held', + stage: 'intake_complete', + status: 'open', + sortIndex: 1, + assignee: 'herdr-123', + }; + expect(selectNextCandidate([producerHeld])).toBeNull(); + expect(selectNextCandidate([{ ...producerHeld, assignee: '' }])?.id).toBe('NEXT-1'); + }); +}); diff --git a/tests/unit/downtime-worker-soft-delete.test.ts b/tests/unit/downtime-worker-soft-delete.test.ts new file mode 100644 index 000000000..6793dd15e --- /dev/null +++ b/tests/unit/downtime-worker-soft-delete.test.ts @@ -0,0 +1,224 @@ +/** + * tests/unit/downtime-worker-soft-delete.test.ts — the downtime dispatcher + * must never dispatch a soft-deleted work item. + * + * A soft delete (`wl delete`) sets `deletedBy` (and `deleteReason`) and may + * leave the item's status untouched, so a `wl next`/`wl list` query can still + * return it. Before this guard the candidate-selection stage filtered only on + * `status`, so a soft-deleted item was re-selected and worked a second time + * (SA-0MUJBLZC60085DI9). The guard treats a non-empty `deletedBy` as the + * authoritative deletion marker at every selection path: the tier `select*` + * functions, the Herdr-head `classifyItemForDispatch`, and the parse helpers + * that carry the field to those filters. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + classifyItemForDispatch, + dispatchDowntimeWork, + isSoftDeleted, + parseAuditCandidatesOutput, + parseCriticalCandidatesOutput, + parseImplementCandidatesOutput, + parseNextCandidatesOutput, + selectAuditCandidate, + selectCriticalCandidate, + selectImplementCandidate, + selectNextCandidate, + type AuditCandidate, + type CriticalCandidate, + type DowntimeCandidate, + type DowntimeHerdrItem, + type ImplementCandidate, +} from '../../packages/herdr/src/downtime-worker.js'; +import { makeDeps } from '../../packages/herdr/src/downtime-worker.fixtures.js'; + +const DELETED_BY = 'deleter@example.com'; + +describe('isSoftDeleted', () => { + it('treats a non-empty deletedBy as deleted', () => { + expect(isSoftDeleted({ deletedBy: DELETED_BY })).toBe(true); + }); + + it('treats empty/whitespace/absent deletedBy as live', () => { + expect(isSoftDeleted({ deletedBy: '' })).toBe(false); + expect(isSoftDeleted({ deletedBy: ' ' })).toBe(false); + expect(isSoftDeleted({})).toBe(false); + expect(isSoftDeleted(undefined)).toBe(false); + expect(isSoftDeleted(null)).toBe(false); + }); +}); + +describe('soft-delete exclusion at candidate selection (SA-0MUJBLZC60085DI9 AC3)', () => { + const audit = (overrides: Partial<AuditCandidate> = {}): AuditCandidate => ({ + id: 'AUD-1', + title: 'Audit', + updatedAt: new Date(Date.now() - 1000).toISOString(), + ...overrides, + }); + const implement = (overrides: Partial<ImplementCandidate> = {}): ImplementCandidate => ({ + id: 'IMP-1', + title: 'Implement', + status: 'open', + risk: 'low', + effort: 'small', + sortIndex: 1, + ...overrides, + }); + const next = (overrides: Partial<DowntimeCandidate> = {}): DowntimeCandidate => ({ + id: 'NEXT-1', + title: 'Next', + stage: 'intake_complete', + status: 'open', + sortIndex: 1, + ...overrides, + }); + const critical = (overrides: Partial<CriticalCandidate> = {}): CriticalCandidate => ({ + id: 'CRIT-1', + title: 'Critical', + status: 'open', + stage: 'idea', + sortIndex: 1, + ...overrides, + }); + + it('selectAuditCandidate skips a soft-deleted candidate', () => { + const deleted = audit({ id: 'AUD-DEL', deletedBy: DELETED_BY, sortIndex: 1 }); + const live = audit({ id: 'AUD-LIVE', sortIndex: 10 }); + expect(selectAuditCandidate([deleted, live])?.id).toBe('AUD-LIVE'); + expect(selectAuditCandidate([deleted])).toBeNull(); + }); + + it('selectImplementCandidate skips a soft-deleted candidate', () => { + const deleted = implement({ id: 'IMP-DEL', deletedBy: DELETED_BY, sortIndex: 1 }); + const live = implement({ id: 'IMP-LIVE', sortIndex: 10 }); + expect(selectImplementCandidate([deleted, live])?.id).toBe('IMP-LIVE'); + expect(selectImplementCandidate([deleted])).toBeNull(); + }); + + it('selectNextCandidate skips a soft-deleted candidate', () => { + const deleted = next({ id: 'NEXT-DEL', deletedBy: DELETED_BY, sortIndex: 1 }); + const live = next({ id: 'NEXT-LIVE', sortIndex: 10 }); + expect(selectNextCandidate([deleted, live])?.id).toBe('NEXT-LIVE'); + expect(selectNextCandidate([deleted])).toBeNull(); + }); + + it('selectCriticalCandidate skips a soft-deleted candidate', () => { + const deleted = critical({ id: 'CRIT-DEL', deletedBy: DELETED_BY, sortIndex: 1 }); + const live = critical({ id: 'CRIT-LIVE', sortIndex: 10 }); + expect(selectCriticalCandidate([deleted, live])?.id).toBe('CRIT-LIVE'); + expect(selectCriticalCandidate([deleted])).toBeNull(); + }); + + it('classifyItemForDispatch returns null for a soft-deleted item', () => { + expect( + classifyItemForDispatch({ id: 'X', status: 'open', stage: 'idea', deletedBy: DELETED_BY }), + ).toBeNull(); + // The same item without the deletion marker is dispatchable. + expect(classifyItemForDispatch({ id: 'X', status: 'open', stage: 'idea' })).toBe('intake'); + }); +}); + +describe('soft-delete excluded from parsed output (SA-0MUJBLZC60085DI9 AC3)', () => { + it('parseNextCandidatesOutput carries deletedBy and the selector skips it', () => { + const stdout = JSON.stringify({ + workItems: [ + { workItem: { id: 'N1', title: 'deleted', status: 'open', stage: 'intake_complete', deletedBy: DELETED_BY } }, + { workItem: { id: 'N2', title: 'live', status: 'open', stage: 'intake_complete' } }, + ], + }); + const candidates = parseNextCandidatesOutput(stdout, 'intake_complete')!; + expect(candidates).not.toBeNull(); + expect(selectNextCandidate(candidates)?.id).toBe('N2'); + }); + + it('parseImplementCandidatesOutput carries deletedBy and the selector skips it', () => { + const stdout = JSON.stringify({ + workItems: [ + { workItem: { id: 'I1', title: 'deleted', status: 'open', risk: 'low', effort: 'small', deletedBy: DELETED_BY } }, + { workItem: { id: 'I2', title: 'live', status: 'open', risk: 'low', effort: 'small' } }, + ], + }); + const candidates = parseImplementCandidatesOutput(stdout)!; + expect(candidates).not.toBeNull(); + expect(selectImplementCandidate(candidates)?.id).toBe('I2'); + }); + + it('parseCriticalCandidatesOutput carries deletedBy and the selector skips it', () => { + const stdout = JSON.stringify({ + workItems: [ + { id: 'C1', title: 'deleted', status: 'open', stage: 'idea', deletedBy: DELETED_BY }, + { id: 'C2', title: 'live', status: 'open', stage: 'idea' }, + ], + }); + const candidates = parseCriticalCandidatesOutput(stdout)!; + expect(candidates).not.toBeNull(); + expect(selectCriticalCandidate(candidates as never)?.id).toBe('C2'); + }); + + it('parseAuditCandidatesOutput carries deletedBy and the selector skips it', () => { + const stdout = JSON.stringify({ + workItems: [ + { id: 'A1', title: 'deleted', deletedBy: DELETED_BY }, + { id: 'A2', title: 'live' }, + ], + }); + const candidates = parseAuditCandidatesOutput(stdout)!; + expect(candidates).not.toBeNull(); + expect(selectAuditCandidate(candidates)?.id).toBe('A2'); + }); +}); + +describe('Herdr-head dispatch never dispatches a soft-deleted item (AC3)', () => { + const deletedCritical: DowntimeHerdrItem = { + id: 'CRIT-DELETED', + title: 'Soft-deleted critical idea', + status: 'open', + stage: 'idea', + priority: 'critical', + sortIndex: 1, + deletedBy: DELETED_BY, + }; + const liveImplement: DowntimeHerdrItem = { + id: 'IMP-LIVE', + title: 'Live implement item', + status: 'open', + stage: 'plan_complete', + priority: 'medium', + risk: 'Low', + effort: 'S', + sortIndex: 100, + }; + + it('skips the soft-deleted head item and dispatches the next live item', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [deletedCritical, liveImplement] }), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.candidate?.id).toBe('IMP-LIVE'); + expect(outcome.candidate?.id).not.toBe('CRIT-DELETED'); + expect(deps.claimItem).not.toHaveBeenCalledWith( + 'CRIT-DELETED', expect.anything(), expect.anything(), + ); + }); + + it('does not dispatch when the head contains only a soft-deleted item', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [deletedCritical] }), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(deps.claimItem).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/form-dialog-open-pane.test.ts b/tests/unit/form-dialog-open-pane.test.ts new file mode 100644 index 000000000..c8f42e0ce --- /dev/null +++ b/tests/unit/form-dialog-open-pane.test.ts @@ -0,0 +1,198 @@ +/** + * tests/unit/form-dialog-open-pane.test.ts — Form-dialog dispatch honours + * `open_pane: false` (WL-0MV6J4HQ0009OJPQ). + * + * A shortcut that combines an unknown placeholder (e.g. `a-r`'s `<reason>`) + * with `open_pane: false` is routed through the placeholder form dialog. The + * form's onSubmit must forward `openPane`/`onRefresh`/`focus` with the same + * arity rules as the placeholder-free dispatch paths, otherwise the flag is + * dropped and a pane opens against the shortcut's explicit opt-out. + * + * Layer note: this is an end-to-end test of the herdr form-dialog dispatch + * path, so it lives under `tests/unit/` (the canonical agent suite in + * `.pi/test-config.json`) rather than `packages/herdr/src/worklist.test.ts`, + * which the configured suite does not run (WL-0MUU173V8008IA5E). It drives + * the real `runWorklistTui` input loop with a fake TTY. + * + * Run: npx vitest run tests/unit/form-dialog-open-pane.test.ts + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { runWorklistTui, type WorkItem } from '../../packages/herdr/src/worklist.js'; +import { ShortcutRegistry } from '../../packages/herdr/src/shortcut-config.js'; +import { setLogPath, resetLogPath } from '../../packages/herdr/src/command-log.js'; +import { setExecFileAsync, resetExecFileAsync } from '../../packages/herdr/src/fetcher.js'; + +type StdinListener = (chunk: Buffer) => void; + +const ITEMS: WorkItem[] = [{ id: 'TEST-123', title: 'Item TEST-123', status: 'open' }]; + +const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 5)); + +let logDir: string; + +beforeEach(() => { + // Isolate the command log so a dispatched form command never touches the + // user's real ~/.config/herdr log. + logDir = mkdtempSync(join(tmpdir(), 'herdr-form-open-pane-')); + setLogPath(join(logDir, 'cmdlog.json')); +}); + +afterEach(() => { + resetLogPath(); + resetExecFileAsync(); + rmSync(logDir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +/** + * Fake-TTY harness (mirrors the composed-footer / chord-quick-create + * harnesses): swap `process.stdin` for a fake TTY that records data + * listeners and captures `process.stdout` writes. + */ +function setupFakeTty(): { + dataListeners: StdinListener[]; + writes: string[]; + restore: () => void; +} { + const dataListeners: StdinListener[] = []; + const fakeStdin = { + isTTY: true, + setRawMode: vi.fn(), + resume: vi.fn(), + pause: vi.fn(), + on: (ev: string, cb: StdinListener) => { + if (ev === 'data') dataListeners.push(cb); + }, + removeListener: (ev: string, cb: StdinListener) => { + if (ev === 'data') { + const i = dataListeners.indexOf(cb); + if (i >= 0) dataListeners.splice(i, 1); + } + }, + }; + const writes: string[] = []; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: any) => { + writes.push(String(chunk)); + return true; + }) as any); + const origStdin = (process as any).stdin; + Object.defineProperty(process, 'stdin', { value: fakeStdin, configurable: true, writable: true }); + setExecFileAsync((async () => ({ stdout: '{"count": 0}' })) as any); + return { + dataListeners, + writes, + restore: () => { + Object.defineProperty(process, 'stdin', { value: origStdin, configurable: true, writable: true }); + writeSpy.mockRestore(); + resetExecFileAsync(); + }, + }; +} + +/** + * A placeholder shortcut (`<title>` is an unknown identifier) with the flag + * under test. No `stages` gate so the chord resolves from the default + * (unfiltered) list view. + */ +function registryWith(openPane: boolean | undefined, focus = false): ShortcutRegistry { + return new ShortcutRegistry([ + { + chord: ['z', 'z'], + command: '!!wl update <id> --title <title>', + view: 'both', + label: 'custom placeholder', + ...(openPane === undefined ? {} : { openPane }), + ...(focus ? { focus: true } : {}), + }, + ]); +} + +/** + * Drive the real input loop: chord → form → type → submit, then return the + * `onCommand`/`onRefresh` spies for assertions. + */ +async function driveForm( + openPane: boolean | undefined, + focus = false, + value = 'mytitle', +): Promise<{ onCommand: ReturnType<typeof vi.fn>; onRefresh: ReturnType<typeof vi.fn> }> { + const { dataListeners, writes, restore } = setupFakeTty(); + const onCommand = vi.fn(); + const onRefresh = vi.fn(); + const press = async (key: string): Promise<void> => { + dataListeners.forEach(cb => cb(Buffer.from(key))); + await tick(); + }; + let tui: Promise<WorkItem | undefined> | undefined; + try { + tui = runWorklistTui(async () => ITEMS, ITEMS, registryWith(openPane, focus), { + autoRefresh: false, + autoSync: false, + onCommand, + onRefresh, + }); + await vi.waitFor(() => { + expect(dataListeners.length).toBeGreaterThan(0); + expect(writes.length).toBeGreaterThan(0); + }); + // Complete the `z z` chord → the placeholder form dialog opens. + await press('z'); + await press('z'); + await vi.waitFor(() => { + expect(writes.join('')).toContain('Command Input'); + }); + // Type the placeholder value and submit. + for (const ch of value) await press(ch); + await press('\r'); + await vi.waitFor(() => { + expect(onCommand).toHaveBeenCalled(); + }); + return { onCommand, onRefresh }; + } finally { + dataListeners.forEach(cb => cb(Buffer.from('q'))); + if (tui) await tui.catch(() => undefined); + restore(); + } +} + +describe('form-dialog dispatch honours open_pane: false (WL-0MV6J4HQ0009OJPQ)', () => { + it('forwards openPane=false and onRefresh for a background placeholder shortcut', async () => { + const { onCommand, onRefresh } = await driveForm(false); + expect(onCommand).toHaveBeenCalledTimes(1); + expect(onCommand).toHaveBeenCalledWith( + "!!wl update TEST-123 --title 'mytitle'", + undefined, + false, + onRefresh, + ); + }); + + it('keeps the default (pane-opening) dispatch for a placeholder shortcut without open_pane', async () => { + const { onCommand } = await driveForm(undefined); + expect(onCommand).toHaveBeenCalledTimes(1); + // openPane is undefined → index.ts falls back to `openPane ?? true` and + // opens a pane exactly as before (byte-compatible 2-arg call). + expect(onCommand).toHaveBeenCalledWith( + "!!wl update TEST-123 --title 'mytitle'", + undefined, + ); + }); + + it('forwards focus=true for a placeholder shortcut with focus and no open_pane', async () => { + const { onCommand } = await driveForm(undefined, true); + expect(onCommand).toHaveBeenCalledTimes(1); + expect(onCommand).toHaveBeenCalledWith( + "!!wl update TEST-123 --title 'mytitle'", + undefined, + undefined, + undefined, + undefined, + true, + ); + }); +}); diff --git a/tests/unit/icons.test.ts b/tests/unit/icons.test.ts index 4561decc5..371841e8a 100644 --- a/tests/unit/icons.test.ts +++ b/tests/unit/icons.test.ts @@ -28,19 +28,13 @@ import { auditIcon, auditLabel, auditFallback, - epicIcon, - epicLabel, - epicFallback, riskIcon, - riskFallback, riskLabel, + riskFallback, effortIcon, effortFallback, effortLabel, iconsEnabled, - needsProducerReviewIcon, - needsProducerReviewLabel, - needsProducerReviewFallback, auditStaleIcon, auditStaleLabel, auditStaleFallback, @@ -56,7 +50,7 @@ describe('priorityIcon', () => { }); it('returns emoji for medium priority', () => { - expect(priorityIcon('medium')).toBe('\u{1F4CB}'); // 📋 + expect(priorityIcon('medium')).toBe('\u{1F7E8}'); // 🟨 }); it('returns emoji for low priority', () => { @@ -76,7 +70,7 @@ describe('priorityIcon', () => { it('is case-insensitive', () => { expect(priorityIcon('CRITICAL')).toBe('\u{1F6A8}'); expect(priorityIcon('High')).toBe('\u{2B50}'); - expect(priorityIcon('MEDIUM')).toBe('\u{1F4CB}'); + expect(priorityIcon('MEDIUM')).toBe('\u{1F7E8}'); }); describe('with noIcons option', () => { @@ -511,104 +505,6 @@ describe('auditLabel', () => { }); }); -// ─── Producer Review Icons ─────────────────────────────────────────────── - -describe('needsProducerReviewIcon', () => { - it('returns ❌ for true (needs review)', () => { - expect(needsProducerReviewIcon(true)).toBe('\u{274C}'); // ❌ - }); - - it('returns ✅ for false (review complete)', () => { - expect(needsProducerReviewIcon(false)).toBe('\u{2705}'); // ✅ - }); - - it('returns ✅ for null (defaults to not needed)', () => { - expect(needsProducerReviewIcon(null)).toBe('\u{2705}'); // ✅ - }); - - it('returns ✅ for undefined (defaults to not needed)', () => { - expect(needsProducerReviewIcon(undefined)).toBe('\u{2705}'); // ✅ - }); - - describe('with noIcons option', () => { - it('returns text fallback for true', () => { - expect(needsProducerReviewIcon(true, { noIcons: true })).toBe('[NEEDS_PRODUCER]'); - }); - - it('returns text fallback for false', () => { - expect(needsProducerReviewIcon(false, { noIcons: true })).toBe('[PRODUCER_OK]'); - }); - - it('returns text fallback for null', () => { - expect(needsProducerReviewIcon(null, { noIcons: true })).toBe('[PRODUCER_OK]'); - }); - - it('returns text fallback for undefined', () => { - expect(needsProducerReviewIcon(undefined, { noIcons: true })).toBe('[PRODUCER_OK]'); - }); - }); -}); - -describe('needsProducerReviewFallback', () => { - it('returns bracketed text for true', () => { - expect(needsProducerReviewFallback(true)).toBe('[NEEDS_PRODUCER]'); - }); - - it('returns bracketed text for false', () => { - expect(needsProducerReviewFallback(false)).toBe('[PRODUCER_OK]'); - }); - - it('returns bracketed text for null', () => { - expect(needsProducerReviewFallback(null)).toBe('[PRODUCER_OK]'); - }); - - it('returns bracketed text for undefined', () => { - expect(needsProducerReviewFallback(undefined)).toBe('[PRODUCER_OK]'); - }); -}); - -describe('needsProducerReviewLabel', () => { - it('returns label for true', () => { - expect(needsProducerReviewLabel(true)).toBe('Needs producer review'); - }); - - it('returns label for false', () => { - expect(needsProducerReviewLabel(false)).toBe('Producer review complete'); - }); - - it('returns label for null', () => { - expect(needsProducerReviewLabel(null)).toBe('Producer review complete'); - }); - - it('returns label for undefined', () => { - expect(needsProducerReviewLabel(undefined)).toBe('Producer review complete'); - }); -}); - -describe('epicIcon', () => { - it('returns castle emoji for epic', () => { - expect(epicIcon()).toBe('\u{1F3F0}'); // 🏰 - }); - - describe('with noIcons option', () => { - it('returns text fallback for epic', () => { - expect(epicIcon({ noIcons: true })).toBe('[EPIC]'); - }); - }); -}); - -describe('epicLabel', () => { - it('returns label for epic', () => { - expect(epicLabel()).toBe('Issue Type: Epic'); - }); -}); - -describe('epicFallback', () => { - it('returns bracketed text for epic', () => { - expect(epicFallback()).toBe('[EPIC]'); - }); -}); - // ─── Stale Audit Result Icons ───────────────────────────────────────────── describe('auditStaleIcon', () => { @@ -1027,6 +923,112 @@ function collectRepoFiles(dir: string): string[] { return files; } +// ─── Producer Review Icons (restored contract WL-0MVAMS4RO000H3B0 F1) ─── +// RED-pinned with it(`: the `needsProducerReviewIcon`/… exports were +// removed from `src/theme.ts` (WL-0MV85WFYU0071YQ9) and are being restored. +// The theme module is loaded lazily so the file does not break while the +// exports are absent; F5 flips `it(` → `it(`. + +describe('needsProducerReviewIcon (RED-contract, restored)', () => { + async function loadTheme(): Promise<any> { + return import('../../src/theme.js'); + } + + it('returns ❌ for true (needs review)', async () => { + const theme = await loadTheme(); + expect(theme.needsProducerReviewIcon(true)).toBe('\u{274C}'); // ❌ + }); + + it('returns ✅ for false (review complete)', async () => { + const theme = await loadTheme(); + expect(theme.needsProducerReviewIcon(false)).toBe('\u{2705}'); // ✅ + }); + + it('returns ✅ for null (defaults to not needed)', async () => { + const theme = await loadTheme(); + expect(theme.needsProducerReviewIcon(null)).toBe('\u{2705}'); // ✅ + }); + + it('returns ✅ for undefined (defaults to not needed)', async () => { + const theme = await loadTheme(); + expect(theme.needsProducerReviewIcon(undefined)).toBe('\u{2705}'); // ✅ + }); + + describe('with noIcons option', () => { + it('returns text fallback for true', async () => { + const theme = await loadTheme(); + expect(theme.needsProducerReviewIcon(true, { noIcons: true })).toBe('[NEEDS_PRODUCER]'); + }); + + it('returns text fallback for false', async () => { + const theme = await loadTheme(); + expect(theme.needsProducerReviewIcon(false, { noIcons: true })).toBe('[PRODUCER_OK]'); + }); + + it('returns text fallback for null', async () => { + const theme = await loadTheme(); + expect(theme.needsProducerReviewIcon(null, { noIcons: true })).toBe('[PRODUCER_OK]'); + }); + + it('returns text fallback for undefined', async () => { + const theme = await loadTheme(); + expect(theme.needsProducerReviewIcon(undefined, { noIcons: true })).toBe('[PRODUCER_OK]'); + }); + }); +}); + +describe('needsProducerReviewFallback (RED-contract, restored)', () => { + async function loadTheme(): Promise<any> { + return import('../../src/theme.js'); + } + + it('returns bracketed text for true', async () => { + const theme = await loadTheme(); + expect(theme.needsProducerReviewFallback(true)).toBe('[NEEDS_PRODUCER]'); + }); + + it('returns bracketed text for false', async () => { + const theme = await loadTheme(); + expect(theme.needsProducerReviewFallback(false)).toBe('[PRODUCER_OK]'); + }); + + it('returns bracketed text for null', async () => { + const theme = await loadTheme(); + expect(theme.needsProducerReviewFallback(null)).toBe('[PRODUCER_OK]'); + }); + + it('returns bracketed text for undefined', async () => { + const theme = await loadTheme(); + expect(theme.needsProducerReviewFallback(undefined)).toBe('[PRODUCER_OK]'); + }); +}); + +describe('needsProducerReviewLabel (RED-contract, restored)', () => { + async function loadTheme(): Promise<any> { + return import('../../src/theme.js'); + } + + it('returns label for true', async () => { + const theme = await loadTheme(); + expect(theme.needsProducerReviewLabel(true)).toBe('Needs producer review'); + }); + + it('returns label for false', async () => { + const theme = await loadTheme(); + expect(theme.needsProducerReviewLabel(false)).toBe('Producer review complete'); + }); + + it('returns label for null', async () => { + const theme = await loadTheme(); + expect(theme.needsProducerReviewLabel(null)).toBe('Producer review complete'); + }); + + it('returns label for undefined', async () => { + const theme = await loadTheme(); + expect(theme.needsProducerReviewLabel(undefined)).toBe('Producer review complete'); + }); +}); + describe('repo-wide grep guard (WL-0MT2GMTAZ003VKVL)', () => { it('no file imports from a removed icons.js/icons.ts path', () => { const offenders: string[] = []; diff --git a/tests/unit/link-shared.test.ts b/tests/unit/link-shared.test.ts new file mode 100644 index 000000000..d5beeca10 --- /dev/null +++ b/tests/unit/link-shared.test.ts @@ -0,0 +1,217 @@ +/** + * tests/scripts/link-shared.test.ts — Behavioural regression tests for + * packages/herdr/scripts/link-shared.mjs (WL-0MVAY631M004RZ3Q). + * + * The herdr plugin installs its `@worklog/shared` file: dependency as a + * PACKAGED COPY inside packages/herdr/node_modules. That copy silently goes + * stale whenever packages/shared changes: the plugin then imports a missing + * export ('needsProducerReviewIcon' during the F2 restore) and the pane + * dies on startup with an opaque ESM SyntaxError. linkSharedPackage() makes + * the resolution LIVE instead — a relative symlink to the workspace + * packages/shared — so no stale copy can survive a build. + * + * Every scenario runs against an isolated temp repo; nothing touches the + * real checkout. + */ + +import { describe, it, expect } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { linkSharedPackage, resolveSharedLinkTarget } from '../../packages/herdr/scripts/link-shared.mjs'; + +/** Build a minimal repo layout: `<root>/packages/{shared,herdr}`. */ +function makeTempRepo(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wl-link-shared-')); + fs.mkdirSync(path.join(root, 'packages', 'shared', 'dist'), { recursive: true }); + fs.mkdirSync(path.join(root, 'packages', 'herdr'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'packages', 'shared', 'package.json'), + JSON.stringify({ name: '@worklog/shared', version: '1.0.0' }, null, 2), + ); + fs.writeFileSync( + path.join(root, 'packages', 'shared', 'dist', 'icons.js'), + 'export const statusIcon = () => "live";\n', + ); + return root; +} + +/** Simulate npm's packaged copy of @worklog/shared (the stale-artifact form). */ +function writeStaleCopy(root: string, version = '0.9.0'): string { + const sharedDir = path.join(root, 'packages', 'herdr', 'node_modules', '@worklog', 'shared'); + fs.mkdirSync(path.join(sharedDir, 'dist'), { recursive: true }); + fs.writeFileSync(path.join(sharedDir, 'package.json'), JSON.stringify({ name: '@worklog/shared', version }, null, 2)); + fs.writeFileSync(path.join(sharedDir, 'dist', 'icons.js'), 'export const statusIcon = () => "stale";\n'); + return sharedDir; +} + +function removeTemp(root: string): void { + fs.rmSync(root, { recursive: true, force: true }); +} + +describe('linkSharedPackage (packages/herdr/scripts/link-shared.mjs)', () => { + it('replaces a stale packaged @worklog/shared copy with a symlink to the live workspace package', () => { + const root = makeTempRepo(); + try { + const herdrDir = path.join(root, 'packages', 'herdr'); + const staleCopy = writeStaleCopy(root); + const linkPath = resolveSharedLinkTarget(herdrDir); + + // Sanity: before the refresh the vendored path is a real stale copy. + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(false); + expect(fs.readFileSync(path.join(staleCopy, 'dist', 'icons.js'), 'utf8')).toContain('stale'); + + const result = linkSharedPackage(herdrDir); + + expect(result).toBe('linked'); + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); + // The link now resolves to the LIVE workspace package — not the copy. + const resolved = fs.realpathSync(linkPath); + expect(resolved).toBe(path.join(root, 'packages', 'shared')); + expect(JSON.parse(fs.readFileSync(path.join(linkPath, 'package.json'), 'utf8')).version).toBe('1.0.0'); + expect(fs.readFileSync(path.join(linkPath, 'dist', 'icons.js'), 'utf8')).toContain('live'); + } finally { + removeTemp(root); + } + }); + + it('is idempotent: leaves an already-correct symlink untouched', () => { + const root = makeTempRepo(); + try { + const herdrDir = path.join(root, 'packages', 'herdr'); + const first = linkSharedPackage(herdrDir); + expect(first).toBe('linked'); + + const second = linkSharedPackage(herdrDir); + expect(second).toBe('unchanged'); + expect(fs.lstatSync(resolveSharedLinkTarget(herdrDir)).isSymbolicLink()).toBe(true); + } finally { + removeTemp(root); + } + }); + + it('creates the symlink when @worklog/shared is missing entirely (fresh install)', () => { + const root = makeTempRepo(); + try { + const herdrDir = path.join(root, 'packages', 'herdr'); + const linkPath = resolveSharedLinkTarget(herdrDir); + expect(fs.existsSync(linkPath)).toBe(false); + + const result = linkSharedPackage(herdrDir); + + expect(result).toBe('linked'); + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); + } finally { + removeTemp(root); + } + }); + + it('repairs a symlink that points at the wrong target', () => { + const root = makeTempRepo(); + try { + const herdrDir = path.join(root, 'packages', 'herdr'); + const linkPath = resolveSharedLinkTarget(herdrDir); + // Someone pointed @worklog/shared at an unrelated directory. + fs.mkdirSync(path.dirname(linkPath), { recursive: true }); + const wrongTarget = path.join(root, 'nowhere'); + fs.mkdirSync(wrongTarget, { recursive: true }); + fs.symlinkSync(wrongTarget, linkPath, 'dir'); + + const result = linkSharedPackage(herdrDir); + expect(result).toBe('linked'); + expect(fs.realpathSync(linkPath)).toBe(path.join(root, 'packages', 'shared')); + } finally { + removeTemp(root); + } + }); + + it('throws a clear error when the workspace shared package is absent', () => { + const root = makeTempRepo(); + try { + // Remove the workspace package so there is nothing to link to. + fs.rmSync(path.join(root, 'packages', 'shared'), { recursive: true, force: true }); + const herdrDir = path.join(root, 'packages', 'herdr'); + expect(() => linkSharedPackage(herdrDir)).toThrow(/packages\/shared/); + } finally { + removeTemp(root); + } + }); +}); + +/** + * End-to-end wiring: `scripts/install-herdr.sh` must invoke the link step so + * a stale copy left behind by npm is refreshed on every build/install. This + * runs the REAL installer against an isolated temp repo with fakes for npm, + * node's package install and herdr, so nothing touches the real checkout. + */ +describe('install-herdr.sh links @worklog/shared (integration)', () => { + it('converts a stale copy left by npm into a symlink to the live workspace package', () => { + const repoRoot = path.resolve(__dirname, '..', '..'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wl-install-herdr-link-')); + try { + // Minimal repo layout the installer expects. + fs.mkdirSync(path.join(root, 'scripts'), { recursive: true }); + fs.mkdirSync(path.join(root, 'packages', 'herdr', 'scripts'), { recursive: true }); + fs.mkdirSync(path.join(root, 'packages', 'shared', 'dist'), { recursive: true }); + fs.copyFileSync( + path.join(repoRoot, 'scripts', 'install-herdr.sh'), + path.join(root, 'scripts', 'install-herdr.sh'), + ); + fs.copyFileSync( + path.join(repoRoot, 'packages', 'herdr', 'scripts', 'link-shared.mjs'), + path.join(root, 'packages', 'herdr', 'scripts', 'link-shared.mjs'), + ); + fs.writeFileSync( + path.join(root, 'packages', 'herdr', 'package.json'), + JSON.stringify({ name: '@worklog/herdr-plugin', version: '0.1.0', private: true }), + ); + fs.writeFileSync( + path.join(root, 'packages', 'herdr', 'herdr-plugin.toml'), + 'id = "worklog-selection-list"\n', + ); + fs.writeFileSync( + path.join(root, 'packages', 'shared', 'package.json'), + JSON.stringify({ name: '@worklog/shared', version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(root, 'packages', 'shared', 'dist', 'icons.js'), + 'export const statusIcon = () => "live";\n', + ); + // npm's packaged (stale) copy, as if a previous install left it behind. + writeStaleCopy(root); + + // Fake npm: exit 0 without invoking the real installer (the stale copy + // is left in place, exactly as the reported bug produced it). + const fakeBin = path.join(root, 'bin'); + fs.mkdirSync(fakeBin); + fs.writeFileSync(path.join(fakeBin, 'npm'), '#!/usr/bin/env bash\nexit 0\n'); + fs.chmodSync(path.join(fakeBin, 'npm'), 0o755); + // Fake herdr: the plugin link is irrelevant to this test. + fs.writeFileSync(path.join(fakeBin, 'herdr'), '#!/usr/bin/env bash\nexit 0\n'); + fs.chmodSync(path.join(fakeBin, 'herdr'), 0o755); + + // Keep the real git (worktree resolution) and node, but the fake npm / + // herdr win. Exclude the test-suite git mock, which lacks `worktree`. + const mockBinDir = path.resolve(repoRoot, 'tests', 'cli', 'mock-bin'); + const cleanPath = (process.env.PATH ?? '') + .split(path.delimiter) + .filter((d) => d !== '' && path.resolve(d) !== mockBinDir); + const scriptPath = `${fakeBin}${path.delimiter}${cleanPath.join(path.delimiter)}`; + + const configPath = path.join(root, 'herdr', 'config.toml'); + const result = spawnSync('bash', [path.join(root, 'scripts', 'install-herdr.sh')], { + cwd: root, + encoding: 'utf8', + env: { ...process.env, PATH: scriptPath, HERDR_CONFIG_PATH: configPath }, + }); + expect(result.status, `installer failed: ${result.stderr}`).toBe(0); + + const linkPath = resolveSharedLinkTarget(path.join(root, 'packages', 'herdr')); + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); + expect(fs.realpathSync(linkPath)).toBe(path.join(root, 'packages', 'shared')); + } finally { + removeTemp(root); + } + }); +}); diff --git a/tests/unit/needs-producer-review-restore-migration.test.ts b/tests/unit/needs-producer-review-restore-migration.test.ts new file mode 100644 index 000000000..dbc82769b --- /dev/null +++ b/tests/unit/needs-producer-review-restore-migration.test.ts @@ -0,0 +1,158 @@ +/** + * Migration test for RESTORING the `needsProducerReview` column + * (WL-0MVAMS4RO000H3B0 F2). + * + * The removal (WL-0MV85WFYU0071YQ9) dropped the column via + * `20261015-drop-needsProducerReview`. The removal was a wrongful deletion + * (RCA WL-0MVAOYBZM0065AEN); the restore: + * + * 1. re-adds the column via `20261016-restore-needsProducerReview`, and + * 2. neutralises the historical drop migration into a metadata-sentinel + * no-op so it can never remove the column again. + * + * These specs prove the restore migration is detected on a database that + * lost the column, re-adds it, converts surviving producer-hold assignees + * (`assignee = 'Map'`) to `needsProducerReview = 1` (Q3=A), and is + * idempotent on a second run. Replaces the removal-era + * `needs-producer-review-drop-migration.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import * as path from 'path'; +import Database from 'better-sqlite3'; +import { createTempDir, cleanupTempDir } from '../test-utils.js'; +import { listPendingMigrations, runMigrations } from '../../src/migrations/index.js'; + +/** A legacy database that has LOST the column (mirrors a live post-removal store). */ +function createLegacyDbWithoutFlag(dbPath: string): void { + const db = new Database(dbPath); + try { + db.exec(` + CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS workitems ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + status TEXT NOT NULL, + priority TEXT NOT NULL, + sortIndex INTEGER NOT NULL DEFAULT 0, + parentId TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + tags TEXT NOT NULL, + assignee TEXT NOT NULL, + stage TEXT NOT NULL, + issueType TEXT NOT NULL, + createdBy TEXT NOT NULL, + deletedBy TEXT NOT NULL, + deleteReason TEXT NOT NULL, + risk TEXT NOT NULL, + effort TEXT NOT NULL, + githubIssueNumber INTEGER, + githubIssueId INTEGER, + githubIssueUpdatedAt TEXT + ); + INSERT OR REPLACE INTO metadata (key, value) VALUES ('schemaVersion', '8'); + INSERT OR REPLACE INTO metadata (key, value) VALUES ('needsProducerReview_added', '1'); + INSERT OR REPLACE INTO metadata (key, value) VALUES ('needsProducerReview_dropped', '1'); + `); + } finally { + db.close(); + } +} + +function columns(dbPath: string): string[] { + const db = new Database(dbPath, { readonly: true }); + try { + return (db.prepare(`PRAGMA table_info('workitems')`).all() as Array<{ name: string }>).map(c => c.name); + } finally { + db.close(); + } +} + +function flagFor(dbPath: string, id: string): number { + const db = new Database(dbPath, { readonly: true }); + try { + const row = db.prepare(`SELECT needsProducerReview AS f FROM workitems WHERE id = ?`).get(id) as { f: number }; + return row.f; + } finally { + db.close(); + } +} + +describe('migrations: restore needsProducerReview column', () => { + it('lists the restore migration as pending when the column is missing', () => { + const tempDir = createTempDir(); + try { + const dbPath = path.join(tempDir, 'worklog.db'); + createLegacyDbWithoutFlag(dbPath); + expect(columns(dbPath)).not.toContain('needsProducerReview'); + + const pending = listPendingMigrations(dbPath); + expect(pending.map(p => p.id)).toContain('20261016-restore-needsProducerReview'); + } finally { + cleanupTempDir(tempDir); + } + }); + + it('re-adds the column and never lets the drop migration remove it again', () => { + const tempDir = createTempDir(); + try { + const dbPath = path.join(tempDir, 'worklog.db'); + createLegacyDbWithoutFlag(dbPath); + + const applied = runMigrations({ confirm: true }, dbPath); + expect(applied.applied.map(a => a.id)).toContain('20261016-restore-needsProducerReview'); + // The neutralised historical drop migration must never run a drop. + expect(applied.applied.map(a => a.id)).not.toContain('20261015-drop-needsProducerReview'); + expect(columns(dbPath)).toContain('needsProducerReview'); + } finally { + cleanupTempDir(tempDir); + } + }); + + it('converts a surviving producer hold (assignee Map) to needsProducerReview = 1 (Q3=A)', () => { + const tempDir = createTempDir(); + try { + const dbPath = path.join(tempDir, 'worklog.db'); + createLegacyDbWithoutFlag(dbPath); + // Seed a producer-hold row BEFORE the migration: the legacy schema has + // an `assignee` column but no `needsProducerReview` column yet. + const seed = new Database(dbPath); + try { + const now = new Date().toISOString(); + seed.prepare( + `INSERT INTO workitems (id, title, description, status, priority, sortIndex, parentId, createdAt, updatedAt, tags, assignee, stage, issueType, createdBy, deletedBy, deleteReason, risk, effort) + VALUES (?, ?, '', 'completed', 'medium', 0, NULL, ?, ?, '', ?, 'in_review', 'task', '', '', '', '', '')`, + ).run('WL-HOLD', 'Held item', now, now, 'Map'); + } finally { + seed.close(); + } + + // The migration adds the column and converts the surviving hold (Q3=A). + runMigrations({ confirm: true }, dbPath); + expect(columns(dbPath)).toContain('needsProducerReview'); + expect(flagFor(dbPath, 'WL-HOLD')).toBe(1); + } finally { + cleanupTempDir(tempDir); + } + }); + + it('is idempotent on a second run', () => { + const tempDir = createTempDir(); + try { + const dbPath = path.join(tempDir, 'worklog.db'); + createLegacyDbWithoutFlag(dbPath); + + runMigrations({ confirm: true }, dbPath); + const secondRun = runMigrations({ confirm: true }, dbPath); + expect(secondRun.applied.map(a => a.id)).not.toContain('20261016-restore-needsProducerReview'); + expect(columns(dbPath)).toContain('needsProducerReview'); + } finally { + cleanupTempDir(tempDir); + } + }); +}); diff --git a/tests/unit/start-launcher.test.ts b/tests/unit/start-launcher.test.ts new file mode 100644 index 000000000..c8fe0de56 --- /dev/null +++ b/tests/unit/start-launcher.test.ts @@ -0,0 +1,112 @@ +/** + * tests/unit/start-launcher.test.ts — Pane-launcher diagnostics + * (WL-0MVAY631M004RZ3Q). + * + * herdr starts the Worklog pane via `npx tsx src/start.ts`. That launcher + * wraps the real plugin entry (index.ts) in a dynamic import so a + * module-load failure — the ESM "does not provide an export named ..." + * SyntaxError thrown when the vendored @worklog/shared is stale — is + * caught and surfaced as an actionable diagnostic instead of the pane + * exiting silently on startup. + * + * Covers: the diagnostic formatter (unit) and the launcher's end-to-end + * behaviour against a fixture that reproduces the missing-export crash. + */ + +import { describe, it, expect } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { formatStartupError } from '../../packages/herdr/src/start.js'; + +describe('formatStartupError', () => { + const missingExport = + "SyntaxError: The requested module '@worklog/shared/icons' does not provide an export named 'needsProducerReviewIcon'"; + + it('explains a missing shared export and points at the rebuild fix', () => { + const msg = formatStartupError(new Error(missingExport)); + expect(msg).toContain(missingExport); + // Actionable: names the stale shared code and the fix. + expect(msg.toLowerCase()).toContain('stale'); + expect(msg).toContain('@worklog/shared'); + expect(msg).toContain('npm run build'); + }); + + it('keeps the original message for unrelated startup errors', () => { + const err = new Error("Cannot access 'modeSwitchWorker' before initialization"); + const msg = formatStartupError(err); + expect(msg).toContain(err.message); + expect(msg).toContain('npm run build'); + }); + + it('handles non-Error throws without crashing', () => { + const msg = formatStartupError('boom'); + expect(msg).toContain('boom'); + }); +}); + +interface LauncherResult { + code: number | null; + output: string; +} + +/** Run `npx tsx src/start.ts` from the herdr package and capture output + exit code. */ +function runLauncher(env: Record<string, string>, timeoutMs = 60_000): Promise<LauncherResult> { + return new Promise((resolve) => { + // This test lives in tests/unit; the plugin entry lives in packages/herdr. + const cwd = path.resolve( + fileURLToPath(new URL('../..', import.meta.url)), + 'packages', 'herdr', + ); + const child: ChildProcess = spawn('npx', ['tsx', 'src/start.ts'], { + cwd, + // stdin closed: the launcher's keep-open wait is TTY-gated, so with + // no TTY it must exit promptly instead of blocking the test. + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, ...env }, + }); + let output = ''; + let settled = false; + const timer = setTimeout( + () => finish(null), + timeoutMs, + ); + function finish(code: number | null): void { + if (settled) return; + settled = true; + clearTimeout(timer); + child.removeAllListeners(); + child.kill('SIGKILL'); + resolve({ code, output }); + } + const collect = (chunk: Buffer): void => { + output += chunk.toString(); + }; + child.stdout.on('data', collect); + child.stderr.on('data', collect); + child.on('exit', (code) => finish(code)); + child.on('error', () => finish(-1)); + }); +} + +describe('pane launcher (src/start.ts, end-to-end)', () => { + it('surfaces a clear diagnostic on a missing shared export instead of dying silently', async () => { + const { code, output } = await runLauncher({ + WORKLOG_PLUGIN_ENTRY: './__fixtures__/stale-entry.ts', + }); + + // The launcher exits non-zero but the failure is explained. + expect(code).toBe(1); + expect(output).toMatch(/does not provide an export named 'needsProducerReviewIcon'/); + expect(output).toMatch(/npm run build/); + expect(output.toLowerCase()).toContain('stale'); + }, 60_000); + + it('reports a non-zero exit and diagnostic when the entry module is missing', async () => { + const { code, output } = await runLauncher({ + WORKLOG_PLUGIN_ENTRY: './__fixtures__/does-not-exist.ts', + }); + expect(code).toBe(1); + expect(output).toMatch(/npm run build/); + }, 60_000); +}); diff --git a/tests/unit/version-parity.test.ts b/tests/unit/version-parity.test.ts new file mode 100644 index 000000000..1046db0fd --- /dev/null +++ b/tests/unit/version-parity.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** + * src/version.ts is auto-generated (`scripts/generate-version.cjs`, wired into + * `npm run build`) and holds the CLI's canonical WORKLOG_VERSION. A package.json + * version bump that forgets to regenerate it causes the CLI to report a stale + * version, which the init tests catch as "expected X to be <package.json>". + * This guard fails fast so the drift surfaces at test time, not after release. + */ +describe('version parity', () => { + it('src/version.ts WORKLOG_VERSION matches package.json version', () => { + const repoRoot = path.resolve(__dirname, '..', '..'); + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { version: string }; + const versionSrc = fs.readFileSync(path.join(repoRoot, 'src', 'version.ts'), 'utf8'); + const match = versionSrc.match(/WORKLOG_VERSION = '([^']+)'/); + expect(match, `WORKLOG_VERSION export not found in src/version.ts`).not.toBeNull(); + expect(match![1]).toBe(pkg.version); + }); +}); diff --git a/tests/unit/worklist-help-layout.test.ts b/tests/unit/worklist-help-layout.test.ts new file mode 100644 index 000000000..eb15d9801 --- /dev/null +++ b/tests/unit/worklist-help-layout.test.ts @@ -0,0 +1,223 @@ +/** + * tests/unit/worklist-help-layout.test.ts — Help-screen layout contract + * (parent WL-0MTV97GV20088CS7; producer audits rejected earlier layouts on + * 2026-10-15 and 2026-10-16). + * + * The 2026-10-15 producer audit required: + * - command chords in a SINGLE column with FULL (never ellipsis-truncated) + * descriptions, grouped logically with headings separating groups; + * - icons grouped by their category prefix under headings, with the prefix + * removed from the meaning and each group on a single line. + * + * The 2026-10-16 producer audit required: + * - blank lines separating the sections; + * - colours for the headings; + * - the ability to scroll the text. + * + * These pins assert the reworked `formatHelpScreen()` output directly (the + * public formatter the render loop calls). The overlay's open/dismiss and + * live-registry wiring are covered by `packages/herdr/src/worklist-help.test.ts`; + * this file lives under `tests/unit/` because the configured project suite + * (`.pi/test-config.json`: `tests/cli` + `tests/unit`) does not run the + * package-local `packages/herdr/src/*.test.ts` files — the same layering note + * as `tests/unit/form-dialog-open-pane.test.ts` (WL-0MUU173V8008IA5E). + * + * Run through the test skill (`/skill:test`); do not invoke vitest directly. + */ +import { describe, it, expect } from 'vitest'; + +import { formatHelpScreen } from '../../packages/herdr/src/worklist.js'; +import { loadShortcutConfig } from '../../packages/herdr/src/shortcut-config.js'; +import { ICON_LEGEND } from '@worklog/shared/icons'; + +/** Strip ANSI SGR escape sequences so layout assertions match visible text. */ +function stripAnsi(s: string): string { + return s.replace(/\x1b\[[0-9;]*m/g, ''); +} + +/** The list-view entries the render path feeds to `formatHelpScreen()`. */ +const entries = loadShortcutConfig() + .getEntriesForStage('plan_complete', false, 'task') + .filter((e) => e.view === 'list' || e.view === 'both'); + +/** The icon legend rows the render path builds from the shared source of truth. */ +const iconRows = ICON_LEGEND.map((e) => ({ + category: e.category, + glyph: e.glyph, + meaning: e.meaning, +})); + +/** Collapse wrapped lines so a full description reads as one contiguous string. */ +const flatten = (frame: string): string => frame.replace(/\s+/g, ' ').trim(); + +describe('help screen layout — single-column grouped commands (WL-0MTV97GV20088CS7)', () => { + const frame = formatHelpScreen(entries, 200, 200, iconRows); + const lines = frame.split('\n'); + + it('renders every command description in full, never truncated with an ellipsis', () => { + // The rejected grid ellipsis-truncated every description to the column + // width; the reworked layout must carry the whole text (word-wrapped). + expect(frame).not.toContain('\u2026'); + + const flat = flatten(stripAnsi(frame)); + for (const entry of entries) { + if (!entry.description) continue; + expect( + flat, + `help should render the full description for "${entry.label ?? entry.command}"`, + ).toContain(entry.description); + } + + // The longest entry proves no mid-description clipping. + expect(flat).toContain( + 'Toggle downtime dispatch for the current pane (d = disable; d again = follow settings; persists across restarts via marker).', + ); + }); + + it('separates command families with a heading per chord leader', () => { + for (const heading of [ + 'd — Downtime', + 'c — Create', + 'i — Implement', + 'r — Review', + 'u — Update', + 'f — Filter', + ]) { + expect( + lines.map(stripAnsi), + `missing group heading "${heading}"`, + ).toContain(heading); + } + }); + + it('separates sections and families with blank lines (2026-10-16 audit)', () => { + const visible = lines.map(stripAnsi); + // A blank line separates the title from the first section heading. + expect(visible.indexOf('')).toBeGreaterThanOrEqual(0); + // A blank line separates each command family: headings are never adjacent. + const familyIndices = visible.map((l, i) => (l.startsWith('d — ') || l.startsWith('c — ') || l.startsWith('i — ') ? i : -1)).filter(i => i >= 0); + for (const idx of familyIndices) { + expect(visible[idx - 1] ?? '', `family heading at ${idx} is not preceded by a blank line`).toBe(''); + expect(visible[idx + 1] ?? 'EOF', `family heading at ${idx} is not followed by content`).not.toBe(''); + } + }); + + it('colours the headings at every level (2026-10-16 audit)', () => { + // Title, section headings and sub-headings each carry bold + a 256-colour + // foreground code and are terminated by a reset — never plain text. + expect(frame).toMatch(/\x1b/); + const expectStyled = (text: string): void => { + const line = lines.find(l => stripAnsi(l).trim() === text.trim()); + expect(line, `heading "${text}" should exist`).toBeDefined(); + expect(line, `heading "${text}" should carry ANSI styling`).toMatch(/\x1b\[1m/); + expect(line, `heading "${text}" should carry a colour`).toMatch(/\x1b\[38;5;\d+m/); + expect(line, `heading "${text}" should be reset`).toContain('\x1b[0m'); + }; + expectStyled('Help'); + expectStyled('Commands'); + expectStyled('d — Downtime'); + expectStyled('Icons'); + }); +}); + +describe('help screen layout — grouped icon legend (WL-0MTV97GV20088CS7)', () => { + const frame = formatHelpScreen(entries, 200, 260, iconRows); + const lines = frame.split('\n'); + + it('groups icons by category under headings', () => { + // `Producer Review` was deliberately absent from this list: the + // needsProducerReview removal (WL-0MV85WFYU0071YQ9) deleted the + // producer-review icon category from ICON_LEGEND, so no such heading is + // rendered anymore. `Epic` is likewise absent: the epic glyph removal + // (WL-0MVAQ873D007LNWD) dropped the epic legend category, so no epic + // heading is rendered. + for (const heading of [ + 'Status', + 'Stage', + 'Priority', + 'Risk', + 'Effort', + 'Audit', + 'Agent', + 'Coverage', + ]) { + expect( + lines.map(stripAnsi), + `missing icon group heading "${heading}"`, + ).toContain(heading); + } + }); + + it('removes the category name prefix from each meaning', () => { + const frame = formatHelpScreen(entries, 200, 260, iconRows); + // The prefix is gone … + expect(frame).not.toContain('Status: open'); + expect(frame).not.toContain('Stage: idea'); + expect(frame).not.toContain('Priority: critical'); + // … and the bare meaning remains under its heading. + expect(frame).toContain('open — not started'); + // The epic meaning is gone too (its legend entry was removed with the + // glyph, WL-0MVAQ873D007LNWD). + expect(frame).not.toContain('parent work item'); + }); + + it('lists each group on a single line under its heading on a wide pane', () => { + const statusLine = lines.find((l) => l.includes('open — not started')); + expect(statusLine).toBeDefined(); + expect(statusLine).toContain('input needed'); + }); +}); + +describe('help screen layout — scrolling (2026-10-16 audit)', () => { + it('shows a scroll hint in the footer when content overflows the pane', () => { + const rows = 12; + const small = formatHelpScreen(entries, 100, rows, iconRows); + const visible = stripAnsi(small); + // Dismiss keys stay discoverable… + expect(visible).toContain('? / Esc / q'); + // … and the footer advertises the scroll capability. + expect(visible).toMatch(/↑↓\/j:k scroll/); + expect(visible).toMatch(/g\/G top\/bot/); + }); + + it('scrolls the content window to reveal hidden lines', () => { + const rows = 12; + const top = formatHelpScreen(entries, 100, rows, iconRows, 0); + const bottom = formatHelpScreen(entries, 100, rows, iconRows, 10_000); + const topVisible = stripAnsi(top); + const bottomVisible = stripAnsi(bottom); + // Top shows the early command families; the bottom window shows the last + // icon category (`Coverage`) instead of the top of the document. + expect(topVisible).toContain('d — Downtime'); + expect(topVisible).not.toContain('Coverage'); + expect(bottomVisible).toContain('Coverage'); + expect(bottomVisible.match(/Coverage/g) ?? []).toHaveLength(1); + // Scrolling further is clamped — the last window is stable. + expect(bottomVisible).toBe( + stripAnsi(formatHelpScreen(entries, 100, rows, iconRows, 20_000)), + ); + // The budget invariant holds for every window. + for (const f of [top, bottom]) { + expect(f.split('\n').length).toBeLessThanOrEqual(rows - 1); + } + }); +}); + +describe('help screen layout — pane budget (WL-0MTV97GV20088CS7)', () => { + it('clips to the rows - 1 budget while keeping the dismiss keys visible', () => { + const cols = 100; + const rows = 12; + const small = formatHelpScreen(entries, cols, rows, iconRows); + const lines = small.split('\n'); + + expect(lines.length).toBeLessThanOrEqual(rows - 1); + expect(stripAnsi(small)).toContain('? / Esc / q'); + // No rendered line overflows the pane width (wrap, never truncate). + // ANSI colour codes are invisible, so measure the visible text. + for (const line of lines) { + const visible = stripAnsi(line); + expect(visible.length, `line overflows ${cols} cols: ${JSON.stringify(line)}`) + .toBeLessThanOrEqual(cols); + } + }); +});