@zhuxixi/pi-agent-board 0.11.0 → 0.12.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 (33) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +4 -3
  3. package/docs/superpowers/plans/2026-10-10-attach-delete-in-place.md +832 -0
  4. package/docs/superpowers/plans/2026-10-14-issue-170-attach-reply-guard.md +771 -0
  5. package/docs/superpowers/plans/2026-10-14-issue-178-accept-registration-race.md +1257 -0
  6. package/docs/superpowers/plans/2026-10-14-issue-184-snapshot-window-contract.md +657 -0
  7. package/docs/superpowers/plans/2026-10-14-issue-185-attach-wheel-alt-screen.md +47 -0
  8. package/docs/superpowers/plans/2026-10-14-issue-187-motionless-drag.md +931 -0
  9. package/docs/superpowers/plans/2026-10-14-issue-189-mixed-input-segments.md +823 -0
  10. package/docs/superpowers/plans/2026-10-15-issue-188-x10-mouse-release.md +526 -0
  11. package/docs/superpowers/plans/2026-10-16-issue-198-attach-wheel-forward-fallback.md +32 -0
  12. package/docs/superpowers/plans/2026-10-18-attach-wheel-replay-pseudo-scrollback.md +437 -0
  13. package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +6 -0
  14. package/docs/superpowers/specs/2026-10-10-attach-delete-in-place-design.md +224 -0
  15. package/docs/superpowers/specs/2026-10-14-issue-170-attach-reply-guard-design.md +190 -0
  16. package/docs/superpowers/specs/2026-10-14-issue-178-accept-registration-race-design.md +287 -0
  17. package/docs/superpowers/specs/2026-10-14-issue-184-snapshot-window-contract-design.md +288 -0
  18. package/docs/superpowers/specs/2026-10-14-issue-185-attach-wheel-alt-screen-design.md +126 -0
  19. package/docs/superpowers/specs/2026-10-14-issue-187-motionless-drag-design.md +269 -0
  20. package/docs/superpowers/specs/2026-10-14-issue-189-mixed-input-segments-design.md +369 -0
  21. package/docs/superpowers/specs/2026-10-15-issue-188-x10-mouse-release-design.md +252 -0
  22. package/docs/superpowers/specs/2026-10-16-issue-198-attach-wheel-forward-fallback-design.md +67 -0
  23. package/docs/superpowers/specs/2026-10-18-attach-wheel-replay-pseudo-scrollback-design.md +250 -0
  24. package/package.json +1 -1
  25. package/src/commands/agent-board.ts +27 -3
  26. package/src/core/attach-delete.mjs +24 -0
  27. package/src/core/pty-scroll.mjs +323 -12
  28. package/src/core/rows.mjs +19 -0
  29. package/src/core/terminal-attach-client.mjs +9 -4
  30. package/src/core/terminal-attach-protocol.mjs +5 -2
  31. package/src/core/terminal-reply-guard.mjs +200 -0
  32. package/src/ui/dashboard.ts +4 -0
  33. package/src/ui/pty-attach.ts +334 -38
@@ -0,0 +1,657 @@
1
+ # Issue #184: A5 burst 窗口契约对齐 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:** 让 `A5: burst through the snapshot window` 用例的覆盖率断言回到快照窗口契约内(不再依赖拍摄时机),并把「失败必须自报机制」的诊断与「窗口边界」的确定性钉子落进测试套件。
6
+
7
+ **Architecture:** 纯测试改动。新增一个纯函数诊断模块(`test-support/burst-diagnostics.mjs`)承载「聚合 + 渲染」,用例侧只留一层薄接线(消息数组 → 文本数组);A5 burst 的 burst 行数降到 frame 窗口内,并用一条运行期守卫把「burst 必须装得进窗口」这条不等式钉住;新增 `A5w` 用例,用确定性双腿(先让老客户端收齐再订阅)把「窗口内必达 / 窗口外截断」变成机械事实;设计文档的 A5 行下方补一段带日期的口径补注。
8
+
9
+ **Tech Stack:** Node.js(`node --test`,本机 v24.13.0)、`node:assert/strict`、既有 `test-support/flake-postmortem.mjs`、既有 `src/core/terminal-snapshot.mjs` 常量。
10
+
11
+ ## Global Constraints
12
+
13
+ - **test-only + docs**:不改 `src/`、`runner/`、`.github/workflows/ci.yml`;不新增依赖;不重命名任何测试文件。
14
+ - **不调预算**:文件默认 `waitFor` 15000 与 A5 既有 30000 保持原样;不新增用例级 timeout;`npm run verify` 的 budget-audit 必须零违规。
15
+ - **按名字隔离只用正模式**,且 `--test-name-pattern` 必须写在位置参数(测试文件)**之前**:`node --test --test-name-pattern "<模式>" <文件>`。写在文件之后会被 node 静默忽略并跑满全文件。
16
+ - **注释与 commit message 一律英文**;缩进用 **tab**(仓规 AGENTS.md)。
17
+ - **窗口不等式(硬约束)**:`BURST_LINES + MAX_BURST_MISC_ROWS <= RUNNER_ROWS + DEFAULT_SCROLLBACK_SNAPSHOT_CAP`。`RUNNER_ROWS` 必须与 `spawnRunner` 的默认参数**同源**(禁止写镜像的 `24`);`DEFAULT_SCROLLBACK_SNAPSHOT_CAP` 从 `src/core/terminal-snapshot.mjs` import(禁止字面量 `256`)。
18
+ - **三层不得合并**:解析(`collectBurstIds`)↔ 聚合 + 渲染(`formatBurstDiagnostics`)↔ 判定(用例内断言)。helper 里出现 `assert` 即违约。
19
+ - **工作目录**:本 plan 全部路径以 worktree 为根 `/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-184-snapshot-window-contract`;所有 `git` 命令用 `git -C <worktree>`,禁止在主 checkout 提交。
20
+ - **spec 验收归属(双向追溯)**:
21
+
22
+ | spec 验收 ID | 落在哪个 task |
23
+ |---|---|
24
+ | V1(A5 burst 用例 + 窗口守卫) | Task 2(常量 + 守卫)、Task 3(诊断接线) |
25
+ | V2(纯函数单测 + 接线静态断言) | Task 1(纯函数)、Task 3(静态断言 ①–⑤) |
26
+ | V3(`A5w` 双腿) | Task 4 |
27
+ | V4(全量剖面佐证) | Task 6 |
28
+ | V5(门禁与预算审计) | Task 6 |
29
+ | U1(设计文档 A5 行补注,人工核对) | Task 5 |
30
+
31
+ **Spec:** `docs/superpowers/specs/2026-10-14-issue-184-snapshot-window-contract-design.md`(同 worktree 内,v2 稿)。
32
+
33
+ ## File Structure
34
+
35
+ | 文件 | 责任 | 动作 |
36
+ |---|---|---|
37
+ | `test-support/burst-diagnostics.mjs` | 纯函数:`burstline-NNNN` id 解析 + 失败诊断块渲染(无 I/O、无定时器、不断言) | 新建 |
38
+ | `test/burst-diagnostics.test.mjs` | 上述纯函数的单测 + 对集成用例的接线静态断言(源码文本级) | 新建 |
39
+ | `test/terminal-snapshot.integration.test.mjs` | 窗口常量与守卫、A5 burst 的诊断接线与行数、新增 `A5w` 用例与 `measureLateSubscribe` 辅助 | 修改 |
40
+ | `docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md` | A5 行的日期化口径补注(追加,不改原文) | 修改 |
41
+
42
+ ---
43
+
44
+ ### Task 1: `test-support/burst-diagnostics.mjs` + 单测
45
+
46
+ **Files:**
47
+ - Create: `test-support/burst-diagnostics.mjs`
48
+ - Test: `test/burst-diagnostics.test.mjs`
49
+
50
+ **Interfaces:**
51
+ - Consumes: 无(纯函数,零依赖)
52
+ - Produces:
53
+ - `collectBurstIds(text: string): number[]` —— 去重、升序的 `burstline-NNNN` id
54
+ - `formatBurstDiagnostics({ expectedRows: number, driverTexts?: string[], frameText?: string, liveTexts?: string[] }): string`
55
+
56
+ - [ ] **Step 1: 先写失败测试**
57
+
58
+ 创建 `test/burst-diagnostics.test.mjs`:
59
+
60
+ ```js
61
+ import assert from "node:assert/strict";
62
+ import { test } from "node:test";
63
+ import { collectBurstIds, formatBurstDiagnostics } from "../test-support/burst-diagnostics.mjs";
64
+
65
+ // issue #184: the A5 burst failure used to be undiagnosable — the durable-state
66
+ // postmortem cannot tell "the child never wrote the rows" from "the snapshot
67
+ // frame window truncated them". These counters can.
68
+
69
+ test("collectBurstIds extracts unique ascending ids from arbitrary chunks", () => {
70
+ assert.deepEqual(collectBurstIds(""), []);
71
+ assert.deepEqual(collectBurstIds("nothing to see here\n"), []);
72
+ assert.deepEqual(collectBurstIds("burstline-0001-pppp\nburstline-0002-pppp"), [1, 2]);
73
+ // chunks may repeat ids (frame + catch-up flush) — the union must dedupe and sort
74
+ assert.deepEqual(collectBurstIds("burstline-0400-a\nburstline-0001-b\nburstline-0400-c"), [1, 400]);
75
+ assert.deepEqual(collectBurstIds("prefix burstline-0012-txt suffix burstline-0013"), [12, 13]);
76
+ });
77
+
78
+ test("formatBurstDiagnostics renders counts, ranges and missing ids", () => {
79
+ const full = formatBurstDiagnostics({
80
+ expectedRows: 3,
81
+ driverTexts: ["burstline-0001-a\nburstline-0002-b\nburstline-0003-c"],
82
+ frameText: "burstline-0002-b\nburstline-0003-c",
83
+ liveTexts: ["burstline-0001-a"],
84
+ });
85
+ assert.match(full, /--- burst coverage \(test-side judgement, issue #184\) ---/);
86
+ assert.match(full, /expectedRows: 3/);
87
+ assert.match(full, /driverRows: 3 \[1\.\.3\]/);
88
+ assert.match(full, /frameRows: 2 \[2\.\.3\]/);
89
+ assert.match(full, /liveRows: 1/);
90
+ assert.match(full, /unionRows: 3/);
91
+ assert.match(full, /missing: <none>/);
92
+
93
+ const truncated = formatBurstDiagnostics({
94
+ expectedRows: 5,
95
+ driverTexts: ["burstline-0001-a\nburstline-0002-b\nburstline-0003-c\nburstline-0004-d\nburstline-0005-e"],
96
+ frameText: "burstline-0004-d\nburstline-0005-e",
97
+ liveTexts: [],
98
+ });
99
+ assert.match(truncated, /unionRows: 2/);
100
+ assert.match(truncated, /missing: 3 \[1, 2, 3\]/);
101
+ });
102
+
103
+ test("formatBurstDiagnostics prints zeros when nothing was observed yet", () => {
104
+ const empty = formatBurstDiagnostics({ expectedRows: 200 });
105
+ assert.match(empty, /driverRows: 0/);
106
+ assert.match(empty, /frameRows: 0/);
107
+ assert.match(empty, /liveRows: 0/);
108
+ assert.match(empty, /unionRows: 0/);
109
+ assert.match(empty, /missing: 200 \[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, …\]/);
110
+ });
111
+ ```
112
+
113
+ - [ ] **Step 2: 跑测试,确认失败**
114
+
115
+ Run: `node --test test/burst-diagnostics.test.mjs`
116
+ Expected: FAIL —— `Cannot find module .../test-support/burst-diagnostics.mjs`(模块尚不存在)
117
+
118
+ - [ ] **Step 3: 实现模块**
119
+
120
+ 创建 `test-support/burst-diagnostics.mjs`:
121
+
122
+ ```js
123
+ /**
124
+ * Test-side judgement block for the A5 burst case (issue #184).
125
+ *
126
+ * The durable-state postmortem cannot tell "the child never emitted the rows"
127
+ * from "the snapshot frame window truncated them": both end as an empty
128
+ * diagnostics/journal with a healthy host. These counters can — the driver is
129
+ * subscribed BEFORE the burst, so a complete driver range proves the child wrote
130
+ * every row and the loss sits in the frame / catch-up path.
131
+ *
132
+ * Pure: no I/O, no timers, no assertions (judgement stays in the test case).
133
+ */
134
+
135
+ /**
136
+ * @param {string} text arbitrary chunk text (many lines, a partial line, or no
137
+ * burst content at all)
138
+ * @returns {number[]} unique `burstline-NNNN` ids, ascending
139
+ */
140
+ export function collectBurstIds(text) {
141
+ const ids = new Set();
142
+ for (const m of String(text ?? "").matchAll(/burstline-(\d{4})/g)) ids.add(Number(m[1]));
143
+ return [...ids].sort((a, b) => a - b);
144
+ }
145
+
146
+ /**
147
+ * @param {number[]} ids ascending ids
148
+ * @returns {string} `3 [1..3]`, or `0` when empty
149
+ */
150
+ function formatRange(ids) {
151
+ if (ids.length === 0) return "0";
152
+ return `${ids.length} [${ids[0]}..${ids[ids.length - 1]}]`;
153
+ }
154
+
155
+ /**
156
+ * Render the block appended to a failing wait's postmortem.
157
+ * @param {{
158
+ * expectedRows: number,
159
+ * driverTexts?: string[],
160
+ * frameText?: string,
161
+ * liveTexts?: string[],
162
+ * }} input `expectedRows` is the burst size the case asserts; `driverTexts` come
163
+ * from the client that was subscribed BEFORE the burst, `frameText`/`liveTexts`
164
+ * from the client under test.
165
+ * @returns {string}
166
+ */
167
+ export function formatBurstDiagnostics({ expectedRows, driverTexts = [], frameText = "", liveTexts = [] }) {
168
+ const driverIds = collectBurstIds(driverTexts.join("\n"));
169
+ const frameIds = collectBurstIds(frameText);
170
+ const liveIds = collectBurstIds(liveTexts.join("\n"));
171
+ const union = new Set([...frameIds, ...liveIds]);
172
+ const missing = [];
173
+ for (let i = 1; i <= expectedRows; i += 1) {
174
+ if (!union.has(i)) missing.push(i);
175
+ }
176
+ const shown = missing.slice(0, 10).join(", ");
177
+ const missingText = missing.length === 0
178
+ ? "<none>"
179
+ : `${missing.length} [${shown}${missing.length > 10 ? ", …" : ""}]`;
180
+ return [
181
+ "--- burst coverage (test-side judgement, issue #184) ---",
182
+ `expectedRows: ${expectedRows}`,
183
+ `driverRows: ${formatRange(driverIds)}`,
184
+ `frameRows: ${formatRange(frameIds)}`,
185
+ `liveRows: ${liveIds.length}`,
186
+ `unionRows: ${union.size}`,
187
+ `missing: ${missingText}`,
188
+ ].join("\n");
189
+ }
190
+ ```
191
+
192
+ - [ ] **Step 4: 跑测试,确认通过**
193
+
194
+ Run: `node --test test/burst-diagnostics.test.mjs`
195
+ Expected: PASS —— 3 个测试全过(`# pass 3` / `# fail 0`)
196
+
197
+ - [ ] **Step 5: 提交**
198
+
199
+ ```bash
200
+ cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-184-snapshot-window-contract
201
+ git add test-support/burst-diagnostics.mjs test/burst-diagnostics.test.mjs
202
+ git commit -m "test(attach): add burst diagnostics formatter for the A5 burst case (issue #184)"
203
+ ```
204
+
205
+ ---
206
+
207
+ ### Task 2: 窗口常量、同源 `RUNNER_ROWS` 与守卫断言
208
+
209
+ **Files:**
210
+ - Modify: `test/terminal-snapshot.integration.test.mjs`(顶部 import/常量、`spawnRunner:101`、A5 burst 用例 `:273` / `:349` / `:351` 与守卫)
211
+
212
+ **Interfaces:**
213
+ - Consumes: `DEFAULT_SCROLLBACK_SNAPSHOT_CAP`(`src/core/terminal-snapshot.mjs`)
214
+ - Produces: 模块常量 `RUNNER_ROWS` / `BURST_LINES` / `MAX_BURST_MISC_ROWS` / `MISC_TOLERANCE`(Task 3 的接线与 Task 4 的用例都引用它们)
215
+
216
+ - [ ] **Step 1: 加 import 与模块常量**
217
+
218
+ 在 import 区追加(放在 `flake-postmortem` 那行之后):
219
+
220
+ ```js
221
+ import { DEFAULT_SCROLLBACK_SNAPSHOT_CAP } from "../src/core/terminal-snapshot.mjs";
222
+ ```
223
+
224
+ 在 `function freshRoot()` 之前插入:
225
+
226
+ ```js
227
+ // Snapshot frame capacity (issue #184): the frame carries the viewport rows plus
228
+ // the scrollback cap, so a late subscriber can only recover the TAIL of that
229
+ // window. RUNNER_ROWS is shared with spawnRunner's default — never mirror it, or
230
+ // the window guard below drifts away from the real viewport.
231
+ const RUNNER_ROWS = 24;
232
+ // The burst must FIT the window, otherwise "frame ∪ flush ∪ live covers every
233
+ // row" becomes a capture-timing race instead of a property (issue #184).
234
+ const BURST_LINES = 200;
235
+ const MAX_BURST_MISC_ROWS = 8;
236
+ // Non-burst rows (boot banner, probe echo, cursor line) can eat a few window
237
+ // slots in the overflow leg; the boundary assertion tolerates this much drift.
238
+ const MISC_TOLERANCE = 8;
239
+ ```
240
+
241
+ - [ ] **Step 2: 让 `spawnRunner` 与 `RUNNER_ROWS` 同源**
242
+
243
+ 把 `:101` 的
244
+
245
+ ```js
246
+ function spawnRunner(root, viewId, { env = {}, cols = 80, rows = 24 } = {}) {
247
+ ```
248
+
249
+ 改为
250
+
251
+ ```js
252
+ function spawnRunner(root, viewId, { env = {}, cols = 80, rows = RUNNER_ROWS } = {}) {
253
+ ```
254
+
255
+ - [ ] **Step 3: A5 burst 用例改用常量并加守卫**
256
+
257
+ `:273`:
258
+
259
+ ```js
260
+ ({ runner } = spawnRunner(root, viewId, { env: { FAKE_PTY_BURST_LINES: String(BURST_LINES) } }));
261
+ ```
262
+
263
+ 在 `const capture = ...` 之前插入守卫:
264
+
265
+ ```js
266
+ // issue #184: the coverage assertion in this case is valid only while the whole
267
+ // burst fits the snapshot frame window (viewport rows + scrollback cap). Keep
268
+ // this guard so a future BURST_LINES bump fails loudly instead of flaking.
269
+ assert.ok(
270
+ BURST_LINES + MAX_BURST_MISC_ROWS <= RUNNER_ROWS + DEFAULT_SCROLLBACK_SNAPSHOT_CAP,
271
+ `burst must fit the snapshot frame window: ${BURST_LINES} + ${MAX_BURST_MISC_ROWS} > ${RUNNER_ROWS} + ${DEFAULT_SCROLLBACK_SNAPSHOT_CAP}`,
272
+ );
273
+ ```
274
+
275
+ `:349` 与 `:351`:
276
+
277
+ ```js
278
+ return seen.size >= BURST_LINES;
279
+ ```
280
+
281
+ ```js
282
+ assert.equal(seen.size, BURST_LINES, "every burst line delivered exactly once across frame+flush+live");
283
+ ```
284
+
285
+ - [ ] **Step 4: 跑 A5 burst 用例**
286
+
287
+ Run: `node --test --test-name-pattern "A5: burst through the snapshot window" test/terminal-snapshot.integration.test.mjs`
288
+ Expected: PASS —— `# pass 1` / `# fail 0`(200 行 < 窗口 280 行,任何拍摄时机都覆盖得到)
289
+
290
+ - [ ] **Step 5: 当场验证守卫会咬人(临时改,改完必须还原)**
291
+
292
+ 把 `BURST_LINES` 临时改成 `400`,Run 同一条命令。
293
+ Expected: FAIL —— 报 ``burst must fit the snapshot frame window: 400 + 8 > 24 + 256``。
294
+ 随后把 `BURST_LINES` **改回 `200`**,重跑确认 PASS。
295
+
296
+ - [ ] **Step 6: 提交**
297
+
298
+ ```bash
299
+ git add test/terminal-snapshot.integration.test.mjs
300
+ git commit -m "test(attach): keep the A5 burst inside the snapshot frame window (issue #184)"
301
+ ```
302
+
303
+ ---
304
+
305
+ ### Task 3: 诊断接线 + 接线静态断言
306
+
307
+ **Files:**
308
+ - Modify: `test/terminal-snapshot.integration.test.mjs:271`(`capture` 闭包)、`:279`(`driverMessages` 赋值)、`:300-301`(B 的 wireClient 拆包)
309
+ - Modify: `test/burst-diagnostics.test.mjs`(追加静态断言)
310
+
311
+ **Interfaces:**
312
+ - Consumes: Task 1 的 `collectBurstIds` / `formatBurstDiagnostics`;Task 2 的 `BURST_LINES`
313
+ - Produces: 模块级辅助 `outputText(messages): string`(Task 4 复用)
314
+
315
+ - [ ] **Step 1: 先加静态断言(此时必然失败)**
316
+
317
+ 在 `test/burst-diagnostics.test.mjs` 顶部补 import 与源码读取,并在文件末尾追加断言:
318
+
319
+ ```js
320
+ import { readFileSync } from "node:fs";
321
+ ```
322
+
323
+ ```js
324
+ // Static wire checks read the real integration suite: they cannot prove the
325
+ // runtime prints, only that the wiring (and the pre-existing judgement calls)
326
+ // cannot be deleted silently. Runtime printing is covered by the case itself.
327
+ const snapshotSource = readFileSync(new URL("./terminal-snapshot.integration.test.mjs", import.meta.url), "utf8");
328
+
329
+ /** @param {string} source @param {string} name top-level case name */
330
+ function caseBody(source, name) {
331
+ const start = source.indexOf(`test(${JSON.stringify(name)}`);
332
+ assert.notEqual(start, -1, `case not found: ${name}`);
333
+ const next = source.indexOf("\ntest(", start + 1);
334
+ return source.slice(start, next === -1 ? source.length : next);
335
+ }
336
+
337
+ const A5_BURST_CASE = "A5: burst through the snapshot window — frame→flush→end interleaving pinned on the wire, client converges in order";
338
+
339
+ test("A5 burst case wires the burst diagnostics and keeps its pinned assertions", () => {
340
+ const body = caseBody(snapshotSource, A5_BURST_CASE);
341
+ // 1. the diagnostics block is composed into the failure capture
342
+ assert.match(body, /formatBurstDiagnostics\(/);
343
+ // 2. every observation source is mapped (guards against wiring the wrong side)
344
+ for (const key of ["driverTexts", "frameText", "liveTexts"]) {
345
+ assert.match(body, new RegExp(`${key}:`), `capture closure must map ${key}`);
346
+ }
347
+ // 3. every wait in this case carries the capture thunk
348
+ const waits = body.match(/await waitFor\(/g) ?? [];
349
+ const captured = body.match(/,\s*capture\)/g) ?? [];
350
+ assert.ok(waits.length > 0, "the A5 burst case must contain waits");
351
+ assert.equal(captured.length, waits.length, "every waitFor in the A5 burst case must pass `capture`");
352
+ // 4. the frame-window guard is present and derives from shared constants
353
+ assert.match(body, /DEFAULT_SCROLLBACK_SNAPSHOT_CAP/);
354
+ assert.match(body, /RUNNER_ROWS/);
355
+ // 5. the pre-existing judgement calls are still there — no green by deletion
356
+ assert.match(body, /flush chunks contiguous after the snapshot cursor/);
357
+ assert.match(body, /assert\.deepEqual\(eventsB\.protocolError, \[\]\)/);
358
+ assert.match(body, /assert\.deepEqual\(eventsB\.resubscribing, \[\]\)/);
359
+ });
360
+ ```
361
+
362
+ - [ ] **Step 2: 跑测试,确认新断言失败**
363
+
364
+ Run: `node --test test/burst-diagnostics.test.mjs`
365
+ Expected: FAIL —— 新用例报 `The input did not match the regular expression /formatBurstDiagnostics\(/`(接线还没做)
366
+
367
+ - [ ] **Step 3: 接线(改 3 处)**
368
+
369
+ 在模块级辅助函数区(`function freshRoot()` 附近)新增:
370
+
371
+ ```js
372
+ /** Concatenate the text of every `output` message (wire chunks may hold many lines). */
373
+ function outputText(messages) {
374
+ return messages.filter((m) => m.type === "output").map((m) => String(m.data)).join("\n");
375
+ }
376
+ ```
377
+
378
+ 把 `:271` 的单行 `capture` 闭包替换为:
379
+
380
+ ```js
381
+ // issue #184: the failure must name its mechanism (child side vs frame side),
382
+ // so the postmortem carries test-side counters next to the durable state.
383
+ const observed = { driver: [], b: null };
384
+ const capture = () =>
385
+ formatPostmortem(capturePostmortem(root, viewId, null))
386
+ + "\n"
387
+ + formatBurstDiagnostics({
388
+ expectedRows: BURST_LINES,
389
+ driverTexts: outputText(observed.driver),
390
+ frameText: observed.b?.snapshotReady?.[0]?.frame ?? "",
391
+ liveTexts: (observed.b?.output ?? []).map((data) => (typeof data === "string" ? data : (data?.data ?? ""))),
392
+ });
393
+ ```
394
+
395
+ 把 `:279` 的 `const driverMessages = listen(driver).messages;` 替换为(**保留别名**,`:295` 的谓词因此不必改动):
396
+
397
+ ```js
398
+ const driverMessages = (observed.driver = listen(driver).messages);
399
+ ```
400
+
401
+ 把 `:300-301` 替换为:
402
+
403
+ ```js
404
+ const wiredB = wireClient(socketB);
405
+ const { client: clientB, events: eventsB } = wiredB;
406
+ observed.b = eventsB;
407
+ clientB.start();
408
+ ```
409
+
410
+ 顶部 import 追加:
411
+
412
+ ```js
413
+ import { collectBurstIds, formatBurstDiagnostics } from "../test-support/burst-diagnostics.mjs";
414
+ ```
415
+
416
+ - [ ] **Step 4: 跑两条验证**
417
+
418
+ Run: `node --test test/burst-diagnostics.test.mjs`
419
+ Expected: PASS —— 4 个测试全过(3 个单测 + 1 个静态断言)
420
+
421
+ Run: `node --test --test-name-pattern "A5: burst through the snapshot window" test/terminal-snapshot.integration.test.mjs`
422
+ Expected: PASS —— `# pass 1` / `# fail 0`(burst 已在 Task 2 降到窗口内的 200 行,不受拍摄时机影响)
423
+
424
+ - [ ] **Step 5: 提交**
425
+
426
+ ```bash
427
+ git add test/terminal-snapshot.integration.test.mjs test/burst-diagnostics.test.mjs
428
+ git commit -m "test(attach): wire burst diagnostics into the A5 burst failure path (issue #184)"
429
+ ```
430
+
431
+ ---
432
+
433
+ ### Task 4: `A5w` 确定性双腿用例
434
+
435
+ **Files:**
436
+ - Modify: `test/terminal-snapshot.integration.test.mjs`(在 A5 burst 用例之后、A5c 之前插入辅助函数与用例)
437
+
438
+ **Interfaces:**
439
+ - Consumes: `RUNNER_ROWS` / `BURST_LINES` / `MISC_TOLERANCE` / `DEFAULT_SCROLLBACK_SNAPSHOT_CAP`(Task 3);`outputText`(Task 2);`collectBurstIds`(Task 1);文件既有辅助 `freshRoot` / `spawnRunner` / `waitFor` / `hostReady` / `listen` / `send` / `wireClient` / `cleanup`
440
+ - Produces: `measureLateSubscribe(totalLines): Promise<{ union: Set<number>, protocolError: unknown[], resubscribing: unknown[] }>`
441
+
442
+ - [ ] **Step 1: 写用例(leg2 先用溢出值,让它红)**
443
+
444
+ 在 A5 burst 用例的 `});` 之后、`test("A5c: ...` 之前插入:
445
+
446
+ ```js
447
+ /** Spawn a runner, drain a full burst on an ALREADY-SUBSCRIBED legacy driver, then
448
+ * subscribe a fresh wire client — the worst-case capture timing for the frame
449
+ * window contract (issue #184). Returns only the client-side observations. */
450
+ async function measureLateSubscribe(totalLines) {
451
+ const root = freshRoot();
452
+ const viewId = `a5w-${totalLines}`;
453
+ const sockets = [];
454
+ let runner;
455
+ try {
456
+ ({ runner } = spawnRunner(root, viewId, { env: { FAKE_PTY_BURST_LINES: String(totalLines) } }));
457
+ await waitFor(() => hostReady(root, viewId), 30000);
458
+
459
+ const driver = createConnection(P.controlSocketPath(root, viewId));
460
+ await once(driver, "connect");
461
+ sockets.push(driver);
462
+ const driverMessages = listen(driver).messages;
463
+ send(driver, { type: "hello" });
464
+ let probeN = 0;
465
+ const probeTimer = setInterval(() => {
466
+ probeN += 1;
467
+ send(driver, { type: "input", data: `boot-probe-${probeN}\r` });
468
+ }, 300);
469
+ try {
470
+ await waitFor(() => driverMessages.find((m) => m.type === "output" && /boot-probe-\d/.test(String(m.data))), 30000);
471
+ } finally {
472
+ clearInterval(probeTimer);
473
+ }
474
+
475
+ // Drain the whole burst BEFORE subscribing: the capture is then guaranteed to
476
+ // land after every row exists, which is what makes both legs deterministic.
477
+ send(driver, { type: "input", data: "burst\r" });
478
+ await waitFor(() => collectBurstIds(outputText(driverMessages)).length >= totalLines, 30000);
479
+
480
+ const socketB = createConnection(P.controlSocketPath(root, viewId));
481
+ await once(socketB, "connect");
482
+ sockets.push(socketB);
483
+ const { client: clientB, events } = wireClient(socketB);
484
+ clientB.start();
485
+ await waitFor(() => events.snapshotReady.length > 0, 30000);
486
+ // No settle delay: the runner sends the catch-up flush chunks BEFORE snapshot_end
487
+ // (src/core/terminal-attach-protocol.mjs:138-150), and the client emits the flush
488
+ // while handling snapshot_end — so observing snapshotReady means the whole window
489
+ // is already in `events` (CR round 1, advisory).
490
+
491
+ const union = new Set([
492
+ ...collectBurstIds(events.snapshotReady[0]?.frame ?? ""),
493
+ ...collectBurstIds(events.output.map((data) => (typeof data === "string" ? data : (data?.data ?? ""))).join("\n")),
494
+ ]);
495
+ return { union, protocolError: events.protocolError, resubscribing: events.resubscribing };
496
+ } finally {
497
+ await cleanup(root, viewId, sockets, [runner]);
498
+ }
499
+ }
500
+
501
+ test("A5w: window contract — a late subscriber gets the frame-window tail, and content inside the window arrives complete", async () => {
502
+ const window = RUNNER_ROWS + DEFAULT_SCROLLBACK_SNAPSHOT_CAP;
503
+
504
+ // leg 1: burst larger than the window — the head is unrecoverable by design.
505
+ const overTotal = window + 120;
506
+ const leg1 = await measureLateSubscribe(overTotal);
507
+ assert.ok(leg1.union.size < overTotal, `expected truncation: union ${leg1.union.size} of ${overTotal}`);
508
+ assert.ok(leg1.union.size <= window, `union ${leg1.union.size} must not exceed the window ${window}`);
509
+ assert.ok(
510
+ leg1.union.size >= window - MISC_TOLERANCE,
511
+ `union ${leg1.union.size} must fill the window (tolerance ${MISC_TOLERANCE})`,
512
+ );
513
+ const leg1Ids = [...leg1.union].sort((a, b) => a - b);
514
+ assert.ok(leg1Ids[0] > 1, "the window truncates the head of the burst");
515
+ assert.equal(
516
+ leg1Ids[leg1Ids.length - 1] - leg1Ids[0] + 1,
517
+ leg1Ids.length,
518
+ "the union is a contiguous tail, never a stitched subset",
519
+ );
520
+ assert.deepEqual(leg1.protocolError, []);
521
+ assert.deepEqual(leg1.resubscribing, []);
522
+
523
+ // leg 2: burst that fits the window — complete coverage even at the worst capture
524
+ // timing. This is the deterministic proof behind the A5 burst coverage assertion.
525
+ const leg2 = await measureLateSubscribe(BURST_LINES);
526
+ assert.equal(leg2.union.size, BURST_LINES, "content inside the window must arrive complete");
527
+ assert.deepEqual(leg2.protocolError, []);
528
+ assert.deepEqual(leg2.resubscribing, []);
529
+ });
530
+ ```
531
+
532
+ > 本步的 leg2 先写 `const leg2 = await measureLateSubscribe(overTotal);`(即 400 行),**故意让它红**——这正是原 CI 失败的形态。
533
+
534
+ - [ ] **Step 2: 跑用例,确认 leg2 失败(机制当场复现)**
535
+
536
+ Run: `node --test --test-name-pattern "A5w:" test/terminal-snapshot.integration.test.mjs`
537
+ Expected: FAIL —— `content inside the window must arrive complete`(`union.size` 约 279 < 400)。leg1 应在此前已通过。
538
+
539
+ - [ ] **Step 3: 把 leg2 改回窗口内,确认转绿**
540
+
541
+ 把 leg2 行改成:
542
+
543
+ ```js
544
+ const leg2 = await measureLateSubscribe(BURST_LINES);
545
+ ```
546
+
547
+ Run: `node --test --test-name-pattern "A5w:" test/terminal-snapshot.integration.test.mjs`
548
+ Expected: PASS —— `# pass 1` / `# fail 0`
549
+
550
+ - [ ] **Step 4: 连跑 5 轮确认确定性(V3)**
551
+
552
+ ```bash
553
+ for i in 1 2 3 4 5; do
554
+ node --test --test-name-pattern "A5w:" test/terminal-snapshot.integration.test.mjs | tail -3
555
+ done
556
+ ```
557
+
558
+ Expected: 5/5 全过;记下每轮 `duration_ms`(写进 Task 6 的验收记录)。
559
+
560
+ - [ ] **Step 5: 提交**
561
+
562
+ ```bash
563
+ git add test/terminal-snapshot.integration.test.mjs
564
+ git commit -m "test(attach): pin the snapshot frame window contract with A5w (issue #184)"
565
+ ```
566
+
567
+ ---
568
+
569
+ ### Task 5: 设计文档 A5 行的口径补注
570
+
571
+ **Files:**
572
+ - Modify: `docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md`(`§7 验收矩阵` 表格之后、`可测性拆分设计见第 9 节。` 之前)
573
+
574
+ **Interfaces:**
575
+ - Consumes: spec v2 的 D3 补注文本(逐字)
576
+ - Produces: 无代码接口(供 U1 人工核对)
577
+
578
+ - [ ] **Step 1: 插入补注**
579
+
580
+ 在 `:259`(表格最后一行 `| U3 | …`)与 `:260`(`可测性拆分设计见第 9 节。`)之间插入:
581
+
582
+ ```markdown
583
+ > **2026-10-14 补注(issue #184)**:上表 A5 的「客户端永不静默丢失或重复输出」适用域为**快照窗口内**——
584
+ > frame 的容量上限为 `viewport rows + DEFAULT_SCROLLBACK_SNAPSHOT_CAP`(当前 24 + 256 = 280 行)。窗口外的
585
+ > 更早历史不在本协议保证范围内,协议帧不承载该段内容,客户端也不会收到截断信号(是否显式化另立项);
586
+ > legacy 降级路径只有 screen.log 尾部回放,且是有界、best-effort 的历史来源,不是正确性来源。
587
+ > 机制、复现与依据见 issue #184。
588
+ ```
589
+
590
+ - [ ] **Step 2: 人工核对(U1)**
591
+
592
+ 对照检查:补注在表格之外(不破坏 markdown 表格);原文一字未改(`git diff` 只应看到新增行);文本与 spec v2 的 D3 逐字一致。
593
+
594
+ Run: `git -C /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-184-snapshot-window-contract diff docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md`
595
+ Expected: 只出现 `+` 行,无 `-` 行。
596
+
597
+ - [ ] **Step 3: 提交**
598
+
599
+ ```bash
600
+ git add docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md
601
+ git commit -m "docs(attach): note the snapshot window contract on the A5 acceptance row (issue #184)"
602
+ ```
603
+
604
+ ---
605
+
606
+ ### Task 6: 验收扫尾(V4 / V5)
607
+
608
+ **Files:** 无预期改动(只跑命令与记录数据);若发现缺陷,按 SDD 的 fix loop 处理。
609
+
610
+ - [ ] **Step 1: V1 —— A5 burst 用例隔离连跑 20 轮**
611
+
612
+ ```bash
613
+ pass=0
614
+ for i in $(seq 1 20); do
615
+ if node --test --test-name-pattern "A5: burst through the snapshot window" test/terminal-snapshot.integration.test.mjs >/tmp/a5-r$i.log 2>&1; then pass=$((pass+1)); else echo "FAIL round $i"; tail -30 /tmp/a5-r$i.log; fi
616
+ done
617
+ echo "A5 burst: $pass/20"
618
+ ```
619
+
620
+ Expected: `20/20`;把每轮耗时与结果写进验收记录。
621
+
622
+ - [ ] **Step 2: V4 —— 全量剖面 ≥3 轮**
623
+
624
+ ```bash
625
+ for i in 1 2 3; do
626
+ echo "=== full run $i ==="
627
+ npm test 2>&1 | tail -20
628
+ done
629
+ ```
630
+
631
+ Expected: 逐轮记录 `pass/fail/skipped`、耗时与失败签名;`A5 burst` 与 `A5w` 零命中。范围外签名(`subscribe_terminal` / `editor_state` / `A2 reconnect`)如实记录、不阻塞本 issue。
632
+
633
+ - [ ] **Step 3: V5 —— 门禁**
634
+
635
+ ```bash
636
+ npm run verify
637
+ ```
638
+
639
+ Expected: 退出码 0(typecheck + perf gate + tests + coverage + pack:dry);budget-audit 零违规;覆盖率仍过阈值。
640
+
641
+ - [ ] **Step 4: 记录验收数据**
642
+
643
+ 把 Step 1–3 的逐轮数据(命令、轮次、结果、耗时、失败签名)整理成文本,回帖到 issue #184;`A5w` 的每轮 `duration_ms` 一并记录。
644
+
645
+ ## Self-Review(作者自查,已执行)
646
+
647
+ - **spec 覆盖**:D1→Task 3;D2→Task 3(守卫 + 同源常量);D3→Task 5;D4→Task 1+2;D5→Task 4;D6(范围)→ 全 plan 的 Global Constraints;V1–V5/U1 见开头追溯表,无遗漏项。
648
+ - **占位符扫描**:无 `TBD`/`TODO`/「适当处理」类描述;每个代码步骤都给了可直接落盘的完整代码。
649
+ - **类型/命名一致性**:`collectBurstIds` / `formatBurstDiagnostics` / `outputText` / `measureLateSubscribe` / `RUNNER_ROWS` / `BURST_LINES` / `MAX_BURST_MISC_ROWS` / `MISC_TOLERANCE` 在各 task 间命名一致;`formatBurstDiagnostics` 的入参键名(`driverTexts`/`frameText`/`liveTexts`)与 Task 2 静态断言 ② 锚定的键名一致。
650
+ - **执行顺序与依赖**:Task 1 → 2 → 3 → 4 → 5 → 6;每个 task 只消费前序 task 已产出的接口(Task 2 产出窗口常量;Task 3 消费它们并产出 `outputText`;Task 4 消费两者)。无跨 task 的前向引用。
651
+
652
+ ## Amendments(实现后修订;两份都是 plan 自身的缺陷,非实现者失误)
653
+
654
+ 1. **Task 3 的 `caseBody` 切片边界(acceptance sweep 发现,commit `987f96d`)**:原片段只切到下一个 `\ntest(`,而 Task 4 按本 plan 的要求把 `measureLateSubscribe` 插在两个用例之间,切片因此包含了 helper 的 4 个无 capture 的等待(静态断言 `4 !== 8`,全量套件下 3/3 红、单跑绿)。现改为切到「下一个顶层声明」(`\ntest(` / `\nfunction ` / `\nasync function `)。
655
+ 2. **Task 4 的 500ms settle delay 删除(CR round 1 advisory)**:协议保证 catch-up flush chunk 先于 `snapshot_end` 发出(`src/core/terminal-attach-protocol.mjs:138-150`),客户端在处理 `snapshot_end` 时同步 emit flush,因此观察到 `snapshotReady` 即意味着整个窗口已在 `events` 里;固定睡眠是多余的时序运气。代码与本文档的 Task 4 片段已同步为不睡眠 + 注明该不变量。
656
+
657
+ **commit subject 约定备注(CR round 1 blocking finding 的核实结论)**:本 plan 的 `git commit` 命令给提交主语加 `(issue #<N>)` —— 这是本仓 plan 文档的历史惯例(`docs/superpowers/plans/` 里 `(issue #145)` 等出现 50+ 次),因为**写 plan 时只知道 issue 号、不知道 PR 号**。最终进 changelog 的是 squash 主语,其 `(#<PR>)` 由 GitHub 在 squash 时按 PR 标题自动追加(实证:PR #183 标题无 `(#183)`,而 main 上 `f81949d` 的主语有);`scripts/release_helper.mjs` 读取的是 main 上「上一个 tag..HEAD」的主语(`commitSubjects()`),分支内主语不参与生成,因此不影响 changelog 与 release verify。PR 开出后新增的 commit 可以用 `(#<PR>)`(此时号码已知)。