@zhuxixi/pi-agent-board 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +10 -2
  3. package/VERIFY.md +2 -1
  4. package/docs/superpowers/plans/2026-09-29-issue-145-holding-semantic-state.md +1183 -0
  5. package/docs/superpowers/plans/2026-09-29-issue-145-user-verification.md +66 -0
  6. package/docs/superpowers/plans/2026-09-30-board-delete-key-x-y-confirm.md +649 -0
  7. package/docs/superpowers/plans/2026-09-30-issue-153-runner-stop-boot-window.md +618 -0
  8. package/docs/superpowers/plans/2026-09-30-issue-95-test-discipline.md +555 -0
  9. package/docs/superpowers/plans/2026-09-30-publish-dispatch-tag-check.md +259 -0
  10. package/docs/superpowers/specs/2026-09-29-issue-145-holding-semantic-state-design.md +394 -0
  11. package/docs/superpowers/specs/2026-09-30-board-delete-key-x-y-confirm-design.md +86 -0
  12. package/docs/superpowers/specs/2026-09-30-issue-153-runner-stop-boot-window-design.md +75 -0
  13. package/docs/superpowers/specs/2026-09-30-issue-95-test-discipline-design.md +226 -0
  14. package/docs/superpowers/specs/2026-09-30-publish-dispatch-tag-check-design.md +129 -0
  15. package/package.json +1 -1
  16. package/runner/job-runner.mjs +56 -22
  17. package/runner/pty-runner-legacy.mjs +1 -1
  18. package/runner/state-coordinator.mjs +1 -1
  19. package/runner/state-runner.mjs +2 -2
  20. package/scripts/budget-audit.mjs +135 -0
  21. package/src/core/auto-state.mjs +12 -10
  22. package/src/core/coordinator-protocol.mjs +3 -1
  23. package/src/core/derive.mjs +3 -0
  24. package/src/core/evidence.mjs +4 -1
  25. package/src/core/host-probe.mjs +12 -1
  26. package/src/core/pty-attach-reconnect.mjs +12 -1
  27. package/src/core/rows.mjs +4 -0
  28. package/src/core/state-commands.mjs +62 -8
  29. package/src/core/stop-latch.mjs +41 -0
  30. package/src/core/store.mjs +8 -1
  31. package/src/core/terminal-query-sequences.mjs +26 -0
  32. package/src/core/test-knobs.mjs +28 -0
  33. package/src/core/types.mjs +5 -2
  34. package/src/core/warm-host-sweeper.mjs +13 -0
  35. package/src/runtime/service.mjs +149 -28
  36. package/src/ui/dashboard.ts +46 -1
  37. package/src/ui/pty-attach.ts +49 -3
@@ -0,0 +1,1183 @@
1
+ # Holding Semantic State + Manual-Fence Lift Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add the 8th semantic state `holding` (manual-only, on-hold) and fix the manual-fence regression where user input on a verdict row never lifts the fence (row shows Done/On hold while the agent runs).
6
+
7
+ **Architecture:** Two deliverables in one branch. F-hold adds a vocabulary entry, two fenced command kinds (`mark_holding`/`clear_holding`), a widened manual-verdict predicate, and UI (`h` toggle). F-lift switches the `sync_foreground` mirror's source to `dashboard-user` when the child Pi reports `InputEvent.source === "interactive"` — no new command kind, verified to pass exactly one guard (manual_fence).
8
+
9
+ **Tech Stack:** Node.js 20+ ESM (.mjs) with JSDoc types, TypeScript dashboard (`src/ui/dashboard.ts`, typecheck via `npm run typecheck`), `node:test` + `assert/strict`.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-09-29-issue-145-holding-semantic-state-design.md` — the plan argues from the spec; executors read both. Acceptance IDs (A1–A21, U1–U7) below refer to the spec's §9 matrix.
12
+
13
+ ## Global Constraints
14
+
15
+ - Indentation: **tabs**. Comments and commit messages: **English**.
16
+ - Commit subjects: conventional (`feat`/`fix`/`test`/`docs`/`refactor`/`chore`), ending with `(issue #145)`; the PR number `(#N)` is appended at merge time per repo convention.
17
+ - `npm run verify` (typecheck + perf gate + tests + coverage thresholds lines 85 / funcs 80 / branches 70 + pack:dry) must pass before the branch is called done — `npm test` alone is NOT sufficient.
18
+ - Never hand-edit `CHANGELOG.md` (machine-generated from commit subjects by release tooling).
19
+ - Naming discipline (spec §4.7): no bare `hold` identifier anywhere — always `holding` / `isHolding` / `markHolding` / `toggleHold`. Comments say "on-hold semantic state", never a bare "hold" (the attach subsystem owns that word for its jiggle protocol).
20
+ - `COORDINATOR_PROTOCOL_VERSION` must be 3 before any `mark_holding`/`clear_holding` command is sent (Task 5; a live v2 coordinator answers `unknown_kind` silently — issue #108 class).
21
+ - Tests use `node:test` + `assert/strict`; anything touching git/fs gets a self-contained fixture under a temp dir. Never mutate `~/.pi/agent/agent-board` from tests.
22
+ - All work happens in this worktree (`$WT`); never touch the main checkout.
23
+
24
+ ## Review Focus
25
+
26
+ Five failure modes the spec implies but individual happy-path tests don't exercise. Each is pinned to the task that owns the code:
27
+
28
+ 1. **A live pre-upgrade coordinator silently rejects the new kinds** (`unknown_kind`, warn-level diagnostics only, holding writes vanish). Pinned: Task 5 asserts `COORDINATOR_PROTOCOL_VERSION >= 3` and Task 12 runs the full coordinator-client suite.
29
+ 2. **A late `auto_state_classified` overwrites the user's on-hold verdict** (the #46 invariant must survive the predicate rename — a missed call site is exactly the silent clobber). Pinned: Task 6's fence test on a holding row + Task 12 keeps `test/state-coordinator.integration.test.mjs` green (A17).
30
+ 3. **`reconcile()` finalizes a holding row to failed/idle after its host dies** (user loses the verdict to a recovery pass). Pinned: Task 7's reconcile-leaves-holding-untouched test.
31
+ 4. **The follow-up queue auto-wakes an on-hold row** (violates "holding 不参与自动续跑"). Pinned: Task 3's `canAutoDrain` state matrix (holding false, idle/completed true — the allow-list must not become a deny-list).
32
+ 5. **Programmatic input (`rpc`/`extension`) wrongly lifts the fence** (an automated injection must not clear a human verdict). Pinned: Task 8's negative tests.
33
+
34
+ ---
35
+
36
+ ### Task 1: Vocabulary layer — types, glyphs, colors, fallback text, STAGE_RGB
37
+
38
+ **Acceptance:** A9, A10, A11 (spec §9). Also unblocks typecheck for every later task.
39
+
40
+ **Files:**
41
+ - Modify: `src/core/types.mjs:6`, `:14`, `:40`, `:53`, `:64`
42
+ - Modify: `src/core/rows.mjs:67` (`stateGlyph`), `:93` (`stateColor`)
43
+ - Modify: `src/core/derive.mjs:16` (`GENERIC_STATUS_TEXT`), `:46` (`fallbackStatusText`)
44
+ - Modify: `src/ui/dashboard.ts:1920` (`STAGE_RGB`)
45
+ - Test: `test/rows.test.mjs`, `test/derive.test.mjs`
46
+
47
+ **Interfaces:**
48
+ - Consumes: none (first task).
49
+ - Produces: `"holding"` valid everywhere `SemanticState` is expected; `GROUP_LABELS.holding === "On hold"`; `GROUP_ORDER` = `… idle, holding, completed …`. Later tasks rely on `"holding"` typechecking.
50
+
51
+ - [ ] **Step 1: Write the failing tests**
52
+
53
+ In `test/rows.test.mjs` (after the existing glyph tests):
54
+
55
+ ```js
56
+ test("holding renders with dedicated glyph/color and filters unambiguously (issue #145)", () => {
57
+ assert.notEqual(stateGlyph("holding", false), "?", "stateGlyph must not fall through to default");
58
+ assert.notEqual(stateColor("holding"), "text", "stateColor must not fall through to default");
59
+ for (const q of ["s:hold", "s:onhold", "s:on", "s:holding"]) {
60
+ assert.deepEqual(parseFilter(q).states, ["holding"], `${q} must match only holding`);
61
+ }
62
+ });
63
+
64
+ test("GROUP_ORDER places holding between idle and completed (issue #145)", () => {
65
+ assert.equal(GROUP_ORDER.indexOf("idle") + 1, GROUP_ORDER.indexOf("holding"));
66
+ assert.equal(GROUP_ORDER.indexOf("holding") + 1, GROUP_ORDER.indexOf("completed"));
67
+ assert.equal(GROUP_LABELS.holding, "On hold");
68
+ });
69
+ ```
70
+
71
+ Add `GROUP_ORDER`, `GROUP_LABELS`, `parseFilter` to the file's existing import from `../src/core/rows.mjs` / `../src/core/types.mjs` (check what's already imported; extend, don't duplicate).
72
+
73
+ In `test/derive.test.mjs`:
74
+
75
+ ```js
76
+ test("fallbackStatusText maps holding and recognizes it as generic (issue #145)", () => {
77
+ assert.equal(fallbackStatusText("holding"), "On hold");
78
+ assert.equal(isGenericStatusText("On hold"), true);
79
+ });
80
+ ```
81
+
82
+ (`isGenericStatusText` is exported from `src/core/derive.mjs`; verify the export name and extend the import.)
83
+
84
+ - [ ] **Step 2: Run to verify failure**
85
+
86
+ Run: `node --test test/rows.test.mjs test/derive.test.mjs`
87
+ Expected: FAIL — `stateGlyph("holding")` returns `"?"`, `parseFilter("s:hold").states` is `[]`, `GROUP_LABELS.holding` undefined.
88
+
89
+ - [ ] **Step 3: Implement**
90
+
91
+ `src/core/types.mjs`:
92
+
93
+ ```js
94
+ // :6 — add "holding" to the hand-written union (second vocabulary; the spec flags this)
95
+ /** Semantic (task) state of a row. @typedef {"queued"|"working"|"needs_input"|"idle"|"holding"|"completed"|"failed"|"stopped"} SemanticState */
96
+ // :14 — EvidenceOutcome gains "holding" (the table already lists every other semantic state)
97
+ /** Evidence outcome. @typedef {"unknown"|"in_progress"|"ready"|"needs_input"|"failed"|"stopped"|"queued"|"working"|"idle"|"holding"|"completed"} EvidenceOutcome */
98
+ ```
99
+
100
+ `SEMANTIC_STATES` (`:40`) and `GROUP_ORDER` (`:53`): insert `"holding",` on its own line directly after `"idle",`.
101
+
102
+ `GROUP_LABELS` (`:64`): add `holding: "On hold",` after the `idle:` entry.
103
+
104
+ `src/core/rows.mjs` — `stateGlyph` switch, after `case "idle":`:
105
+
106
+ ```js
107
+ case "holding":
108
+ return unread ? "◒" : "◑";
109
+ ```
110
+
111
+ `stateColor` switch, after `case "idle":`:
112
+
113
+ ```js
114
+ case "holding":
115
+ return "warning";
116
+ ```
117
+
118
+ `src/core/derive.mjs`:
119
+
120
+ ```js
121
+ // GENERIC_STATUS_TEXT — grow-only table; holding owns its label
122
+ holding: new Set(["On hold"]),
123
+ // fallbackStatusText switch, after case "idle":
124
+ case "holding":
125
+ return "On hold";
126
+ ```
127
+
128
+ `src/ui/dashboard.ts` `STAGE_RGB` — insert after the `idle` line (amber, distinct from needs_input's `[245, 158, 11]`):
129
+
130
+ ```ts
131
+ holding: [251, 191, 36],
132
+ ```
133
+
134
+ - [ ] **Step 4: Run tests + typecheck**
135
+
136
+ Run: `node --test test/rows.test.mjs test/derive.test.mjs && npm run typecheck`
137
+ Expected: PASS + typecheck clean (the `satisfies Record<keyof typeof GROUP_LABELS, …>` guard on `STAGE_RGB` is what makes the omission impossible).
138
+
139
+ - [ ] **Step 5: Commit**
140
+
141
+ ```bash
142
+ git add src/core/types.mjs src/core/rows.mjs src/core/derive.mjs src/ui/dashboard.ts test/rows.test.mjs test/derive.test.mjs
143
+ git commit -m "feat: add holding to the semantic-state vocabulary (issue #145)"
144
+ ```
145
+
146
+ ---
147
+
148
+ ### Task 2: `isManualVerdict` predicate
149
+
150
+ **Acceptance:** A1.
151
+
152
+ **Files:**
153
+ - Modify: `src/core/auto-state.mjs` (add `isManualVerdict` next to `isManualCompletion` — the old export is deleted in Task 6, keeping this task green)
154
+ - Test: `test/auto-state.test.mjs`
155
+
156
+ **Interfaces:**
157
+ - Produces: `isManualVerdict(state) -> boolean` — true iff `semanticState ∈ {"completed","holding"}` AND `autoState == null`. Task 4/6/8 consume it.
158
+
159
+ - [ ] **Step 1: Write the failing test**
160
+
161
+ Replace the existing `isManualCompletion distinguishes…` test (`test/auto-state.test.mjs:148-153`) with:
162
+
163
+ ```js
164
+ test("isManualVerdict recognizes both manual verdict states (issue #145)", () => {
165
+ assert.equal(isManualVerdict({ semanticState: "completed", autoState: null }), true);
166
+ assert.equal(isManualVerdict({ semanticState: "holding", autoState: null }), true);
167
+ assert.equal(isManualVerdict({ semanticState: "completed", autoState: { kind: "done" } }), false);
168
+ assert.equal(isManualVerdict({ semanticState: "holding", autoState: { kind: "done" } }), false);
169
+ assert.equal(isManualVerdict({ semanticState: "idle", autoState: null }), false);
170
+ assert.equal(isManualVerdict(null), false);
171
+ });
172
+ ```
173
+
174
+ Add `isManualVerdict` to the import from `../src/core/auto-state.mjs`. **Do not delete the `isManualCompletion` import/test yet** — 14 call sites still use it (Task 6).
175
+
176
+ - [ ] **Step 2: Run to verify failure**
177
+
178
+ Run: `node --test test/auto-state.test.mjs`
179
+ Expected: FAIL — `isManualVerdict is not a function`.
180
+
181
+ - [ ] **Step 3: Implement**
182
+
183
+ In `src/core/auto-state.mjs`, directly above `isManualCompletion`:
184
+
185
+ ```js
186
+ /**
187
+ * Whether a row/status carries a manual verdict (issue #145): the user placed a
188
+ * terminal judgment ("completed" or "holding") that no automated writer may
189
+ * overwrite. mark_completed and mark_holding both clear autoState on
190
+ * state.json and status.json, so `autoState == null` combined with a verdict
191
+ * state is the persisted signal. Supersedes isManualCompletion (no call site
192
+ * needs the narrow completed-only form — spec D15).
193
+ * @param {{ semanticState?: string, autoState?: unknown|null }|null|undefined} state
194
+ */
195
+ export function isManualVerdict(state) {
196
+ return Boolean(state && (state.semanticState === "completed" || state.semanticState === "holding") && state.autoState == null);
197
+ }
198
+ ```
199
+
200
+ - [ ] **Step 4: Run tests**
201
+
202
+ Run: `node --test test/auto-state.test.mjs`
203
+ Expected: PASS.
204
+
205
+ - [ ] **Step 5: Commit**
206
+
207
+ ```bash
208
+ git add src/core/auto-state.mjs test/auto-state.test.mjs
209
+ git commit -m "feat: add isManualVerdict covering completed and holding (issue #145)"
210
+ ```
211
+
212
+ ---
213
+
214
+ ### Task 3: Migrate `canAutoDrain` to a pure exported predicate
215
+
216
+ **Acceptance:** A8 (drain half), Review Focus #4.
217
+
218
+ **Files:**
219
+ - Modify: `src/core/warm-host-sweeper.mjs` (add `canAutoDrain`)
220
+ - Modify: `src/runtime/service.mjs:56` (import), `:2278-2281` (delete local)
221
+ - Test: `test/warm-host-sweeper.test.mjs`
222
+
223
+ **Interfaces:**
224
+ - Produces: `canAutoDrain(row) -> boolean` exported from `src/core/warm-host-sweeper.mjs`. Expression is byte-identical to the current private one: `!isAgentBusy(row) && (st === "idle" || st === "completed")`.
225
+
226
+ - [ ] **Step 1: Write the failing test**
227
+
228
+ In `test/warm-host-sweeper.test.mjs`:
229
+
230
+ ```js
231
+ test("canAutoDrain is an allow-list that excludes holding (issue #145)", () => {
232
+ const row = (semanticState, alive = false) => ({
233
+ alive,
234
+ state: { semanticState, processState: alive ? "alive" : "exited", pendingQuestions: [] },
235
+ });
236
+ assert.equal(canAutoDrain(row("holding")), false, "a user's on-hold verdict must never be woken by the queue");
237
+ assert.equal(canAutoDrain(row("idle")), true);
238
+ assert.equal(canAutoDrain(row("completed")), true);
239
+ assert.equal(canAutoDrain(row("failed")), false);
240
+ assert.equal(canAutoDrain(row("stopped")), false);
241
+ assert.equal(canAutoDrain(row("needs_input")), false);
242
+ assert.equal(canAutoDrain(row("working", true)), false);
243
+ });
244
+ ```
245
+
246
+ Add `canAutoDrain` to the existing import from `../src/core/warm-host-sweeper.mjs`.
247
+
248
+ - [ ] **Step 2: Run to verify failure**
249
+
250
+ Run: `node --test test/warm-host-sweeper.test.mjs`
251
+ Expected: FAIL — no such export.
252
+
253
+ - [ ] **Step 3: Implement**
254
+
255
+ In `src/core/warm-host-sweeper.mjs`, after `isAgentBusy`:
256
+
257
+ ```js
258
+ /**
259
+ * Whether queued follow-ups may auto-drain for this row (issue #145).
260
+ * Deliberately an allow-list: a manual verdict state outside {idle, completed}
261
+ * (today: holding) must never be woken by the follow-up queue — only the user
262
+ * resumes it. Pure row predicate; co-located with isAgentBusy, which it
263
+ * composes, so both share this unit-test home.
264
+ * @param {import("./store.mjs").Row} row
265
+ */
266
+ export function canAutoDrain(row) {
267
+ const st = row.state?.semanticState;
268
+ return !isAgentBusy(row) && (st === "idle" || st === "completed");
269
+ }
270
+ ```
271
+
272
+ In `src/runtime/service.mjs`:
273
+ - `:56` — extend to `import { canAutoDrain, hasPendingQuestions, isAgentBusy, selectIdleHostsToEvict } from "../core/warm-host-sweeper.mjs";`
274
+ - Delete the local `function canAutoDrain(row) { … }` at `:2278-2281` (including its JSDoc, if any).
275
+
276
+ - [ ] **Step 4: Run tests**
277
+
278
+ Run: `node --test test/warm-host-sweeper.test.mjs test/host-input.test.mjs`
279
+ Expected: PASS (host-input's drain-gate tests must stay green — same expression, new home).
280
+
281
+ - [ ] **Step 5: Commit**
282
+
283
+ ```bash
284
+ git add src/core/warm-host-sweeper.mjs src/runtime/service.mjs test/warm-host-sweeper.test.mjs
285
+ git commit -m "refactor: export canAutoDrain as a pure row predicate (issue #145)"
286
+ ```
287
+
288
+ ---
289
+
290
+ ### Task 4: Command kinds `mark_holding` / `clear_holding` + `source_not_allowed`
291
+
292
+ **Acceptance:** A2, A3, A4, A5 (decision layer).
293
+
294
+ **Files:**
295
+ - Modify: `src/core/state-commands.mjs` — `DECIDED_REJECT_REASONS:71`, `STATE_COMMAND_KINDS:36`, new branches after `mark_completed` (`:284-304`)
296
+ - Test: `test/state-commands.test.mjs`
297
+
298
+ **Interfaces:**
299
+ - Consumes: `isManualCompletion` for the generic fence (unchanged this task — widening happens in Task 6).
300
+ - Produces: kinds `mark_holding`, `clear_holding`; reject reason `source_not_allowed`. Both apply a full field-set patch (idempotent under the coordinator's sparse `{...state, ...mutate.state}` merge).
301
+
302
+ - [ ] **Step 1: Update the vocabulary snapshot, then write the failing tests**
303
+
304
+ `test/state-commands.test.mjs:53-58` — the snapshot becomes:
305
+
306
+ ```js
307
+ assert.deepEqual([...STATE_COMMAND_KINDS], [
308
+ "mark_completed", "mark_holding", "clear_holding", "auto_state_classified", "run_finalized",
309
+ "mark_queued", "run_started", "run_progress", "reconcile_finalize",
310
+ "host_run_failed", "archive_view", "adopt_session", "sync_foreground",
311
+ "plan_ready", "followup_started", "patch_fields",
312
+ ]);
313
+ ```
314
+
315
+ New tests (place after the `-- mark_completed --` block; `manualCompletedState` is the existing fixture — copy its spread pattern):
316
+
317
+ ```js
318
+ // -- mark_holding / clear_holding (issue #145) --
319
+
320
+ test("mark_holding applies a manual-verdict patch mirroring mark_completed", () => {
321
+ const cmd = { ...baseCmd, source: "dashboard-user", kind: "mark_holding", payload: {} };
322
+ const idle = { ...manualCompletedState, semanticState: "idle", autoState: { kind: "in_progress" } };
323
+ const d = decideStateTransition(cmd, idle, null, 20);
324
+ assert.equal(d.action, "apply");
325
+ assert.deepEqual(d.mutate.state, {
326
+ semanticState: "holding",
327
+ processState: "exited",
328
+ needsInput: false,
329
+ hasError: false,
330
+ question: null,
331
+ pendingQuestions: [],
332
+ error: null,
333
+ autoState: null,
334
+ });
335
+ assert.deepEqual(d.mutate.status, { autoState: null });
336
+ });
337
+
338
+ test("mark_holding rejects busy rows and non-user sources independently of the fence", () => {
339
+ const alive = { ...manualCompletedState, semanticState: "idle", processState: "alive", autoState: {} };
340
+ assert.deepEqual(
341
+ decideStateTransition({ ...baseCmd, source: "dashboard-user", kind: "mark_holding" }, alive, null),
342
+ { action: "reject", reason: "busy" },
343
+ );
344
+ // Unfenced row: proves the source guard is its own rule, not the fence.
345
+ assert.deepEqual(
346
+ decideStateTransition({ ...baseCmd, source: "job-runner", kind: "mark_holding" }, { ...manualCompletedState, semanticState: "idle", autoState: {} }, null),
347
+ { action: "reject", reason: "source_not_allowed" },
348
+ );
349
+ assert.ok(DECIDED_REJECT_REASONS.has("source_not_allowed"));
350
+ });
351
+
352
+ test("clear_holding resumes to idle; non-holding rows are no_change", () => {
353
+ const holding = { ...manualCompletedState, semanticState: "holding" };
354
+ const d = decideStateTransition({ ...baseCmd, source: "dashboard-user", kind: "clear_holding" }, holding, null, 20);
355
+ assert.equal(d.action, "apply");
356
+ assert.equal(d.mutate.state.semanticState, "idle");
357
+ assert.equal(d.mutate.state.autoState, null);
358
+ assert.deepEqual(
359
+ decideStateTransition({ ...baseCmd, source: "dashboard-user", kind: "clear_holding" }, { ...manualCompletedState }, null),
360
+ { action: "reject", reason: "no_change" },
361
+ );
362
+ });
363
+
364
+ test("non-user commands stay fenced on a holding row (spec A5)", () => {
365
+ const holding = { ...manualCompletedState, semanticState: "holding" };
366
+ const d = decideStateTransition(baseCmd, holding, null);
367
+ assert.deepEqual(d, { action: "reject", reason: "manual_fence" });
368
+ });
369
+ ```
370
+
371
+ (The last test mirrors the existing `auto_state_classified rejected when manual fence active` at `:19-22` — `baseCmd` is that same classification command.)
372
+
373
+ - [ ] **Step 2: Run to verify failure**
374
+
375
+ Run: `node --test test/state-commands.test.mjs`
376
+ Expected: FAIL — `unknown_kind` / missing reasons.
377
+
378
+ - [ ] **Step 3: Implement**
379
+
380
+ `src/core/state-commands.mjs`:
381
+
382
+ `STATE_COMMAND_KINDS` — insert `"mark_holding",` and `"clear_holding",` right after `"mark_completed",`.
383
+
384
+ `DECIDED_REJECT_REASONS` — add `"source_not_allowed",` after `"field_not_allowed",` and extend the block comment's reason list.
385
+
386
+ New branches directly after the `mark_completed` case:
387
+
388
+ ```js
389
+ case "mark_holding": {
390
+ // Manual-only (spec D14): an on-hold verdict is a human judgment; any
391
+ // automated source is rejected before the fence can matter.
392
+ if (command.source !== "dashboard-user") return reject("source_not_allowed");
393
+ if (currentState.processState === "alive") return reject("busy");
394
+ // autoState: null on both artifacts is the manual-verdict fence signal
395
+ // (see isManualVerdict). Full field-set patch: idempotent under the
396
+ // coordinator's sparse merge, mirrors mark_completed's shape.
397
+ return {
398
+ action: "apply",
399
+ reason: "manual_holding",
400
+ mutate: {
401
+ state: {
402
+ semanticState: "holding",
403
+ processState: "exited",
404
+ needsInput: false,
405
+ hasError: false,
406
+ question: null,
407
+ pendingQuestions: [],
408
+ error: null,
409
+ autoState: null,
410
+ },
411
+ status: { autoState: null },
412
+ },
413
+ };
414
+ }
415
+ case "clear_holding": {
416
+ if (command.source !== "dashboard-user") return reject("source_not_allowed");
417
+ if (currentState.semanticState !== "holding") return reject("no_change");
418
+ if (currentState.processState === "alive") return reject("busy");
419
+ // Un-hold returns the row to automated management (idle = the row is
420
+ // waiting for the next directive; queued follow-ups may drain again).
421
+ return {
422
+ action: "apply",
423
+ reason: "manual_resume",
424
+ mutate: {
425
+ state: {
426
+ semanticState: "idle",
427
+ processState: "exited",
428
+ needsInput: false,
429
+ hasError: false,
430
+ question: null,
431
+ pendingQuestions: [],
432
+ error: null,
433
+ autoState: null,
434
+ },
435
+ status: { autoState: null },
436
+ },
437
+ };
438
+ }
439
+ ```
440
+
441
+ `runner/state-coordinator.mjs:72` — `LAST_ACTIVITY_STAMP_KINDS` gains both kinds:
442
+
443
+ ```js
444
+ const LAST_ACTIVITY_STAMP_KINDS = new Set(["mark_queued", "archive_view", "adopt_session", "reconcile_finalize", "plan_ready", "mark_completed", "mark_holding", "clear_holding", "host_run_failed"]);
445
+ ```
446
+
447
+ - [ ] **Step 4: Run tests**
448
+
449
+ Run: `node --test test/state-commands.test.mjs`
450
+ Expected: PASS.
451
+
452
+ - [ ] **Step 5: Commit**
453
+
454
+ ```bash
455
+ git add src/core/state-commands.mjs runner/state-coordinator.mjs test/state-commands.test.mjs
456
+ git commit -m "feat: mark_holding and clear_holding command kinds (issue #145)"
457
+ ```
458
+
459
+ ---
460
+
461
+ ### Task 5: Coordinator protocol version 2 → 3
462
+
463
+ **Acceptance:** A13. Review Focus #1.
464
+
465
+ **Files:**
466
+ - Modify: `src/core/coordinator-protocol.mjs:12`
467
+ - Test: `test/coordinator-client.test.mjs`
468
+
469
+ **Interfaces:**
470
+ - Produces: `COORDINATOR_PROTOCOL_VERSION === 3`. Client-side `ensureCoordinator` already replaces stale instances on `< N`.
471
+
472
+ - [ ] **Step 1: Write the failing test**
473
+
474
+ In `test/coordinator-client.test.mjs` (the module already imports `COORDINATOR_PROTOCOL_VERSION`):
475
+
476
+ ```js
477
+ test("protocol version gates the holding command kinds (issue #145)", () => {
478
+ // A live v2 coordinator answers unknown_kind for mark_holding/clear_holding
479
+ // (issue #108 class); version 3 is the floor that ships them.
480
+ assert.ok(COORDINATOR_PROTOCOL_VERSION >= 3, `expected >= 3, got ${COORDINATOR_PROTOCOL_VERSION}`);
481
+ });
482
+ ```
483
+
484
+ - [ ] **Step 2: Run to verify failure**
485
+
486
+ Run: `node --test test/coordinator-client.test.mjs`
487
+ Expected: FAIL — expected >= 3, got 2.
488
+
489
+ - [ ] **Step 3: Implement**
490
+
491
+ `src/core/coordinator-protocol.mjs`:
492
+
493
+ ```js
494
+ export const COORDINATOR_PROTOCOL_VERSION = 3;
495
+ ```
496
+
497
+ Extend the header comment: `Version 3 adds mark_holding / clear_holding (issue #145).`
498
+
499
+ - [ ] **Step 4: Run tests**
500
+
501
+ Run: `node --test test/coordinator-client.test.mjs`
502
+ Expected: PASS (the `:371` pong assertion references the constant by name and adapts automatically; the stale-instance tests use a literal `protocolVersion: 1` and are unaffected).
503
+
504
+ - [ ] **Step 5: Commit**
505
+
506
+ ```bash
507
+ git add src/core/coordinator-protocol.mjs test/coordinator-client.test.mjs
508
+ git commit -m "feat: bump coordinator protocol to 3 for holding kinds (issue #145)"
509
+ ```
510
+
511
+ ---
512
+
513
+ ### Task 6: Widen the fence — migrate 14 call sites to `isManualVerdict`, delete `isManualCompletion`
514
+
515
+ **Acceptance:** A5 (fence via renamed predicate), A6, A7. Review Focus #2.
516
+
517
+ **Files:**
518
+ - Modify: `src/core/auto-state.mjs` (`:214`, `:239` call sites; delete `isManualCompletion` at `:200`)
519
+ - Modify: `src/core/state-commands.mjs:31` (import), `:280` (guard)
520
+ - Modify: `src/runtime/service.mjs:12` (import), `:1466`, `:1501`
521
+ - Modify: `runner/state-runner.mjs:12` (import), `:34`
522
+ - Modify: `runner/job-runner.mjs:21` (import), `:187`, `:235`, `:240`, `:471`, `:501`, `:659`, `:680`, `:714`
523
+ - Test: `test/auto-state.test.mjs`
524
+
525
+ **Interfaces:**
526
+ - Consumes: `isManualVerdict` (Task 2).
527
+ - Produces: `isManualCompletion` **no longer exists** — this is deliberate (spec D15): a missed call site becomes a typecheck/import error instead of a silent clobber.
528
+
529
+ - [ ] **Step 1: Write the failing tests**
530
+
531
+ In `test/auto-state.test.mjs`:
532
+
533
+ ```js
534
+ test("the auto-state rules never overwrite a holding verdict (spec A6)", () => {
535
+ const classification = { kind: "needs_input", confidence: "high", source: "model", reason: "asks", question: "Q?", classifiedAt: 5 };
536
+ const state = { viewId: "v", processState: "exited", semanticState: "holding", autoState: null, question: null, pendingQuestions: [], summary: "s" };
537
+ assert.equal(applyAutoStateToViewState(state, classification, 6), false);
538
+ assert.equal(state.semanticState, "holding");
539
+ const status = { viewId: "v", processState: "exited", semanticState: "holding", autoState: null, question: null, summary: "s" };
540
+ assert.equal(applyAutoStateToStatus(status, classification, 6), false);
541
+ assert.equal(status.semanticState, "holding");
542
+ });
543
+
544
+ test("semanticStateForAutoKind never yields holding (spec A7)", () => {
545
+ for (const kind of ["needs_input", "in_progress", "done"]) {
546
+ assert.notEqual(semanticStateForAutoKind(kind), "holding");
547
+ }
548
+ });
549
+ ```
550
+
551
+ Add `semanticStateForAutoKind` to the import if absent.
552
+
553
+ - [ ] **Step 2: Run to verify failure**
554
+
555
+ Run: `node --test test/auto-state.test.mjs`
556
+ Expected: FAIL — the holding row gets overwritten (`false`/`"needs_input"` mismatch) because the guards still use the narrow predicate.
557
+
558
+ - [ ] **Step 3: Implement — mechanical rename, file by file**
559
+
560
+ In every listed file: change the import `isManualCompletion` → `isManualVerdict`, then rename each call site. Comments that say "manual completion" where they mean "manual verdict" get their wording updated in the same pass (e.g. `src/core/state-commands.mjs:278-279` becomes "Manual verdicts are user judgments…"; `runner/job-runner.mjs:499-500` "A manual verdict racing the exit chain…"). **Do not** reword comments that genuinely mean completed (there are none — verified in spec research round 2, table §4).
561
+
562
+ Then delete `isManualCompletion` from `src/core/auto-state.mjs` and remove any now-unused test import references.
563
+
564
+ Sanity net: `rg -n "isManualCompletion" src/ runner/ test/` must return **zero** hits after this step.
565
+
566
+ - [ ] **Step 4: Run tests + typecheck**
567
+
568
+ Run: `node --test test/auto-state.test.mjs test/state-commands.test.mjs && npm run typecheck`
569
+ Expected: PASS + zero dangling references (a missed site fails the import resolution, not silently).
570
+
571
+ - [ ] **Step 5: Commit**
572
+
573
+ ```bash
574
+ git add src/core/auto-state.mjs src/core/state-commands.mjs src/runtime/service.mjs runner/state-runner.mjs runner/job-runner.mjs test/auto-state.test.mjs
575
+ git commit -m "refactor: widen the manual fence to isManualVerdict across all call sites (issue #145)"
576
+ ```
577
+
578
+ ---
579
+
580
+ ### Task 7: Service layer — `holdView` / `clearHoldView` + direct-write fallbacks
581
+
582
+ **Acceptance:** feeds A21/U1/U2/U5; Review Focus #3; documents D4 (markCompletedMany on holding).
583
+
584
+ **Files:**
585
+ - Modify: `src/runtime/service.mjs` — new `holdViewDirect`/`clearHoldViewDirect` helpers next to `completeViewDirect` (`:569-591`); new `holdView`/`clearHoldView` next to `completeView` (`:600-626`); expose both on the returned service object next to `markCompleted` (`:1862`)
586
+ - Test: `test/service.test.mjs`
587
+
588
+ **Interfaces:**
589
+ - Consumes: kinds from Task 4; `isManualVerdict` (Task 6) not needed here.
590
+ - Produces: `service.holdView(viewId) -> Promise<{ok, error?}>`, `service.clearHoldView(viewId) -> Promise<{ok, error?}>` — typed automatically via `ReturnType<typeof createService>` (dashboard's `Service` alias).
591
+
592
+ - [ ] **Step 1: Write the failing tests**
593
+
594
+ In `test/service.test.mjs` (mirror the existing injected-`sendStateCommand` pattern at `:883-885`):
595
+
596
+ ```js
597
+ test("holdView and clearHoldView submit user-sourced commands (issue #145)", async () => {
598
+ const sent = [];
599
+ const svc = service(root, { sendStateCommand: async (cmd) => { sent.push(cmd); return { status: "applied" }; } });
600
+ assert.equal((await svc.holdView("v1")).ok, true);
601
+ assert.equal(sent[0].kind, "mark_holding");
602
+ assert.equal(sent[0].source, "dashboard-user");
603
+ assert.equal((await svc.clearHoldView("v1")).ok, true);
604
+ assert.equal(sent[1].kind, "clear_holding");
605
+ assert.equal(sent[1].source, "dashboard-user");
606
+ });
607
+
608
+ test("holdView refuses busy rows with the same wording as markCompleted (issue #145)", async () => {
609
+ const svc = service(root, { sendStateCommand: async () => ({ status: "rejected", reason: "busy" }) });
610
+ const res = await svc.holdView("v1");
611
+ assert.equal(res.ok, false);
612
+ assert.equal(res.error, "Wait for the active run to finish before placing on hold");
613
+ });
614
+
615
+ test("markCompletedMany completes holding rows — d-key semantics need zero UI change (spec D4)", async () => {
616
+ const svc = service(root, { sendStateCommand: async (cmd) => (cmd.kind === "mark_completed" ? { status: "applied" } : { status: "rejected", reason: "busy" }) });
617
+ const res = await svc.markCompletedMany(["v1"]);
618
+ assert.equal(res.completed, 1);
619
+ });
620
+
621
+ test("reconcile leaves holding rows untouched even with a terminal host record (issue #145)", async () => {
622
+ // Seed a holding row whose host record is exited: reconcile must not
623
+ // finalize it (looksActive allow-list skips exited/holding rows).
624
+ const st = readState(root, "v1");
625
+ st.semanticState = "holding";
626
+ st.processState = "exited";
627
+ writeState(root, st);
628
+ writeHost(root, "v1", { state: "exited", runnerPid: null, childPid: null, instanceId: "i1", socketPath: "/no/s.sock" });
629
+ const svc = service(root, { sendStateCommand: async () => ({ status: "rejected", reason: "manual_fence" }) });
630
+ await svc.reconcile();
631
+ assert.equal(readState(root, "v1").semanticState, "holding");
632
+ });
633
+ ```
634
+
635
+ (Adapt fixture helpers — `readState`/`writeState`/`writeHost` — to the file's existing imports/fixtures; the fourth test needs the `writeHost` helper the service tests already use.)
636
+
637
+ - [ ] **Step 2: Run to verify failure**
638
+
639
+ Run: `node --test test/service.test.mjs`
640
+ Expected: FAIL — `svc.holdView is not a function`.
641
+
642
+ - [ ] **Step 3: Implement**
643
+
644
+ Next to `completeViewDirect` in `src/runtime/service.mjs`:
645
+
646
+ ```js
647
+ /** Direct write for holdView — coordinator_disabled escape hatch only. */
648
+ function holdViewDirect(state) {
649
+ state.semanticState = "holding";
650
+ state.processState = "exited";
651
+ state.needsInput = false;
652
+ state.hasError = false;
653
+ state.question = null;
654
+ state.pendingQuestions = [];
655
+ state.error = null;
656
+ state.autoState = null;
657
+ state.lastActivityAt = Date.now();
658
+ state.updatedAt = Date.now();
659
+ writeState(root, state);
660
+ }
661
+
662
+ /** Direct write for clearHoldView — coordinator_disabled escape hatch only. */
663
+ function clearHoldViewDirect(state) {
664
+ state.semanticState = "idle";
665
+ state.processState = "exited";
666
+ state.needsInput = false;
667
+ state.hasError = false;
668
+ state.question = null;
669
+ state.pendingQuestions = [];
670
+ state.error = null;
671
+ state.autoState = null;
672
+ state.lastActivityAt = Date.now();
673
+ state.updatedAt = Date.now();
674
+ writeState(root, state);
675
+ }
676
+
677
+ /** Place an inactive session on hold (issue #145; template: completeView). */
678
+ async function holdView(viewId) {
679
+ const row = loadRow(root, viewId);
680
+ if (!row) return { ok: false, error: "Unknown session" };
681
+ if (isAgentBusy(row)) return { ok: false, error: "Wait for the active run to finish before placing on hold" };
682
+ const state = readState(root, viewId) ?? row.state ?? blankState(viewId);
683
+ const result = await sendStateCommandImpl(root, {
684
+ type: "state_command",
685
+ viewId,
686
+ runId: state.currentRunId ?? null,
687
+ source: "dashboard-user",
688
+ kind: "mark_holding",
689
+ expectedRevision: null,
690
+ payload: {},
691
+ });
692
+ if (result.status === "applied") return { ok: true };
693
+ if (result.reason === "busy") return { ok: false, error: "Wait for the active run to finish before placing on hold" };
694
+ if (result.reason === "coordinator_disabled") {
695
+ holdViewDirect(state);
696
+ return { ok: true };
697
+ }
698
+ return { ok: false, error: result.reason ?? "state_command_failed" };
699
+ }
700
+
701
+ /** Resume an on-hold session back to Needs-instructions (issue #145). */
702
+ async function clearHoldView(viewId) {
703
+ const row = loadRow(root, viewId);
704
+ if (!row) return { ok: false, error: "Unknown session" };
705
+ const state = readState(root, viewId) ?? row.state ?? blankState(viewId);
706
+ const result = await sendStateCommandImpl(root, {
707
+ type: "state_command",
708
+ viewId,
709
+ runId: state.currentRunId ?? null,
710
+ source: "dashboard-user",
711
+ kind: "clear_holding",
712
+ expectedRevision: null,
713
+ payload: {},
714
+ });
715
+ if (result.status === "applied") return { ok: true };
716
+ if (result.reason === "no_change") return { ok: true };
717
+ if (result.reason === "coordinator_disabled") {
718
+ clearHoldViewDirect(state);
719
+ return { ok: true };
720
+ }
721
+ return { ok: false, error: result.reason ?? "state_command_failed" };
722
+ }
723
+ ```
724
+
725
+ On the returned service object, next to `markCompleted`:
726
+
727
+ ```js
728
+ /** @param {string} viewId @returns {Promise<{ ok: boolean, error?: string }>} */
729
+ holdView(viewId) {
730
+ return holdView(viewId);
731
+ },
732
+
733
+ /** @param {string} viewId @returns {Promise<{ ok: boolean, error?: string }>} */
734
+ clearHoldView(viewId) {
735
+ return clearHoldView(viewId);
736
+ },
737
+ ```
738
+
739
+ - [ ] **Step 4: Run tests**
740
+
741
+ Run: `node --test test/service.test.mjs`
742
+ Expected: PASS.
743
+
744
+ - [ ] **Step 5: Commit**
745
+
746
+ ```bash
747
+ git add src/runtime/service.mjs test/service.test.mjs
748
+ git commit -m "feat: holdView and clearHoldView service actions (issue #145)"
749
+ ```
750
+
751
+ ---
752
+
753
+ ### Task 8: F-lift — interactive input lifts the manual fence
754
+
755
+ **Acceptance:** A14, A15, A16, A17, A19. Review Focus #5. This is the regression fix that also unbreaks completed rows.
756
+
757
+ **Files:**
758
+ - Modify: `src/runtime/service.mjs` — `writeForegroundState` (`:745`) gains an `opts.source` override; `syncRowEvent`'s input branch (`:1444-1453`)
759
+ - Test: `test/state-commands.test.mjs` (decision level), `test/state-coordinator.integration.test.mjs` (A14/A17), `test/service.test.mjs` (A15/A16/A19)
760
+
761
+ **Interfaces:**
762
+ - Consumes: nothing new (pure source switch on an existing command).
763
+ - Produces: `writeForegroundState(row, status, opts?: { source?: string })`.
764
+
765
+ - [ ] **Step 1: Write the failing tests**
766
+
767
+ Decision level, `test/state-commands.test.mjs`:
768
+
769
+ ```js
770
+ // -- F-lift (issue #145): interactive input lifts the manual fence --
771
+
772
+ test("sync_foreground as dashboard-user passes the fence; as service it stays fenced", () => {
773
+ const holding = { ...manualCompletedState, semanticState: "holding" };
774
+ const payload = { projection: { semanticState: "working", processState: "alive" } };
775
+ const fenced = decideStateTransition({ ...baseCmd, source: "service", kind: "sync_foreground", payload }, holding, null);
776
+ assert.equal(fenced.action, "reject");
777
+ assert.equal(fenced.reason, "manual_fence");
778
+ const lifted = decideStateTransition({ ...baseCmd, source: "dashboard-user", kind: "sync_foreground", payload }, holding, null);
779
+ assert.equal(lifted.action, "apply");
780
+ });
781
+ ```
782
+
783
+ Integration, `test/state-coordinator.integration.test.mjs` (uses the file's existing `freshRoot` / `createView` / `legacyRowState` / `startCoordinator` / `readyClient` / `waitForExit` helpers — same shape as the A8 test at `:515-580`; match that test's cleanup convention):
784
+
785
+ ```js
786
+ test("interactive-input mirror lifts the fence; service-source mirrors stay fenced (issue #145, spec A14/A15)", async () => {
787
+ const root = freshRoot();
788
+ createView(root, { id: "v1", name: "hold", cwd: process.cwd() });
789
+ const st = legacyRowState("v1");
790
+ st.semanticState = "holding";
791
+ st.autoState = null; // manual-verdict signal
792
+ writeState(root, st);
793
+ const child = startCoordinator(root);
794
+ try {
795
+ const { client } = await readyClient(root);
796
+ const payload = { projection: { ...legacyRowState("v1"), semanticState: "working", processState: "alive", autoState: null } };
797
+ // 1. Automated mirror (rpc/extension injection keeps source "service"): fenced.
798
+ client.send({ type: "state_command", commandId: "f-lift-svc-1", viewId: "v1", runId: null, source: "service", kind: "sync_foreground", expectedRevision: null, payload });
799
+ const fenced = await client.next();
800
+ assert.equal(fenced.status, "rejected");
801
+ assert.equal(fenced.reason, "manual_fence");
802
+ assert.equal(readState(root, "v1").semanticState, "holding", "row keeps the verdict");
803
+ // 2. User mirror (pi reported InputEvent.source === "interactive"): lifts.
804
+ client.send({ type: "state_command", commandId: "f-lift-user-1", viewId: "v1", runId: null, source: "dashboard-user", kind: "sync_foreground", expectedRevision: null, payload });
805
+ const lifted = await client.next();
806
+ assert.equal(lifted.status, "applied");
807
+ assert.equal(readState(root, "v1").semanticState, "working", "user speaking resumes the row");
808
+ } finally {
809
+ child.kill("SIGTERM");
810
+ await waitForExit(child);
811
+ }
812
+ });
813
+ ```
814
+
815
+ Red/green self-proof: the `dashboard-user` leg fails on unmodified main (rejected manual_fence); the `service` leg must keep failing-to-apply forever (A15).
816
+
817
+ Service level, `test/service.test.mjs` (asserts the source switch itself — the unit the coordinator test can't see):
818
+
819
+ ```js
820
+ test("syncRowEvent routes interactive input as dashboard-user; rpc/extension stay service (issue #145)", async () => {
821
+ const sent = [];
822
+ const svc = service(root, { sendStateCommand: async (cmd) => { sent.push(cmd); return { status: "applied" }; } });
823
+ await svc.syncHostedEvent("v1", { type: "input", source: "interactive", text: "go" });
824
+ await svc.syncHostedEvent("v1", { type: "input", source: "rpc", text: "auto" });
825
+ await svc.syncHostedEvent("v1", { type: "agent_start" });
826
+ // The working-state mirror is a fire-and-forget sync_foreground beat
827
+ // (service.test.mjs:908 precedent) — poll instead of asserting immediately.
828
+ const kinds = await waitFor(() => {
829
+ const beats = sent.filter((c) => c.kind === "sync_foreground");
830
+ return beats.length >= 3 ? beats : null;
831
+ });
832
+ assert.equal(kinds[0].source, "dashboard-user");
833
+ assert.equal(kinds[1].source, "service");
834
+ assert.equal(kinds[2].source, "service");
835
+ });
836
+ ```
837
+
838
+ (Seed `v1` via the file's usual createView fixture first; reuse the file's existing `waitFor` helper. The completed-row variant — same input on a `completed` + `autoState: null` row, spec A16 — is the same test with the state seeded `completed`; assert the row's state converges `working` when the fake `sendStateCommand` applies only `dashboard-user` mirrors and rejects `service` ones with `manual_fence`.)
839
+
840
+ - [ ] **Step 2: Run to verify failure**
841
+
842
+ Run: `node --test test/state-commands.test.mjs test/state-coordinator.integration.test.mjs test/service.test.mjs`
843
+ Expected: the F-lift tests FAIL (mirror rejected manual_fence / source still `service`).
844
+
845
+ - [ ] **Step 3: Implement**
846
+
847
+ `src/runtime/service.mjs` — `writeForegroundState`:
848
+
849
+ ```js
850
+ /**
851
+ * @param {import("../core/store.mjs").Row} row
852
+ * @param {import("../core/types.mjs").RunStatus} status
853
+ * @param {{ source?: string }} [opts] F-lift (issue #145): pass "dashboard-user"
854
+ * when the mirror was triggered by a human submitting input — the manual
855
+ * fence must not outlive the user speaking to the row.
856
+ * @returns {Promise<void>}
857
+ */
858
+ async function writeForegroundState(row, status, opts = {}) {
859
+ ```
860
+
861
+ and inside, the command's `source:` line becomes `source: opts.source ?? "service",`.
862
+
863
+ `syncRowEvent`'s first branch (`:1444`):
864
+
865
+ ```js
866
+ if (event.type === "input" || event.type === "before_agent_start" || event.type === "agent_start") {
867
+ status.semanticState = "working";
868
+ status.processState = "alive";
869
+ status.currentTool = null;
870
+ status.question = null;
871
+ status.pendingQuestions = [];
872
+ status.error = null;
873
+ status.summary = "Running…";
874
+ status.lastActivityAt = now;
875
+ // F-lift (issue #145): pi's InputEvent carries source; "interactive"
876
+ // means a human submitted this text (attach keystrokes, a dashboard
877
+ // reply injected into a live host, or an auto-drained follow-up — all
878
+ // arrive as PTY bytes, which pi classifies as interactive). A manual
879
+ // verdict must not outlive the user speaking to the row, so this one
880
+ // mirror travels as dashboard-user and passes the manual_fence guard.
881
+ // Safe by construction: stale_run cannot fire (runId stays null) and
882
+ // the coordinator shell never reads source — manual_fence is the only
883
+ // guard this crosses. rpc/extension injections keep source "service".
884
+ const userSpoke = event.type === "input" && event.source === "interactive";
885
+ void writeForegroundState(row, status, userSpoke ? { source: "dashboard-user" } : {});
886
+ return true;
887
+ }
888
+ ```
889
+
890
+ - [ ] **Step 4: Run tests**
891
+
892
+ Run: `node --test test/state-commands.test.mjs test/state-coordinator.integration.test.mjs test/service.test.mjs`
893
+ Expected: PASS — including the pre-existing A8-invariant tests (`:308-310`, `:542-577`), which is spec A17: lifting the fence for user mirrors does not re-open the door for late classifications.
894
+
895
+ - [ ] **Step 5: Commit**
896
+
897
+ ```bash
898
+ git add src/runtime/service.mjs test/state-commands.test.mjs test/state-coordinator.integration.test.mjs test/service.test.mjs
899
+ git commit -m "fix: interactive input lifts the manual fence on verdict rows (issue #145)"
900
+ ```
901
+
902
+ ---
903
+
904
+ ### Task 9: UI — `h` key toggles hold in list / peek / session modes
905
+
906
+ **Acceptance:** A21. Feeds U1/U2/U5.
907
+
908
+ **Files:**
909
+ - Modify: `src/ui/dashboard.ts` — `toggleHold` method; `handleListKey:355`, `handlePeekKey`, `handleSessionKey`; `renderHelp:1648` table; hint line `:1316`
910
+ - Create: `test-support/hold-key-wrap.ts`
911
+ - Test: `test/dashboard-hold-key.test.mjs`
912
+
913
+ **Interfaces:**
914
+ - Consumes: `service.holdView`/`clearHoldView` (Task 7 — typed via `Service = ReturnType<typeof createService>`).
915
+ - Produces: `h` in list/peek/session = hold ⇄ unhold. Select mode gets no `h` (spec non-goal).
916
+
917
+ - [ ] **Step 1: Write the failing test**
918
+
919
+ Create `test-support/hold-key-wrap.ts` (mirror `navigation-wrap.ts`; fake service records commands):
920
+
921
+ ```ts
922
+ // Hold-key probe: construct a dashboard with a fake service, press "h" in
923
+ // list/peek/session modes, and report the submitted commands as JSON.
924
+ // Run via `node --experimental-transform-types`. Not typechecked.
925
+ const stateById: Record<string, { semanticState: string; processState: string; pendingQuestions: unknown[] }> = {
926
+ v1: { semanticState: "idle", processState: "exited", pendingQuestions: [] },
927
+ v2: { semanticState: "holding", processState: "exited", pendingQuestions: [] },
928
+ };
929
+ const sent: Array<[string, string]> = [];
930
+ const row = (id: string) => ({
931
+ meta: { id, name: id, cwd: "/", sessionFile: `/s/${id}.jsonl` },
932
+ state: stateById[id],
933
+ alive: false, hostAlive: false, hostActive: false, hostReady: false, host: null,
934
+ });
935
+ const service = {
936
+ rows: () => [row("v1"), row("v2")],
937
+ holdView: async (id: string) => { sent.push(["mark_holding", id]); stateById[id].semanticState = "holding"; return { ok: true }; },
938
+ clearHoldView: async (id: string) => { sent.push(["clear_holding", id]); stateById[id].semanticState = "idle"; return { ok: true }; },
939
+ reconcile: async () => 0,
940
+ };
941
+ const tui = { terminal: { rows: 24, cols: 80, columns: 80, write: () => {} }, requestRender: () => {} };
942
+ const theme = { fg: (_c: string, t: string) => t, bold: (t: string) => t };
943
+ const dash = new DashboardComponent(tui as never, theme as never, {} as never, () => {}, {
944
+ service: service as never, root: "/", defaultCwd: "/", availableModels: [], currentModel: null, currentThinkingLevel: "off",
945
+ });
946
+ dash.selectedId = "v1";
947
+ dash.handleInput("h"); // list mode, idle row -> mark_holding
948
+ dash.selectedId = "v2";
949
+ dash.handleInput("h"); // list mode, holding row -> clear_holding
950
+ dash.peekId = "v2"; dash.mode = "peek";
951
+ dash.handleInput("h"); // peek mode -> (v2 was reset to idle by the previous press) mark_holding
952
+ dash.mode = "session";
953
+ dash.handleInput("h"); // session mode -> clear_holding
954
+ dash.dispose();
955
+ console.log(JSON.stringify(sent));
956
+ ```
957
+
958
+ Create `test/dashboard-hold-key.test.mjs`:
959
+
960
+ ```js
961
+ import assert from "node:assert/strict";
962
+ import { execFileSync } from "node:child_process";
963
+ import { fileURLToPath } from "node:url";
964
+ import { join } from "node:path";
965
+ import test from "node:test";
966
+
967
+ const ROOT_DIR = fileURLToPath(new URL("../", import.meta.url));
968
+ const PROBE = join(ROOT_DIR, "test-support", "hold-key-wrap.ts");
969
+
970
+ test("h key submits mark_holding / clear_holding across list, peek and session modes (issue #145)", () => {
971
+ const out = execFileSync(process.execPath, ["--experimental-transform-types", PROBE], { encoding: "utf-8", timeout: 30_000 });
972
+ assert.deepEqual(JSON.parse(out), [
973
+ ["mark_holding", "v1"],
974
+ ["clear_holding", "v2"],
975
+ ["mark_holding", "v2"],
976
+ ["clear_holding", "v2"],
977
+ ]);
978
+ });
979
+ ```
980
+
981
+ - [ ] **Step 2: Run to verify failure**
982
+
983
+ Run: `node --test test/dashboard-hold-key.test.mjs`
984
+ Expected: FAIL — `sent` is empty (`h` falls into the isPrintable notice branch).
985
+
986
+ - [ ] **Step 3: Implement**
987
+
988
+ `src/ui/dashboard.ts` — new method next to `confirmDone`:
989
+
990
+ ```ts
991
+ // On-hold toggle (issue #145): manual-only state, so the command travels as
992
+ // dashboard-user; the coordinator's source guard rejects any other writer.
993
+ private toggleHold(): void {
994
+ const row = this.selectedRow();
995
+ if (!row) return;
996
+ const holding = row.state?.semanticState === "holding";
997
+ if (!holding && isAgentBusy(row)) return this.notice("Wait for the active run to finish before placing on hold", "warn");
998
+ const run = holding ? this.deps.service.clearHoldView(row.meta.id) : this.deps.service.holdView(row.meta.id);
999
+ void Promise.resolve(run).then((res) => {
1000
+ if (!res.ok) this.notice(res.error ?? (holding ? "Resume failed" : "Hold failed"), "error");
1001
+ else this.notice(holding ? "Resumed — needs instructions" : "On hold", "info");
1002
+ this.refresh();
1003
+ });
1004
+ }
1005
+ ```
1006
+
1007
+ (`isAgentBusy` is already imported — `confirmDone` uses it.)
1008
+
1009
+ Key wiring — `handleListKey`, after the `d` line:
1010
+
1011
+ ```ts
1012
+ if (data === "d") return this.confirmDone();
1013
+ if (data === "h") return this.toggleHold();
1014
+ ```
1015
+
1016
+ `handlePeekKey`, after its `d` line: `if (data === "h") return this.toggleHold();`
1017
+ `handleSessionKey`, after its `d` line: `if (data === "h") return this.toggleHold();`
1018
+
1019
+ `renderHelp` rows — insert after the `d` row:
1020
+
1021
+ ```ts
1022
+ ["h", "Hold / unhold the selected inactive session (on-hold state)"],
1023
+ ```
1024
+
1025
+ Hint line (`:1316`) — insert `"h hold"` into the hints array right after `"d done"`.
1026
+
1027
+ - [ ] **Step 4: Run tests + typecheck**
1028
+
1029
+ Run: `node --test test/dashboard-hold-key.test.mjs test/dashboard-navigation.test.mjs && npm run typecheck`
1030
+ Expected: PASS (navigation probe unaffected).
1031
+
1032
+ - [ ] **Step 5: Commit**
1033
+
1034
+ ```bash
1035
+ git add src/ui/dashboard.ts test-support/hold-key-wrap.ts test/dashboard-hold-key.test.mjs
1036
+ git commit -m "feat: h key toggles the on-hold state from list, peek and session (issue #145)"
1037
+ ```
1038
+
1039
+ ---
1040
+
1041
+ ### Task 10: Evidence contract — `outcome: "holding"`, `ready: true`
1042
+
1043
+ **Acceptance:** A12.
1044
+
1045
+ **Files:**
1046
+ - Modify: `src/core/evidence.mjs:223-224`
1047
+ - Test: `test/evidence.test.mjs`
1048
+
1049
+ **Interfaces:**
1050
+ - Consumes: `"holding"` in `EvidenceOutcome` (Task 1).
1051
+ - Produces: `finalizeEvidence` maps holding → `outcome: "holding"`, `ready: true` (spec D8 — ready is display/filter-only, verified).
1052
+
1053
+ - [ ] **Step 1: Write the failing test**
1054
+
1055
+ In `test/evidence.test.mjs` (reuse the file's snapshot fixture helpers):
1056
+
1057
+ ```js
1058
+ test("finalizeEvidence maps holding to its own outcome and keeps ready true (issue #145)", () => {
1059
+ const snap = emptyEvidenceSnapshot({ viewId: "v", source: "hosted" });
1060
+ finalizeEvidence(snap, { viewId: "v", semanticState: "holding" }, 5);
1061
+ assert.equal(snap.outcome, "holding");
1062
+ assert.equal(snap.ready, true);
1063
+ });
1064
+ ```
1065
+
1066
+ (Adapt `emptyEvidenceSnapshot`/`finalizeEvidence` to the file's existing imports.)
1067
+
1068
+ - [ ] **Step 2: Run to verify failure**
1069
+
1070
+ Run: `node --test test/evidence.test.mjs`
1071
+ Expected: FAIL — `ready` is false (holding is neither idle nor completed in the current expression).
1072
+
1073
+ - [ ] **Step 3: Implement**
1074
+
1075
+ `src/core/evidence.mjs:223-224`:
1076
+
1077
+ ```js
1078
+ snapshot.outcome = status.semanticState === "idle" ? "ready" : status.semanticState;
1079
+ // holding keeps ready=true (issue #145 spec D8): an on-hold row's evidence is
1080
+ // reviewable like a completed one; `ready` feeds display + the review:ready
1081
+ // filter only (verified — no functional gate reads it).
1082
+ snapshot.ready = status.semanticState === "idle" || status.semanticState === "completed" || status.semanticState === "holding";
1083
+ ```
1084
+
1085
+ - [ ] **Step 4: Run tests**
1086
+
1087
+ Run: `node --test test/evidence.test.mjs test/dashboard-evidence.test.mjs`
1088
+ Expected: PASS.
1089
+
1090
+ - [ ] **Step 5: Commit**
1091
+
1092
+ ```bash
1093
+ git add src/core/evidence.mjs test/evidence.test.mjs
1094
+ git commit -m "feat: evidence outcome and ready flag for holding rows (issue #145)"
1095
+ ```
1096
+
1097
+ ---
1098
+
1099
+ ### Task 11: README — state table, key tables, filter examples
1100
+
1101
+ **Acceptance:** documentation for U1–U7; no automated ID (build gate in Task 12 covers the doc build).
1102
+
1103
+ **Files:**
1104
+ - Modify: `README.md` — state table (`~:204-213`), Main list / Peek / Transcript key tables, filter example block
1105
+
1106
+ **Interfaces:** none.
1107
+
1108
+ - [ ] **Step 1: Edit the state table** — insert after the **Needs instructions** row:
1109
+
1110
+ ```markdown
1111
+ | **On hold** | The user parked this session intentionally (manual verdict, `h` to toggle). It stays out of Done until resumed; queued follow-ups do not wake it. |
1112
+ ```
1113
+
1114
+ - [ ] **Step 2: Edit the key tables** — Main list, after the `d` row:
1115
+
1116
+ ```markdown
1117
+ | `h` | Hold or unhold the selected inactive session. |
1118
+ ```
1119
+
1120
+ Peek table, after `d`-equivalent actions (Peek has no `d`; add after `e`): `| `h` | Hold or unhold the session. |`. Transcript table, after the `d` row: same line. Session actions paragraph: add "`h` toggles the on-hold state (manual verdict — automated writers are fenced)".
1121
+
1122
+ - [ ] **Step 3: Edit the filter examples** — add `s:hold` to the filter token example block.
1123
+
1124
+ - [ ] **Step 4: Verify nothing broke**
1125
+
1126
+ Run: `npm run typecheck && node --test test/ui-smoke.test.mjs`
1127
+ Expected: PASS.
1128
+
1129
+ - [ ] **Step 5: Commit**
1130
+
1131
+ ```bash
1132
+ git add README.md
1133
+ git commit -m "docs: document the on-hold state, h key and s:hold filter (issue #145)"
1134
+ ```
1135
+
1136
+ ---
1137
+
1138
+ ### Task 12: Full verification + user-acceptance handoff
1139
+
1140
+ **Acceptance:** A20 (build gate), A18/A17 regression evidence, U1–U7 checklist.
1141
+
1142
+ **Files:**
1143
+ - Create: `docs/superpowers/plans/2026-09-29-issue-145-user-verification.md` (the U-item checklist, executed by the user against an installed build)
1144
+
1145
+ **Interfaces:** none.
1146
+
1147
+ - [ ] **Step 1: Run the full gate**
1148
+
1149
+ Run: `npm run verify`
1150
+ Expected: typecheck + perf gate + full test suite + coverage thresholds (lines 85 / funcs 80 / branches 70) + pack:dry all green. This run is the evidence for A20, and its inclusion of `runner.integration`/`state-coordinator.integration` is the regression evidence for A17/A18.
1151
+
1152
+ - [ ] **Step 2: Write the user-verification checklist**
1153
+
1154
+ `docs/superpowers/plans/2026-09-29-issue-145-user-verification.md` — one section per U item (U1–U7) copied verbatim from spec §9's user-acceptance table (operation steps + pass criteria), plus a results column left blank. State explicitly: automation passing does NOT substitute for these; mark `pending` where not yet executed.
1155
+
1156
+ - [ ] **Step 3: Commit**
1157
+
1158
+ ```bash
1159
+ git add docs/superpowers/plans/2026-09-29-issue-145-user-verification.md
1160
+ git commit -m "test: user-acceptance checklist for the on-hold state (issue #145)"
1161
+ ```
1162
+
1163
+ - [ ] **Step 4: Report**
1164
+
1165
+ Post a summary to the issue: tasks completed, A-item statuses with the commands that produced them, and the pending U items with the checklist path. Do NOT push or open a PR — that gate belongs to the human.
1166
+
1167
+ ---
1168
+
1169
+ ## Acceptance traceability (spec §9 ↔ tasks)
1170
+
1171
+ | Spec ID | Task(s) | | Spec ID | Task(s) |
1172
+ |---|---|---|---|---|
1173
+ | A1 | 2 | | A12 | 10 |
1174
+ | A2 / A3 / A4 | 4 | | A13 | 5 |
1175
+ | A5 | 4, 6 | | A14 | 8 |
1176
+ | A6 / A7 | 6 | | A15 | 8 |
1177
+ | A8 | 3 (+12 regression) | | A16 | 8 |
1178
+ | A9 / A10 / A11 | 1 | | A17 | 8 + 12 |
1179
+ | — | — | | A18 | 3 + 6 + 12 |
1180
+ | — | — | | A19 | 8 + 12 |
1181
+ | — | — | | A20 | 12 |
1182
+ | — | — | | A21 | 9 |
1183
+ | U1–U7 | 12 (checklist) | | | |