@zhuxixi/pi-agent-board 0.9.0 → 0.10.0

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 +20 -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 +46 -3
@@ -0,0 +1,649 @@
1
+ # Board Delete Key `x` → `y` 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:** 在 board 主列表新增 `x` → `(y/N)` 确认删除入口,同时完整保留旧的 `Ctrl+X` ×2 免确认快捷方式。
6
+
7
+ **Architecture:** 复用 dashboard 已有的确认机制(`pending` + `mode = "confirm"` + `handleConfirmKey`)与删除语义(`service.archive`,软删除、busy 先停 host/run)。改动 = `handleListKey` 一个按键分支 + 一个新的 `confirmDelete()` 方法 + 文案同步;不改 service 层、不改协议、不改多选态。
8
+
9
+ **Tech Stack:** TypeScript(`src/ui/dashboard.ts`)、Node 内置 test runner(`node:test`)、`--experimental-transform-types` 探针(`test-support/*.ts`)、c8 覆盖率。
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-09-30-board-delete-key-x-y-confirm-design.md`
12
+
13
+ **Worktree (`$WT` in the commands below):** `/home/elling/work/git-repo/pi-agent-board/.pi/worktrees/issue-150-board-delete-key-x-y-confirm`
14
+
15
+ ## Global Constraints
16
+
17
+ - 工作目录:worktree 根 `<repo>/.pi/worktrees/issue-150-board-delete-key-x-y-confirm`;禁止改动 main checkout,禁止 `git checkout -b` / `git switch -c`。
18
+ - Node >= 20;测试命令 `npm test`(= `node --test test/*.test.mjs`),类型检查 `npm run typecheck`,全量验证 `npm run verify`(typecheck + perf gate + tests + coverage[lines 85 / funcs 80 / branches 70] + pack:dry)。
19
+ - 探针文件放 `test-support/`,用 `node --experimental-transform-types` 运行(dashboard.ts 使用 TS 参数属性);`test-support/` 不参与类型检查。
20
+ - 缩进用 tab;代码注释、commit message 用英文;conventional commits(`test:` / `feat:` / `docs:`)。
21
+ - `git add <file>` 逐个文件 stage,禁止 `git add -A`。
22
+ - 不新增 export、不改 `PtyAttachResult` / 协议 / `service` 层;不改大写 `X`、evidence 的 `x`、多选态 `ctrl+x`。
23
+
24
+ ## Review Focus
25
+
26
+ | # | 输入 / 失败模式 | 期望行为 | 归属测试 |
27
+ | --- | --- | --- | --- |
28
+ | 1 | 确认态下按 `x` / `q` / `n` / esc | 取消,不删除、无 notice | Task 1 A3 |
29
+ | 2 | 空列表 / 无选中行按 `x` | 静默返回,不崩溃、不进入 confirm | Task 1 A1b |
30
+ | 3 | busy 行(working)按 `x` | 提示标明会停 active run;`y` 后走 archive(停 host/run) | Task 1 A1c + A2 |
31
+ | 4 | insert 模式下按 `x` | 作为普通字符进编辑器,不触发删除 | Task 1 A5 |
32
+ | 5 | 旧 `ctrl+x` ×2 与 `x` 混按 | 500ms 窗口语义不变;按 `x` 清掉 arm,不叠加触发 | Task 2 A4 |
33
+
34
+ ---
35
+
36
+ ### Task 1: `x` → `(y/N)` 确认删除(主路径)
37
+
38
+ **Deliverable:** list 模式按 `x` 打开确认提示;`y` 执行 `service.archive` 并回到列表;`n` / esc / 其它键取消;busy 行提示会停 run;insert 模式的 `x` 不受影响。
39
+
40
+ **Files:**
41
+ - Create: `test-support/dashboard-delete-key-probe.ts`
42
+ - Create: `test/dashboard-delete-key.test.mjs`
43
+ - Modify: `src/ui/dashboard.ts`(`handleListKey` :355-390 加分支;新增 `confirmDelete()`,紧邻 `confirmDeleteState` :1035)
44
+
45
+ **Interfaces:**
46
+ - Consumes: `DashboardComponent` 构造签名 `(tui, theme, keybindings, done, deps: DashboardDeps)`;`deps.service.rows()/row(id)/archive(id)`;`handleConfirmKey` 既有语义(`y`/`Y`/enter = 确认,其它 = 取消)。
47
+ - Produces: `private confirmDelete(): void`(本 task 内部方法,不被其它 task 引用)。
48
+
49
+ - [x] **Step 0: 安装依赖**
50
+
51
+ Run: `cd "$WT" && npm install`
52
+ Expected: 成功;出现 `node_modules/`。失败则停止并报告(后续所有测试依赖它)。
53
+
54
+ - [x] **Step 1: 写失败的探针 + 测试**
55
+
56
+ 创建 `test-support/dashboard-delete-key-probe.ts`:
57
+
58
+ ```ts
59
+ // Delete-key probe: drive the dashboard delete gestures through the public
60
+ // handleInput() and report observable state as JSON. One run covers the new
61
+ // `x` -> (y/N) path plus the legacy ctrl+x and multi-select regressions.
62
+ // Run via `node --experimental-transform-types` (dashboard.ts uses TS
63
+ // parameter properties). Not typechecked (tsconfig excludes test-support).
64
+ import { mkdtempSync } from "node:fs";
65
+ import { tmpdir } from "node:os";
66
+ import { join } from "node:path";
67
+ import { createService } from "../src/runtime/service.mjs";
68
+ import { createView } from "../src/core/store.mjs";
69
+ import { DashboardComponent } from "../src/ui/dashboard.ts";
70
+
71
+ type Row = { meta: { id: string; name: string }; alive: boolean; hostAlive: boolean; state: Record<string, unknown> | null };
72
+
73
+ function makeEnv(): {
74
+ service: ReturnType<typeof createService>;
75
+ root: string;
76
+ archiveCalls: string[];
77
+ archiveManyCalls: string[][];
78
+ setRowPatch: (fn: ((r: Row) => Row) | null) => void;
79
+ } {
80
+ const root = mkdtempSync(join(tmpdir(), "agentview-delete-key-"));
81
+ createView(root, { id: "v1", name: "one", cwd: root });
82
+ createView(root, { id: "v2", name: "two", cwd: root });
83
+ const service = createService({
84
+ root,
85
+ runnerScript: "/no/runner.mjs",
86
+ piCommand: "pi",
87
+ piArgsPrefix: [],
88
+ defaultCwd: root,
89
+ launch: () => ({ pid: null, configPath: "/no/config.json" }),
90
+ launchHost: () => ({ pid: null, configPath: "/no/host-config.json" }),
91
+ launchTitle: () => ({ pid: null, configPath: "/no/title-config.json" }),
92
+ });
93
+ const archiveCalls: string[] = [];
94
+ const realArchive = service.archive.bind(service);
95
+ service.archive = ((id: string) => {
96
+ archiveCalls.push(id);
97
+ return realArchive(id);
98
+ }) as typeof service.archive;
99
+ // archiveMany calls archiveView directly (bypasses the archive wrapper
100
+ // above), so the batch path needs its own recorder.
101
+ const archiveManyCalls: string[][] = [];
102
+ const realArchiveMany = service.archiveMany.bind(service);
103
+ service.archiveMany = ((ids: string[]) => {
104
+ archiveManyCalls.push([...ids]);
105
+ return realArchiveMany(ids);
106
+ }) as typeof service.archiveMany;
107
+ const realRows = service.rows.bind(service);
108
+ let rowPatch: ((r: Row) => Row) | null = null;
109
+ service.rows = (() => realRows().map((r) => (rowPatch ? rowPatch(r as unknown as Row) : r))) as typeof service.rows;
110
+ // selectedBatchRows() reads service.row(id), not service.rows() — patch
111
+ // both so fixtures (busy/completed rows) stay consistent everywhere.
112
+ const realRow = service.row.bind(service);
113
+ service.row = ((id: string) => {
114
+ const r = realRow(id);
115
+ return r && rowPatch ? rowPatch(r as unknown as Row) : r;
116
+ }) as typeof service.row;
117
+ return { service, root, archiveCalls, archiveManyCalls, setRowPatch: (fn) => { rowPatch = fn; } };
118
+ }
119
+
120
+ const writes: string[] = [];
121
+ const tui = {
122
+ terminal: { rows: 24, cols: 80, columns: 80, write: (d: string) => { writes.push(d); } },
123
+ requestRender: () => {},
124
+ };
125
+ const theme = { fg: (_c: string, t: string) => t, bold: (t: string) => t };
126
+
127
+ function makeDash(env: ReturnType<typeof makeEnv>): DashboardComponent {
128
+ return new DashboardComponent(tui as never, theme as never, { matches: () => false } as never, () => {}, {
129
+ service: env.service,
130
+ root: env.root,
131
+ defaultCwd: env.root,
132
+ availableModels: [],
133
+ currentModel: null,
134
+ currentThinkingLevel: "off",
135
+ } as never);
136
+ }
137
+
138
+ async function waitFor(pred: () => boolean, ms = 2000): Promise<boolean> {
139
+ const start = Date.now();
140
+ while (Date.now() - start < ms) {
141
+ if (pred()) return true;
142
+ await new Promise((r) => setTimeout(r, 10));
143
+ }
144
+ return pred();
145
+ }
146
+
147
+ type Snapshot = { mode: string; prompt: string | null; notice: string | null; archived: string[] };
148
+
149
+ function snap(dash: DashboardComponent, env: ReturnType<typeof makeEnv>): Snapshot {
150
+ const d = dash as unknown as { mode: string; pending: { prompt: string } | null; flash: { text: string } | null };
151
+ return { mode: d.mode, prompt: d.pending?.prompt ?? null, notice: d.flash?.text ?? null, archived: [...env.archiveCalls] };
152
+ }
153
+
154
+ const report: Record<string, unknown> = {};
155
+
156
+ // 1) idle row: x opens the confirm prompt, nothing archived yet
157
+ {
158
+ const env = makeEnv();
159
+ const dash = makeDash(env);
160
+ dash.handleInput("x");
161
+ report.idlePrompt = snap(dash, env);
162
+ dash.dispose();
163
+ }
164
+
165
+ // 2) busy row: prompt names the stopped run
166
+ {
167
+ const env = makeEnv();
168
+ env.setRowPatch((r) => ({ ...r, alive: true, hostAlive: true, state: { ...(r.state ?? {}), semanticState: "working" } }));
169
+ const dash = makeDash(env);
170
+ dash.handleInput("x");
171
+ report.busyPrompt = snap(dash, env);
172
+ dash.dispose();
173
+ }
174
+
175
+ // 3) cancel paths: n / esc / x again / q must not delete
176
+ {
177
+ const cancel: Record<string, Snapshot> = {};
178
+ for (const [name, key] of [["n", "n"], ["escape", "\x1b"], ["x", "x"], ["q", "q"]] as const) {
179
+ const env = makeEnv();
180
+ const dash = makeDash(env);
181
+ dash.handleInput("x");
182
+ dash.handleInput(key);
183
+ cancel[name] = snap(dash, env);
184
+ dash.dispose();
185
+ }
186
+ report.cancel = cancel;
187
+ }
188
+
189
+ // 4) y confirms: archive runs, notice lands, the row leaves the list
190
+ {
191
+ const env = makeEnv();
192
+ const dash = makeDash(env);
193
+ const before = (dash as unknown as { orderedIds: string[] }).orderedIds[0];
194
+ const beforeName = env.service.row(before)?.meta.name ?? "?";
195
+ dash.handleInput("x");
196
+ dash.handleInput("y");
197
+ await waitFor(() => env.archiveCalls.length > 0, 5000);
198
+ const after = dash as unknown as { orderedIds: string[]; selectedId: string; mode: string; flash: { level: string } | null };
199
+ await waitFor(() => after.flash !== null, 5000);
200
+ report.confirm = { ...snap(dash, env), beforeId: before, beforeName, orderedIds: after.orderedIds, selectedId: after.selectedId, level: after.flash?.level ?? null };
201
+ dash.dispose();
202
+ }
203
+
204
+ // 5) insert mode: x is literal editor text
205
+ {
206
+ const env = makeEnv();
207
+ const dash = makeDash(env);
208
+ dash.handleInput("i");
209
+ dash.handleInput("x");
210
+ const d = dash as unknown as { mode: string; input: string };
211
+ report.insertMode = { mode: d.mode, input: d.input, archived: [...env.archiveCalls] };
212
+ dash.dispose();
213
+ }
214
+
215
+ // 6) empty list: x is a no-op
216
+ {
217
+ const root = mkdtempSync(join(tmpdir(), "agentview-delete-key-empty-"));
218
+ const service = createService({
219
+ root,
220
+ runnerScript: "/no/runner.mjs",
221
+ piCommand: "pi",
222
+ piArgsPrefix: [],
223
+ defaultCwd: root,
224
+ launch: () => ({ pid: null, configPath: "/no/config.json" }),
225
+ launchHost: () => ({ pid: null, configPath: "/no/host-config.json" }),
226
+ launchTitle: () => ({ pid: null, configPath: "/no/title-config.json" }),
227
+ });
228
+ const dash = new DashboardComponent(tui as never, theme as never, { matches: () => false } as never, () => {}, {
229
+ service,
230
+ root,
231
+ defaultCwd: root,
232
+ availableModels: [],
233
+ currentModel: null,
234
+ currentThinkingLevel: "off",
235
+ } as never);
236
+ dash.handleInput("x");
237
+ const d = dash as unknown as { mode: string; pending: unknown };
238
+ report.emptyList = { mode: d.mode, pending: d.pending ?? null, crashed: false };
239
+ dash.dispose();
240
+ }
241
+
242
+ console.log(JSON.stringify(report));
243
+ ```
244
+
245
+ 创建 `test/dashboard-delete-key.test.mjs`:
246
+
247
+ ```js
248
+ import assert from "node:assert/strict";
249
+ import { execFileSync } from "node:child_process";
250
+ import { fileURLToPath } from "node:url";
251
+ import { join } from "node:path";
252
+ import test from "node:test";
253
+
254
+ const ROOT_DIR = fileURLToPath(new URL("../", import.meta.url));
255
+ const PROBE = join(ROOT_DIR, "test-support", "dashboard-delete-key-probe.ts");
256
+
257
+ // One probe run feeds every assertion below.
258
+ const report = JSON.parse(
259
+ execFileSync(process.execPath, ["--experimental-transform-types", PROBE], {
260
+ encoding: "utf8",
261
+ timeout: 60_000,
262
+ }),
263
+ );
264
+
265
+ test("A1: x opens a (y/N) confirm prompt naming the selected session", () => {
266
+ assert.equal(report.idlePrompt.mode, "confirm");
267
+ assert.match(report.idlePrompt.prompt, /^Delete "(one|two)"\? Session file is preserved\. \(y\/N\)$/);
268
+ assert.deepEqual(report.idlePrompt.archived, [], "nothing archived before the confirm key");
269
+ });
270
+
271
+ test("A1b: x on an empty list is a no-op", () => {
272
+ assert.equal(report.emptyList.mode, "list");
273
+ assert.equal(report.emptyList.pending, null);
274
+ });
275
+
276
+ test("A1c: busy rows warn that deletion stops the active run", () => {
277
+ assert.equal(report.busyPrompt.mode, "confirm");
278
+ assert.match(report.busyPrompt.prompt, /Stops the active run\./);
279
+ });
280
+
281
+ test("A3: n / esc / x / q cancel without deleting", () => {
282
+ for (const [name, snap] of Object.entries(report.cancel)) {
283
+ assert.equal(snap.mode, "list", `${name}: back to list mode`);
284
+ assert.equal(snap.prompt, null, `${name}: prompt dismissed`);
285
+ assert.equal(snap.notice, null, `${name}: no notice`);
286
+ assert.deepEqual(snap.archived, [], `${name}: nothing archived`);
287
+ }
288
+ });
289
+
290
+ test("A2: y archives the selected view, notices, and returns to the list", async () => {
291
+ assert.deepEqual(report.confirm.archived, [report.confirm.beforeId]);
292
+ assert.equal(report.confirm.mode, "list");
293
+ assert.equal(report.confirm.notice, `Deleted "${report.confirm.beforeName}"`);
294
+ assert.equal(report.confirm.level, "info");
295
+ assert.ok(!report.confirm.orderedIds.includes(report.confirm.beforeId), "deleted row leaves the list");
296
+ assert.notEqual(report.confirm.selectedId, report.confirm.beforeId, "selection leaves the deleted row");
297
+ assert.equal(report.confirm.selectedId, report.confirm.orderedIds[0], "selection re-lands on the remaining row");
298
+ });
299
+
300
+ test("A5: x is literal text in insert mode", () => {
301
+ assert.equal(report.insertMode.mode, "dispatch");
302
+ assert.match(report.insertMode.input, /x/);
303
+ assert.deepEqual(report.insertMode.archived, []);
304
+ });
305
+ ```
306
+
307
+ - [x] **Step 2: 跑测试确认失败**
308
+
309
+ Run: `cd "$WT" && node --test test/dashboard-delete-key.test.mjs`
310
+ Expected: FAIL —— `A1` 的 `mode` 是 `"list"` 而非 `"confirm"`(`x` 目前只会触发 "Press i to enter INSERT mode" 提示)。
311
+
312
+ - [x] **Step 3: 最小实现**
313
+
314
+ `src/ui/dashboard.ts` `handleListKey`,在 `if (data === "d") return this.confirmDone();` 之后、`ctrl+x` 分支之前插入:
315
+
316
+ ```ts
317
+ if (data === "x") return this.confirmDelete();
318
+ ```
319
+
320
+ 在 `confirmDeleteState()`(:1035)之前新增方法:
321
+
322
+ ```ts
323
+ private confirmDelete(): void {
324
+ const row = this.selectedRow();
325
+ if (!row) return;
326
+ // Confirmation-style delete (issue #150): `x` then `y`, mirroring the
327
+ // `d`-then-`y` flow. The legacy ctrl+x double-press stays as the
328
+ // no-confirm shortcut.
329
+ this.pending = {
330
+ prompt: `Delete "${row.meta.name}"?${isAgentBusy(row) ? " Stops the active run." : ""} Session file is preserved. (y/N)`,
331
+ onYes: () => {
332
+ // archive is async (routes through the view-state coordinator, issue #91);
333
+ // the notice + refresh land when the command settles.
334
+ void Promise.resolve(this.deps.service.archive(row.meta.id)).then((res) => {
335
+ if (!res.ok) this.notice(res.error ?? "Delete failed", "error");
336
+ else this.notice(`Deleted "${row.meta.name}"`, "info");
337
+ this.refresh();
338
+ });
339
+ },
340
+ };
341
+ this.mode = "confirm";
342
+ }
343
+ ```
344
+
345
+ - [x] **Step 4: 跑测试确认通过**
346
+
347
+ Run: `cd "$WT" && node --test test/dashboard-delete-key.test.mjs`
348
+ Expected: PASS(6 个 test 全绿:A1/A1b/A1c/A3/A2/A5)。若 `A2` 超时,检查探针 `waitFor` 轮询条件是否覆盖 `flash`。
349
+
350
+ - [x] **Step 5: 类型检查与全量测试**
351
+
352
+ Run: `cd "$WT" && npm run typecheck && npm test`
353
+ Expected: 均通过;无新增 warning。
354
+
355
+ - [x] **Step 6: Commit**
356
+
357
+ ```bash
358
+ cd "$WT"
359
+ git add test-support/dashboard-delete-key-probe.ts test/dashboard-delete-key.test.mjs src/ui/dashboard.ts
360
+ git commit -m "feat(ui): x then y confirms session deletion (issue #150)"
361
+ ```
362
+
363
+ ---
364
+
365
+ ### Task 2: 旧路径回归钉死(`ctrl+x` ×2 与多选态)
366
+
367
+ **Deliverable:** 探针新增回归场景:`ctrl+x` 双击仍免确认删除(含 700ms 窗口过期不删);多选态仍是 `ctrl+x`(`x` 无副作用),且 `y` 确认后经 `service.archiveMany` 归档、行从列表消失。
368
+
369
+ **Files:**
370
+ - Modify: `test-support/dashboard-delete-key-probe.ts`(追加两个场景 + report 字段)
371
+ - Modify: `test/dashboard-delete-key.test.mjs`(追加三个 test:A4/A4b/A6)
372
+
373
+ **Interfaces:**
374
+ - Consumes: Task 1 的探针工厂 `makeEnv()` / `makeDash()` / `snap()`;`Row` 补丁钩子 `setRowPatch`。
375
+ - Produces: report 新增 `legacyDoublePress`、`legacySlow`、`selectMode`(含 `afterX` / `ctrlX` / `confirmed` 子字段);`makeEnv()` 返回值新增 `archiveManyCalls: string[][]`。
376
+
377
+ - [x] **Step 1: 写回归场景(先跑,预期直接通过)**
378
+
379
+ 在探针 `console.log` 之前追加:
380
+
381
+ ```ts
382
+ // 7) legacy shortcut: ctrl+x twice inside the window still deletes without confirm
383
+ {
384
+ const env = makeEnv();
385
+ const dash = makeDash(env);
386
+ dash.handleInput("\x18");
387
+ dash.handleInput("\x18");
388
+ await waitFor(() => env.archiveCalls.length > 0);
389
+ report.legacyDoublePress = snap(dash, env);
390
+ dash.dispose();
391
+ }
392
+
393
+ // 8) legacy window: a second ctrl+x after 700ms only re-arms
394
+ {
395
+ const env = makeEnv();
396
+ const dash = makeDash(env);
397
+ dash.handleInput("\x18");
398
+ await new Promise((r) => setTimeout(r, 700));
399
+ dash.handleInput("\x18");
400
+ report.legacySlow = snap(dash, env);
401
+ dash.dispose();
402
+ }
403
+
404
+ // 9) multi-select keeps ctrl+x; plain x does nothing there
405
+ {
406
+ const env = makeEnv();
407
+ env.setRowPatch((r) => ({ ...r, state: { ...(r.state ?? {}), semanticState: "completed" } }));
408
+ const dash = makeDash(env);
409
+ const target = (dash as unknown as { orderedIds: string[] }).orderedIds[0];
410
+ dash.handleInput("m");
411
+ dash.handleInput(" ");
412
+ const d0 = dash as unknown as { mode: string; pending: { prompt: string; returnMode?: string } | null };
413
+ dash.handleInput("x");
414
+ const afterX = { mode: d0.mode, prompt: d0.pending?.prompt ?? null };
415
+ dash.handleInput("\x18");
416
+ const d1 = dash as unknown as { mode: string; pending: { prompt: string; returnMode?: string } | null };
417
+ const ctrlX = { mode: d1.mode, prompt: d1.pending?.prompt ?? null, returnMode: d1.pending?.returnMode ?? null };
418
+ dash.handleInput("y");
419
+ const d2 = dash as unknown as { mode: string; orderedIds: string[] };
420
+ await waitFor(() => !d2.orderedIds.includes(target), 5000);
421
+ report.selectMode = { afterX, ctrlX, target, confirmed: { archiveManyCalls: [...env.archiveManyCalls], mode: d2.mode, orderedIds: [...d2.orderedIds] }, archived: [...env.archiveCalls] };
422
+ dash.dispose();
423
+ }
424
+ ```
425
+
426
+ 测试文件追加:
427
+
428
+ ```js
429
+ test("A4: legacy ctrl+x double-press still deletes without a confirm step", () => {
430
+ assert.equal(report.legacyDoublePress.mode, "list", "no confirm mode involved");
431
+ assert.equal(report.legacyDoublePress.prompt, null);
432
+ assert.deepEqual(report.legacyDoublePress.archived, ["v1"]);
433
+ });
434
+
435
+ test("A4b: ctrl+x outside the 500ms window only re-arms", () => {
436
+ assert.deepEqual(report.legacySlow.archived, []);
437
+ assert.equal(report.legacySlow.mode, "list");
438
+ });
439
+
440
+ test("A6: multi-select keeps ctrl+x and ignores plain x", () => {
441
+ assert.equal(report.selectMode.afterX.mode, "select", "plain x is a no-op in select mode");
442
+ assert.equal(report.selectMode.afterX.prompt, null);
443
+ assert.equal(report.selectMode.ctrlX.mode, "confirm");
444
+ assert.match(report.selectMode.ctrlX.prompt, /^Delete 1 done session\? Session files are preserved\. \(y\/N\)$/);
445
+ assert.equal(report.selectMode.ctrlX.returnMode, "select");
446
+ assert.deepEqual(report.selectMode.archived, [], "the batch path never routes through single-row archive");
447
+ assert.deepEqual(report.selectMode.confirmed.archiveManyCalls, [[report.selectMode.target]], "y routes the batch through archiveMany");
448
+ assert.ok(!report.selectMode.confirmed.orderedIds.includes(report.selectMode.target), "deleted row leaves the list");
449
+ });
450
+ ```
451
+
452
+ - [x] **Step 2: 跑测试**
453
+
454
+ Run: `cd "$WT" && node --test test/dashboard-delete-key.test.mjs`
455
+ Expected: PASS(9 个 test)。若 8) 出现误删,说明 500ms 窗口被 Task 1 改动破坏——回到 `handleListKey` 首行的 arm 重置语义排查。
456
+
457
+ - [x] **Step 3: 全量测试**
458
+
459
+ Run: `cd "$WT" && npm test`
460
+ Expected: PASS。
461
+
462
+ - [x] **Step 4: Commit**
463
+
464
+ ```bash
465
+ cd "$WT"
466
+ git add test-support/dashboard-delete-key-probe.ts test/dashboard-delete-key.test.mjs
467
+ git commit -m "test(ui): pin legacy ctrl+x and multi-select delete paths (issue #150)"
468
+ ```
469
+
470
+ ---
471
+
472
+ ### Task 3: 文案同步(hints / help overlay / README)
473
+
474
+ **Deliverable:** 三条路径都只讲同一事实:`x`(确认式)与 `ctrl+x` ×2(免确认快捷),无 `ctrl+x x2` 独占表述。
475
+
476
+ **Files:**
477
+ - Modify: `src/ui/dashboard.ts`(`listHints` :1316;help overlay :1667-1668)
478
+ - Modify: `README.md`(:117 bullet、:142 表格;:119 多选 bullet 不动)
479
+ - Modify: `test-support/dashboard-delete-key-probe.ts`(渲染快照)
480
+ - Modify: `test/dashboard-delete-key.test.mjs`(A7 断言)
481
+
482
+ **Interfaces:**
483
+ - Consumes: Task 1 的探针 harness;`DashboardComponent.render(width)` 公开方法;`dash.mode = "help"` 白盒设置。
484
+ - Produces: report 新增 `copy` 字段(`{ hints: string, help: string }`)。
485
+
486
+ - [x] **Step 1: 写失败断言**
487
+
488
+ 探针追加(`console.log` 之前):
489
+
490
+ ```ts
491
+ // 10) copy: list hints and the help overlay both advertise the new key
492
+ {
493
+ const env = makeEnv();
494
+ const dash = makeDash(env);
495
+ const d = dash as unknown as { mode: string };
496
+ // 240 cols: the hints line clips at narrow widths (pre-existing behavior);
497
+ // assert against the unclipped source string.
498
+ const hints = dash.render(240).join("\n");
499
+ d.mode = "help";
500
+ const help = dash.render(240).join("\n");
501
+ report.copy = { hints, help };
502
+ dash.dispose();
503
+ }
504
+ ```
505
+
506
+ 测试追加:
507
+
508
+ ```js
509
+ test("A7: hints and help advertise x (y/N) alongside the legacy shortcut", () => {
510
+ assert.match(report.copy.hints, /x delete/);
511
+ assert.match(report.copy.hints, /ctrl\+x x2/);
512
+ assert.match(report.copy.help, /x\s+Delete selected session \(y\/N confirm\)/);
513
+ assert.match(report.copy.help, /ctrl\+x x2\s+Delete selected session \(quick double-press, no confirm\)/);
514
+ });
515
+ ```
516
+
517
+ Run: `cd "$WT" && node --test test/dashboard-delete-key.test.mjs`
518
+ Expected: FAIL(hints 仍是 `ctrl+x x2 delete`,help 无 `x` 行)。
519
+
520
+ - [x] **Step 2: 改文案**
521
+
522
+ `src/ui/dashboard.ts` `listHints`(:1316)把 `"ctrl+x x2 delete"` 改为 `"x delete (y/N)"` 与 `"ctrl+x x2 quick"` 两项(保持数组顺序:`d done` … `x delete (y/N)` … `ctrl+x x2 quick` …)。
523
+
524
+ help overlay(:1668)在 `ctrl+x x2` 行之前插入:
525
+
526
+ ```ts
527
+ ["x", "Delete selected session (y/N confirm)"],
528
+ ```
529
+
530
+ `README.md` :117 bullet 改为:
531
+
532
+ ```markdown
533
+ - Press `x`, then `y` to confirm deleting the selected row. `Ctrl+X` twice quickly still deletes without confirmation. Archiving removes the row from the board but preserves its underlying Pi session file.
534
+ ```
535
+
536
+ `README.md` :142 表格在 `Ctrl+X` 行之前插入:
537
+
538
+ ```markdown
539
+ | `x`, then `y` | Archive/delete the selected row (confirmation prompt). |
540
+ ```
541
+
542
+ 并把既有行文案改为:
543
+
544
+ ```markdown
545
+ | `Ctrl+X` twice quickly | Archive/delete the selected row without confirmation. |
546
+ ```
547
+
548
+ - [x] **Step 3: 跑测试与静态检查**
549
+
550
+ Run:
551
+ ```bash
552
+ cd "$WT"
553
+ node --test test/dashboard-delete-key.test.mjs
554
+ rg -n "ctrl\+x x2 delete" src/ README.md ; test $? -eq 1
555
+ rg -n "Ctrl\+X twice quickly|Ctrl\+X` twice" README.md
556
+ ```
557
+ Expected: 测试 PASS;第一条 `rg` 无匹配(exit 1);第二条命中 README :117 与 :142。
558
+
559
+ - [x] **Step 4: 类型检查 + 全量测试**
560
+
561
+ Run: `cd "$WT" && npm run typecheck && npm test`
562
+ Expected: PASS。
563
+
564
+ - [x] **Step 5: Commit**
565
+
566
+ ```bash
567
+ cd "$WT"
568
+ git add src/ui/dashboard.ts README.md test-support/dashboard-delete-key-probe.ts test/dashboard-delete-key.test.mjs
569
+ git commit -m "docs(ui): document x-then-y delete alongside the ctrl+x shortcut (issue #150)"
570
+ ```
571
+
572
+ ---
573
+
574
+ ### Task 4: 全量验证与验收对账
575
+
576
+ **Deliverable:** `npm run verify` 全绿;spec 验收矩阵(A1-A7 / U1)逐项对账记录。
577
+
578
+ **Files:**
579
+ - Modify: `docs/superpowers/plans/2026-09-30-board-delete-key-x-y-confirm.md`(勾选本 plan 的 checkbox,附验证输出摘要)
580
+
581
+ - [x] **Step 1: 全量验证**
582
+
583
+ Run: `cd "$WT" && npm run verify`
584
+ Expected: typecheck / perf gate / tests / coverage(lines 85, funcs 80, branches 70)/ pack:dry 全部通过。若覆盖率不足,补充探针场景而不是放宽阈值。
585
+
586
+ - [x] **Step 2: 验收矩阵对账**
587
+
588
+ 逐项执行并记录:
589
+ ```bash
590
+ cd "$WT"
591
+ node --test test/dashboard-delete-key.test.mjs # A1, A1b, A1c, A2, A3, A4, A4b, A5, A6, A7
592
+ npm run typecheck # 静态
593
+ rg -n "ctrl\+x x2 delete|Ctrl\+X`? twice quickly to archive" src/ README.md # A7 静态(应无旧表述)
594
+ ```
595
+ Expected: 全部通过;A7 的 `rg` 无输出。
596
+
597
+ - [x] **Step 3: 记录 U1 实测清单(交给用户执行)**
598
+
599
+ 在 plan 末尾记录(不勾选,等用户结果):
600
+
601
+ ```markdown
602
+ ## U1 实测清单(用户在真实终端执行)
603
+ 1. 启动 board:`pi` → 打开 agent-board 面板。
604
+ 2. 选中一个已完成的 session → 按 `x` → 看到 `Delete "name"? Session file is preserved. (y/N)` → 按 `n`:无变化。
605
+ 3. 再次 `x` → `y`:该行消失、出现 `Deleted "name"`,board 交互正常。
606
+ 4. 选中一个 Working 行 → `x`:提示含 `Stops the active run.` → `y`:行消失;`ps` 确认无残留 runner/host 进程。
607
+ 5. 查 `~/.pi/agent/agent-board/views/<viewId>/`:`meta.json` 中 `archived: true`,session 文件仍在。
608
+ 6. 老路径:选中一行 → `Ctrl+X` 连按两次:仍免确认删除。
609
+ ```
610
+
611
+ - [x] **Step 4: Commit**
612
+
613
+ ```bash
614
+ cd "$WT"
615
+ git add docs/superpowers/plans/2026-09-30-board-delete-key-x-y-confirm.md docs/superpowers/specs/2026-09-30-board-delete-key-x-y-confirm-design.md
616
+ git commit -m "chore: record issue-150 verification results"
617
+ ```
618
+
619
+ ---
620
+
621
+ ## Post-review fixes(最终分支 review 后的一轮修复,2026-09-30)
622
+
623
+ Review 结论:Ready to merge (Yes),无 Critical/Important;3 个 Minor 全部修掉:
624
+
625
+ 1. `VERIFY.md:85` 键位清单同步 `x` → `y` 与 `Ctrl+X` ×2 双路径(原文只列了 Ctrl+X)。
626
+ 2. 新增探针场景 11 + 测试 A4c:`ctrl+x` arm 状态下按 `x` 必须清 arm 并打开确认,不叠加触发双击(Review Focus 5 原本声称 A4 覆盖但无测试钉住;arm 提示读 `inputNotice` 而非 `flash`)。
627
+ 3. `package-lock.json` 被 worktree 内 `npm install` 重写(tab 缩进差异,未入 commit),已 `git checkout --` 还原。
628
+
629
+ 测试:`node --test test/dashboard-delete-key.test.mjs` → 11/11。
630
+
631
+ ## 验证结果(2026-09-30,worktree 内实测)
632
+
633
+ - `node --test test/dashboard-delete-key.test.mjs` → 10/10 pass(A1, A1b, A1c, A2, A3, A4, A4b, A5, A6, A7)。
634
+ - `npm run typecheck` → clean。
635
+ - `npm test` → 1077-1078 pass / 0 fail(3 skipped = perf gate;一次 run 中 1 个 `runner.integration` 用例 flake,重跑不复现,属 #95 已知类别,与本改动无关)。
636
+ - `npm run test:coverage` → 1078 pass / 0 fail;statements 91.7 / branches 81.0 / functions 88.85 / lines 91.7,全过阈值(85/80/70... lines 85 / funcs 80 / branches 70)。
637
+ - `npm run pack:dry` → tarball 正常(203 files)。
638
+ - 静态:`rg "ctrl\+x x2 delete" src/ README.md` 无匹配;README:117/:143 均含 `x` + `(y/N)` 与 `Ctrl+X` ×2 双路径说明。
639
+ - **U1 用户实测:pending**(清单见上节,等用户在真实终端执行)。
640
+
641
+ ## 追溯矩阵(spec 验收 ID ↔ 本 plan task)
642
+
643
+ | 验收 ID | Task |
644
+ | --- | --- |
645
+ | A1 / A1b / A1c / A2 / A3 / A5 | Task 1 |
646
+ | A4 / A4b / A6 | Task 2 |
647
+ | A7 | Task 3 |
648
+ | U1 + 全量静态/构建验证 | Task 4 |
649
+ | Review Focus 1-5 | Task 1(1/2/3/4)、Task 2(5) |