@zhuxixi/pi-agent-board 0.7.0 → 0.9.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 (70) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +16 -12
  3. package/VERIFY.md +2 -1
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
  5. package/docs/RELEASE.md +148 -0
  6. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
  7. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
  8. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
  9. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
  10. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
  11. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
  12. package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
  13. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
  14. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
  15. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
  16. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
  17. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
  18. package/docs/superpowers/plans/2026-09-23-a5-mid-stream-assertion.md +25 -0
  19. package/docs/superpowers/plans/2026-09-23-cwd-browse-tab-drill.md +393 -0
  20. package/docs/superpowers/plans/2026-09-23-terminal-query-forwarding.md +51 -0
  21. package/docs/superpowers/plans/2026-09-23-unconditional-detach-key.md +58 -0
  22. package/docs/superpowers/plans/2026-09-24-control-reconcile-flake.md +698 -0
  23. package/docs/superpowers/plans/2026-09-24-probe-attached-clients.md +605 -0
  24. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
  25. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
  26. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
  27. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
  28. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
  29. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
  30. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
  31. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
  32. package/docs/superpowers/specs/2026-09-23-a5-mid-stream-assertion-design.md +43 -0
  33. package/docs/superpowers/specs/2026-09-23-cwd-browse-tab-drill-design.md +95 -0
  34. package/docs/superpowers/specs/2026-09-23-terminal-query-forwarding-design.md +63 -0
  35. package/docs/superpowers/specs/2026-09-23-unconditional-detach-key-design.md +57 -0
  36. package/docs/superpowers/specs/2026-09-24-control-reconcile-flake-design.md +98 -0
  37. package/docs/superpowers/specs/2026-09-24-probe-attached-clients-design.md +190 -0
  38. package/package.json +3 -2
  39. package/runner/job-runner.mjs +8 -7
  40. package/runner/pty-runner.mjs +633 -31
  41. package/runner/state-coordinator.mjs +43 -17
  42. package/runner/state-runner.mjs +6 -5
  43. package/scripts/release.mjs +446 -0
  44. package/scripts/run-perf-gate.mjs +40 -0
  45. package/src/commands/agent-board.ts +8 -8
  46. package/src/commands/attach-flow.ts +5 -5
  47. package/src/core/control-protocol.mjs +482 -0
  48. package/src/core/editor-state-reporter.mjs +11 -1
  49. package/src/core/foreground-preview-cache.mjs +117 -0
  50. package/src/core/host-protocol.mjs +62 -0
  51. package/src/core/launch-options.mjs +37 -0
  52. package/src/core/locks.mjs +68 -14
  53. package/src/core/paths.mjs +35 -3
  54. package/src/core/pid.mjs +32 -1
  55. package/src/core/pty-attach-jiggle-controller.mjs +27 -3
  56. package/src/core/pty-attach-reconnect.mjs +13 -6
  57. package/src/core/pty-attach-render.mjs +20 -0
  58. package/src/core/state-commands.mjs +88 -6
  59. package/src/core/status-consistency.mjs +98 -0
  60. package/src/core/store.mjs +59 -13
  61. package/src/core/terminal-attach-client.mjs +809 -0
  62. package/src/core/terminal-attach-protocol.mjs +252 -0
  63. package/src/core/terminal-model.mjs +222 -0
  64. package/src/core/terminal-query-sequences.mjs +132 -0
  65. package/src/core/terminal-snapshot.mjs +440 -0
  66. package/src/index.ts +12 -4
  67. package/src/runtime/service.mjs +284 -16
  68. package/src/ui/dashboard.ts +61 -41
  69. package/src/ui/pty-attach.ts +318 -72
  70. package/src/core/pty-input.mjs +0 -47
@@ -74,8 +74,16 @@ const LAST_ACTIVITY_STAMP_KINDS = new Set(["mark_queued", "archive_view", "adopt
74
74
  * full status content for a run that has no materialized status yet. Every
75
75
  * other kind keeps PR #1's "patch presence ≠ file requirement" semantics
76
76
  * (e.g. mark_completed's `status: {autoState: null}` on a legacy row without
77
- * a status file must not fabricate one). */
78
- const STATUS_BOOTSTRAP_KINDS = new Set(["run_started", "followup_started"]);
77
+ * a status file must not fabricate one).
78
+ *
79
+ * `run_progress` joins them (issue #91 hard-down residual closure): its beat
80
+ * patch IS the runner's full in-memory status, and when the coordinator was
81
+ * down through the runner's boot window (mark_queued applied, run_started
82
+ * lost) the first post-recovery beat is the only surviving source that can
83
+ * recreate the file — without it beats and finalize reject stale_run forever
84
+ * and the row can never converge. The decision layer gates this on a
85
+ * qualified live-run beat (full-shaped patch, matching string runId). */
86
+ const STATUS_BOOTSTRAP_KINDS = new Set(["run_started", "followup_started", "run_progress"]);
79
87
 
80
88
  const root = process.argv[2];
81
89
  if (!root) {
@@ -312,32 +320,50 @@ async function main() {
312
320
  * guards stamp only files strictly behind the record revision: the live path
313
321
  * always qualifies (the global counter is monotonic), while replay repairs
314
322
  * just the half that missed its write in a crash window and never moves the
315
- * other half backwards. A status patch without a status file is skipped —
316
- * patch presence ≠ file requirement (mark_completed always emits
317
- * `status: {autoState: null}`). The status run binding is the decision-time
318
- * one (`statusRunId`, recorded in the journal) so replay cannot re-bind a
319
- * runId-less patch to whatever run is current on disk at replay time;
320
- * `state.currentRunId` remains only as a legacy fallback for records written
321
- * before statusRunId existed.
323
+ * other half backwards.
324
+ *
325
+ * Pairing invariant (issue #91 final review): EVERY applied revision pairs
326
+ * BOTH files. A state-only patch (e.g. patch_fields markVisited) restamps
327
+ * the existing status file's revision with no content change, and a
328
+ * status-only patch restamps the state file the same way — a healthy
329
+ * coordinator can never produce the asymmetric pair the reader-side
330
+ * revision check (reconcile) treats as a crash signature. Journaled
331
+ * state-only records heal on replay through this shared path. Residual:
332
+ * a torn TRANSIENT beat (crash between the two writes, never journaled)
333
+ * stays half-paired — ms-window × adjacent-crash, documented in the spec.
334
+ *
335
+ * A status patch without a status file is skipped — patch presence ≠ file
336
+ * requirement (mark_completed always emits `status: {autoState: null}`).
337
+ * The status run binding is the decision-time one (`statusRunId`, recorded
338
+ * in the journal) so replay cannot re-bind a runId-less patch to whatever
339
+ * run is current on disk at replay time; `state.currentRunId` remains only
340
+ * as a legacy fallback for records written before statusRunId existed.
322
341
  */
323
342
  function materialize(viewId, command, mutate, revision, statusRunId) {
324
343
  withViewLockSync(root, viewId, "state-materialize", () => {
325
344
  const state = readState(root, viewId);
326
- if (mutate?.state && state && (state.materializedRevision ?? 0) < revision) {
327
- writeState(root, { ...state, ...mutate.state, materializedRevision: revision });
345
+ if (state && (state.materializedRevision ?? 0) < revision) {
346
+ writeState(root, mutate?.state
347
+ ? { ...state, ...mutate.state, materializedRevision: revision }
348
+ : { ...state, materializedRevision: revision });
328
349
  }
329
350
  const runId = command?.runId ?? statusRunId ?? state?.currentRunId ?? null;
330
- if (mutate?.status && runId) {
331
- const status = readStatus(root, viewId, runId);
351
+ if (!runId) return;
352
+ const status = readStatus(root, viewId, runId);
353
+ if (mutate?.status) {
332
354
  if (!status && STATUS_BOOTSTRAP_KINDS.has(command?.kind)) {
333
- // run_started / followup_started carry the full status content for a
334
- // run that has no status file yet — create it (F1's clean-bootstrap
335
- // half). Identity fields come from the command context; the patch
336
- // carries everything meaningful.
355
+ // run_started / followup_started / (bootstrapping) run_progress
356
+ // carry the full status content for a run that has no status
357
+ // file yet — create it. Identity fields come from the command
358
+ // context; the patch carries everything meaningful.
337
359
  writeStatus(root, { version: 1, runId, viewId, ...mutate.status, materializedRevision: revision });
338
360
  } else if (status && (status.materializedRevision ?? 0) < revision) {
339
361
  writeStatus(root, { ...status, ...mutate.status, materializedRevision: revision });
340
362
  }
363
+ } else if (status && (status.materializedRevision ?? 0) < revision) {
364
+ // State-only patch: restamp the existing status file's revision with
365
+ // no content change (pairing invariant above).
366
+ writeStatus(root, { ...status, materializedRevision: revision });
341
367
  }
342
368
  });
343
369
  }
@@ -14,6 +14,7 @@ import { finalizeEvidence, readEvidence, summarizeEvidence, writeEvidence } from
14
14
  import { updateCodeRefsFromEvidence } from "../src/core/code-refs-store.mjs";
15
15
  import { readState, readStatus, readMeta, writeState, writeStatus } from "../src/core/store.mjs";
16
16
  import { sendStateCommand } from "../src/core/coordinator-client.mjs";
17
+ import { commandRejectDiagnostic } from "../src/core/state-commands.mjs";
17
18
 
18
19
  async function main() {
19
20
  const configPath = process.argv[2];
@@ -69,12 +70,12 @@ async function main() {
69
70
 
70
71
  if (result.status === "applied") {
71
72
  appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, code: "auto_state_classified", message: "Auto-state classifier updated row state", details: { kind: classification.kind, confidence: classification.confidence, source: classification.source, reason: classification.reason } });
72
- } else if (result.reason === "manual_fence" || result.reason === "no_change" || result.reason === "stale_run") {
73
- // Designed fences — informational, not errors: the coordinator is the
74
- // authority and a manual completion wins by design.
75
- appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, code: "auto_state_classified_skipped", message: `Auto-state classification not applied (${result.reason})`, details: { reason: result.reason } });
76
73
  } else if (result.reason !== "coordinator_disabled") {
77
- appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, level: "warn", code: "auto_state_command_ambiguous", message: `Auto-state classification outcome unknown (${result.reason}); if the command was journaled, coordinator replay will recover it; otherwise the next classification pass will converge the row`, details: { reason: result.reason } });
74
+ // Unified decided/ambiguous classification (CR r2 issue-3): decided
75
+ // rejects (manual_fence/stale_run/…) log as info *_skipped — the
76
+ // coordinator's verdict is authoritative; only genuinely ambiguous
77
+ // outcomes (timeout/connection_reset) keep the recovery-path warn.
78
+ appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, ...commandRejectDiagnostic("auto_state", "Auto-state classification", result.reason, "otherwise the next classification pass will converge the row"), details: { reason: result.reason } });
78
79
  }
79
80
 
80
81
  if (result.reason === "coordinator_disabled") {
@@ -0,0 +1,446 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Release orchestrator — the single entry point for a release, from a clean
4
+ * `main` to a published npm version.
5
+ *
6
+ * Flow (prose version: docs/RELEASE.md):
7
+ *
8
+ * precheck → prepare <bump> → open-pr <version>
9
+ * → merge the PR with a MERGE COMMIT
10
+ * → finish <version> (creates the GitHub Release)
11
+ * → .github/workflows/publish.yml publishes to npm with provenance
12
+ *
13
+ * Two ordering rules are baked in, because breaking either one ships a version
14
+ * that `main` does not contain, or skips a version's notes:
15
+ *
16
+ * 1. The CHANGELOG section is generated BEFORE `npm version`, because
17
+ * `npm version` commits and tags the bump and would empty the commit range
18
+ * the section is rendered from.
19
+ * 2. The GitHub Release is created AFTER the release PR is merged, and only
20
+ * once the tag is an ancestor of `origin/main`. A squash-merged release PR
21
+ * leaves the tag orphaned; `finish` refuses to continue in that case
22
+ * instead of publishing a version whose bump commit never reached main.
23
+ *
24
+ * Modes:
25
+ *
26
+ * node scripts/release.mjs precheck
27
+ * Read-only gate: on main, clean tree, in sync with origin, no open
28
+ * release/* PR, last tag reachable from origin/main, and at least one
29
+ * functional commit to release.
30
+ * node scripts/release.mjs prepare <patch|minor|major|X.Y.Z> [--dry-run]
31
+ * Runs the gate, renders the CHANGELOG section from conventional commits,
32
+ * commits it, runs `npm version` (bump commit + tag), and switches to
33
+ * `release/vX.Y.Z`. Nothing leaves the machine.
34
+ * node scripts/release.mjs open-pr <version> [--dry-run]
35
+ * Pushes the release branch and its tag, then opens the release PR.
36
+ * node scripts/release.mjs finish <version> [--dry-run] [--sync-main]
37
+ * After the PR is merged: checks tag ancestry and CHANGELOG sync, then
38
+ * creates the GitHub Release — the CI publish trigger. `--sync-main`
39
+ * additionally resets a clean local main to origin/main.
40
+ * node scripts/release.mjs notes <version>
41
+ * Prints the CHANGELOG section for a version (the GitHub Release notes).
42
+ *
43
+ * Every mode is fail-closed: a failed check stops the run with exit code 1 and
44
+ * a JSON body naming the failure. All modes print JSON except `notes`, which
45
+ * prints the section itself so it can be redirected to a file.
46
+ *
47
+ * Changelog rendering is delegated to the pure, unit-tested functions in
48
+ * release_helper.mjs; the git/npm/gh side effects live here and nowhere else.
49
+ */
50
+ import { execFileSync } from "node:child_process";
51
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
52
+ import { tmpdir } from "node:os";
53
+ import { dirname, join } from "node:path";
54
+ import { fileURLToPath } from "node:url";
55
+ import {
56
+ functionalLines,
57
+ generateChangelog,
58
+ hasSection,
59
+ insertSection,
60
+ nextVersion,
61
+ parseCommitLines,
62
+ verifyFrom,
63
+ } from "./release_helper.mjs";
64
+
65
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
66
+ const CHANGELOG_MD = join(REPO_ROOT, "CHANGELOG.md");
67
+ const PACKAGE_JSON = join(REPO_ROOT, "package.json");
68
+ const MAIN_BRANCH = "main";
69
+ const PACKAGE_NAME = "@zhuxixi/pi-agent-board";
70
+ const COMMANDS = new Set(["precheck", "prepare", "open-pr", "finish", "notes"]);
71
+ const FLAGS = new Set(["--dry-run", "--sync-main"]);
72
+
73
+ /* ------------------------------------------------------------------ *
74
+ * Pure helpers (unit-tested in test/release-flow.test.mjs)
75
+ * ------------------------------------------------------------------ */
76
+
77
+ /** Branch a version is prepared on: `release/v0.9.0`. */
78
+ export function releaseBranchName(version) {
79
+ return `release/v${version}`;
80
+ }
81
+
82
+ /**
83
+ * The `## [version]` section of a CHANGELOG, without the trailing link
84
+ * reference definition — i.e. the body used as GitHub Release notes.
85
+ * @param {string} changelogText
86
+ * @param {string} version
87
+ * @returns {string|null} null when the section is absent
88
+ */
89
+ export function extractReleaseSection(changelogText, version) {
90
+ const text = String(changelogText ?? "");
91
+ const escaped = String(version).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
92
+ const heading = new RegExp(`^##\\s*\\[${escaped}]`, "m");
93
+ const match = heading.exec(text);
94
+ if (!match) return null;
95
+ const rest = text.slice(match.index);
96
+ // The next `## [` heading ends the section; +1 because the search runs on a
97
+ // sliced string, so its index is relative to rest[1].
98
+ const nextHeading = rest.slice(1).search(/^##\s*\[/m);
99
+ const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading + 1);
100
+ return section
101
+ .replace(/^\[[^\]]+]:\s+\S+\s*$/gm, "")
102
+ .replace(/\s+$/, "")
103
+ .replace(/^\n+/, "");
104
+ }
105
+
106
+ /**
107
+ * Decide whether a release may start, from a collected snapshot of repo state.
108
+ * Pure so the rules can be tested without a repository.
109
+ * @param {{branch: string, clean: boolean, ahead: number, behind: number, openReleasePrs: number|null, lastTag: string|null, lastTagReachable: boolean, functionalCommits: number}} state
110
+ * @returns {{ok: boolean, failures: string[]}}
111
+ */
112
+ export function evaluatePreconditions(state) {
113
+ const failures = [];
114
+ if (state.branch !== MAIN_BRANCH) {
115
+ failures.push(`must run on ${MAIN_BRANCH} (currently on "${state.branch || "detached HEAD"}")`);
116
+ }
117
+ if (!state.clean) failures.push("working tree is dirty — commit or stash first");
118
+ if (state.behind > 0) failures.push(`local ${MAIN_BRANCH} is behind origin/${MAIN_BRANCH} by ${state.behind} commit(s) — pull first`);
119
+ if (state.ahead > 0) failures.push(`local ${MAIN_BRANCH} is ahead of origin/${MAIN_BRANCH} by ${state.ahead} commit(s)`);
120
+ if ((state.openReleasePrs ?? 0) > 0) failures.push(`${state.openReleasePrs} open release/* PR(s) — finish or close them first`);
121
+ if (state.lastTag && !state.lastTagReachable) {
122
+ failures.push(`tag ${state.lastTag} is not an ancestor of origin/${MAIN_BRANCH} (orphan tag) — re-point it before releasing`);
123
+ }
124
+ if (state.functionalCommits === 0) failures.push("no functional commits since the last tag — nothing to release");
125
+ return { ok: failures.length === 0, failures };
126
+ }
127
+
128
+ /**
129
+ * Parse the CLI argv into a command, its positional argument, and flags.
130
+ * @param {string[]} argv
131
+ * @returns {{command: string, arg: string|undefined, dryRun: boolean, syncMain: boolean}}
132
+ */
133
+ export function parseArgs(argv) {
134
+ const flags = [];
135
+ const positional = [];
136
+ for (const raw of argv) {
137
+ if (raw.startsWith("--")) flags.push(raw);
138
+ else positional.push(raw);
139
+ }
140
+ const [command, arg] = positional;
141
+ if (!COMMANDS.has(command ?? "")) {
142
+ throw new Error(`unknown command "${command ?? ""}" — expected one of ${[...COMMANDS].join(", ")}`);
143
+ }
144
+ for (const flag of flags) {
145
+ if (!FLAGS.has(flag)) throw new Error(`unknown flag "${flag}" — expected ${[...FLAGS].join(", ")}`);
146
+ }
147
+ return { command, arg, dryRun: flags.includes("--dry-run"), syncMain: flags.includes("--sync-main") };
148
+ }
149
+
150
+ /* ------------------------------------------------------------------ *
151
+ * Repo plumbing
152
+ * ------------------------------------------------------------------ */
153
+
154
+ /**
155
+ * Run a command, capturing both streams. stderr is folded into the thrown error
156
+ * so a failure names the command and what it said, while a successful run stays
157
+ * quiet (git's "Switched to a new branch" chatter included).
158
+ * @param {string} cmd
159
+ * @param {string[]} args
160
+ * @returns {string} stdout
161
+ */
162
+ function run(cmd, args) {
163
+ try {
164
+ return execFileSync(cmd, args, { cwd: REPO_ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
165
+ } catch (err) {
166
+ const stderr = String(err?.stderr ?? "").trim();
167
+ const detail = stderr ? `: ${stderr}` : "";
168
+ throw new Error(`${cmd} ${args.join(" ")} failed${detail}`);
169
+ }
170
+ }
171
+
172
+ /** @param {string[]} args @returns {string} git stdout */
173
+ function git(args) {
174
+ return run("git", args);
175
+ }
176
+
177
+ /** @param {string[]} args @returns {boolean} true when the command succeeded */
178
+ function gitOk(args) {
179
+ try {
180
+ git(args);
181
+ return true;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ /** @param {string[]} args @returns {string} npm stdout */
188
+ function npm(args) {
189
+ return run("npm", args);
190
+ }
191
+
192
+ /** @param {string[]} args @returns {string} gh stdout */
193
+ function gh(args) {
194
+ return run("gh", args);
195
+ }
196
+
197
+ /** @returns {string} version from package.json */
198
+ function packageVersion() {
199
+ const version = JSON.parse(readFileSync(PACKAGE_JSON, "utf8")).version;
200
+ if (!/^\d+\.\d+\.\d+$/.test(version ?? "")) throw new Error(`package.json has no semver version: ${version}`);
201
+ return version;
202
+ }
203
+
204
+ /** @returns {string|null} newest reachable `v*` tag */
205
+ function latestTag() {
206
+ try {
207
+ const tag = git(["describe", "--tags", "--abbrev=0", "--match", "v*"]).trim();
208
+ return tag || null;
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ /** @param {string} range @returns {string[]} commit subjects in the range */
215
+ function subjects(range) {
216
+ return git(["log", range, "--format=%s"])
217
+ .split("\n")
218
+ .map((s) => s.trim())
219
+ .filter(Boolean);
220
+ }
221
+
222
+ /** @returns {boolean} whether the tag exists locally */
223
+ function tagExists(tag) {
224
+ return gitOk(["rev-parse", "--verify", "--quiet", `refs/tags/${tag}`]);
225
+ }
226
+
227
+ /** @param {string} ref @returns {boolean} whether ref exists locally */
228
+ function refExists(ref) {
229
+ return gitOk(["rev-parse", "--verify", "--quiet", ref]);
230
+ }
231
+
232
+ /** @param {string} tag @param {string} ref @returns {boolean} */
233
+ function isAncestor(tag, ref) {
234
+ return gitOk(["merge-base", "--is-ancestor", tag, ref]);
235
+ }
236
+
237
+ /**
238
+ * Count open `release/*` PRs. null means the check could not run (no `gh`, no
239
+ * auth, or not a GitHub remote) — reported but never a hard failure, so a
240
+ * missing `gh` cannot block a release that does not need it.
241
+ * @returns {number|null}
242
+ */
243
+ function openReleasePrCount() {
244
+ try {
245
+ const json = gh(["pr", "list", "--state", "open", "--limit", "50", "--json", "headRefName"]);
246
+ return JSON.parse(json).filter((pr) => String(pr.headRefName ?? "").startsWith("release/")).length;
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+
252
+ /** @returns {object} the state consumed by evaluatePreconditions */
253
+ function collectState() {
254
+ git(["fetch", "origin", "--quiet"]);
255
+ const [behind, ahead] = git(["rev-list", "--left-right", "--count", `origin/${MAIN_BRANCH}...HEAD`])
256
+ .trim()
257
+ .split(/\s+/)
258
+ .map(Number);
259
+ const lastTag = latestTag();
260
+ const functionalCommits = functionalLines(subjects(lastTag ? `${lastTag}..HEAD` : "HEAD")).length;
261
+ return {
262
+ branch: git(["branch", "--show-current"]).trim(),
263
+ clean: git(["status", "--porcelain"]).trim() === "",
264
+ ahead,
265
+ behind,
266
+ openReleasePrs: openReleasePrCount(),
267
+ lastTag,
268
+ lastTagReachable: lastTag ? isAncestor(lastTag, `origin/${MAIN_BRANCH}`) : true,
269
+ functionalCommits,
270
+ };
271
+ }
272
+
273
+ /** Throw unless the release may start; returns the collected state. */
274
+ function assertPreconditions() {
275
+ const state = collectState();
276
+ const { ok, failures } = evaluatePreconditions(state);
277
+ if (!ok) throw new Error(`precheck failed:\n- ${failures.join("\n- ")}`);
278
+ return state;
279
+ }
280
+
281
+ /* ------------------------------------------------------------------ *
282
+ * Commands
283
+ * ------------------------------------------------------------------ */
284
+
285
+ /** @returns {object} precheck report (never throws on a failed gate) */
286
+ function cmdPrecheck() {
287
+ const state = collectState();
288
+ const { ok, failures } = evaluatePreconditions(state);
289
+ return { ok, failures, ...state, prCheck: state.openReleasePrs === null ? "unavailable (gh not authenticated or not a GitHub remote)" : "ok" };
290
+ }
291
+
292
+ /**
293
+ * @param {string|undefined} bump patch|minor|major|X.Y.Z
294
+ * @param {boolean} dryRun
295
+ * @returns {object}
296
+ */
297
+ function cmdPrepare(bump, dryRun) {
298
+ const state = assertPreconditions();
299
+ const version = nextVersion(bump ?? "patch", packageVersion());
300
+ const branch = releaseBranchName(version);
301
+ const existing = readFileSync(CHANGELOG_MD, "utf8");
302
+ if (hasSection(existing, version)) {
303
+ throw new Error(`CHANGELOG.md already has a [${version}] section — remove the stale section before re-applying`);
304
+ }
305
+ const range = state.lastTag ? `${state.lastTag}..HEAD` : "HEAD";
306
+ const entries = parseCommitLines(subjects(range));
307
+ const section = generateChangelog({
308
+ version,
309
+ date: new Date().toISOString().slice(0, 10),
310
+ entries,
311
+ prevTag: state.lastTag,
312
+ });
313
+ const plan = [
314
+ `git add CHANGELOG.md && git commit -m "docs(changelog): add ${version} section"`,
315
+ `npm version ${bump ?? "patch"}`,
316
+ `git switch -c ${branch}`,
317
+ ];
318
+ const next = `node scripts/release.mjs open-pr ${version}`;
319
+ if (dryRun) {
320
+ return { ok: true, dry_run: true, current_version: packageVersion(), new_version: version, last_tag: state.lastTag, changelog_preview: section, plan, next };
321
+ }
322
+ writeFileSync(CHANGELOG_MD, insertSection(existing, section));
323
+ git(["add", "CHANGELOG.md"]);
324
+ git(["commit", "-m", `docs(changelog): add ${version} section`]);
325
+ npm(["version", bump ?? "patch"]);
326
+ git(["switch", "-c", branch]);
327
+ return { ok: true, version, branch, tag: `v${version}`, next };
328
+ }
329
+
330
+ /**
331
+ * @param {string|undefined} version
332
+ * @param {boolean} dryRun
333
+ * @returns {object}
334
+ */
335
+ function cmdOpenPr(version, dryRun) {
336
+ if (!version) throw new Error("open-pr requires a version (node scripts/release.mjs open-pr 0.9.0)");
337
+ const branch = releaseBranchName(version);
338
+ const tag = `v${version}`;
339
+ const current = git(["branch", "--show-current"]).trim();
340
+ if (packageVersion() !== version) throw new Error(`package.json is at ${packageVersion()}, expected ${version}`);
341
+ if (current !== branch) throw new Error(`expected to be on ${branch} (currently on "${current || "detached HEAD"}")`);
342
+ if (!tagExists(tag)) throw new Error(`tag ${tag} does not exist — run prepare first`);
343
+ const notes = extractReleaseSection(readFileSync(CHANGELOG_MD, "utf8"), version);
344
+ if (!notes) throw new Error(`CHANGELOG.md has no [${version}] section`);
345
+ const body = `${notes}\n\n---\n\nMerge with a **merge commit** — squashing would orphan the \`${tag}\` tag and break the changelog range for the next release.`;
346
+ const commands = [
347
+ `git push -u origin ${branch}`,
348
+ `git push origin ${tag}`,
349
+ `gh pr create --base ${MAIN_BRANCH} --head ${branch} --title "chore: release ${version}"`,
350
+ ];
351
+ if (dryRun) return { ok: true, dry_run: true, version, branch, tag, commands };
352
+ git(["push", "-u", "origin", branch]);
353
+ git(["push", "origin", tag]);
354
+ const url = gh(["pr", "create", "--base", MAIN_BRANCH, "--head", branch, "--title", `chore: release ${version}`, "--body", body]).trim();
355
+ return { ok: true, version, branch, tag, pr: url, next: `merge with a merge commit, then: node scripts/release.mjs finish ${version} --sync-main` };
356
+ }
357
+
358
+ /**
359
+ * @param {string|undefined} version
360
+ * @param {{dryRun: boolean, syncMain: boolean}} opts
361
+ * @returns {object}
362
+ */
363
+ function cmdFinish(version, { dryRun, syncMain }) {
364
+ if (!version) throw new Error("finish requires a version (node scripts/release.mjs finish 0.9.0)");
365
+ const tag = `v${version}`;
366
+ if (packageVersion() !== version) throw new Error(`package.json is at ${packageVersion()}, expected ${version}`);
367
+ git(["fetch", "origin", "--quiet"]);
368
+ if (!tagExists(tag)) throw new Error(`tag ${tag} is missing — push it with open-pr first`);
369
+ if (!isAncestor(tag, `origin/${MAIN_BRANCH}`)) {
370
+ throw new Error(
371
+ `tag ${tag} is not an ancestor of origin/${MAIN_BRANCH} — the release PR was probably squash-merged. ` +
372
+ `Point the tag at the content-equivalent commit on main (git tag -f ${tag} <sha> && git push origin ${tag} --force) and retry`,
373
+ );
374
+ }
375
+ const changelogText = readFileSync(CHANGELOG_MD, "utf8");
376
+ const drift = verifyFrom({ lines: subjects(`${tag}..origin/${MAIN_BRANCH}`), changelogText });
377
+ if (!drift.ok) {
378
+ throw new Error(
379
+ `CHANGELOG is missing functional PR(s) ${drift.missing.join(", ")} merged after the bump — ` +
380
+ `add them to the ${version} section in a docs(changelog) PR, then retry`,
381
+ );
382
+ }
383
+ const notes = extractReleaseSection(changelogText, version);
384
+ if (!notes) throw new Error(`CHANGELOG.md has no [${version}] section`);
385
+ if (dryRun) {
386
+ return { ok: true, dry_run: true, tag, notes, commands: [`gh release create ${tag} --title ${tag} --notes-file <notes>`], syncMain };
387
+ }
388
+ const dir = mkdtempSync(join(tmpdir(), "release-notes-"));
389
+ const notesFile = join(dir, `${tag}.md`);
390
+ try {
391
+ writeFileSync(notesFile, `${notes}\n`);
392
+ gh(["release", "create", tag, "--title", tag, "--notes-file", notesFile]);
393
+ } finally {
394
+ rmSync(dir, { recursive: true, force: true });
395
+ }
396
+ let syncedMain = false;
397
+ if (syncMain && git(["status", "--porcelain"]).trim() === "") {
398
+ git(["switch", MAIN_BRANCH]);
399
+ git(["reset", "--hard", `origin/${MAIN_BRANCH}`]);
400
+ syncedMain = true;
401
+ }
402
+ return {
403
+ ok: true,
404
+ version,
405
+ tag,
406
+ release: `https://github.com/zhuxixi/pi-agent-board/releases/tag/${tag}`,
407
+ package: `${PACKAGE_NAME}@${version}`,
408
+ synced_main: syncedMain,
409
+ publish: "CI publishes on release — watch it with: gh run list --workflow=publish.yml --limit 1",
410
+ };
411
+ }
412
+
413
+ /**
414
+ * @param {string|undefined} version
415
+ * @returns {string} the release notes text
416
+ */
417
+ function cmdNotes(version) {
418
+ if (!version) throw new Error("notes requires a version (node scripts/release.mjs notes 0.9.0)");
419
+ const notes = extractReleaseSection(readFileSync(CHANGELOG_MD, "utf8"), version);
420
+ if (!notes) throw new Error(`CHANGELOG.md has no [${version}] section`);
421
+ return notes;
422
+ }
423
+
424
+ /* ------------------------------------------------------------------ *
425
+ * CLI
426
+ * ------------------------------------------------------------------ */
427
+
428
+ function main(argv) {
429
+ try {
430
+ const { command, arg, dryRun, syncMain } = parseArgs(argv);
431
+ if (command === "notes") {
432
+ process.stdout.write(`${cmdNotes(arg)}\n`);
433
+ return;
434
+ }
435
+ const result = command === "precheck" ? cmdPrecheck() : command === "prepare" ? cmdPrepare(arg, dryRun) : command === "open-pr" ? cmdOpenPr(arg, dryRun) : cmdFinish(arg, { dryRun, syncMain });
436
+ console.log(JSON.stringify(result, null, 1));
437
+ if (result.ok === false) process.exitCode = 1;
438
+ } catch (err) {
439
+ console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));
440
+ process.exitCode = 1;
441
+ }
442
+ }
443
+
444
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
445
+ main(process.argv.slice(2));
446
+ }
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Authoritative entry for the A11 perf gate (issue #121).
4
+ *
5
+ * Loudly refuses under coverage instrumentation: c8 sets NODE_V8_COVERAGE
6
+ * for its whole process tree, and instrumented latency measurements are
7
+ * invalid (2.5–6× inflation). Silently skipping here would manufacture a
8
+ * fake-green gate, so refusal is a nonzero exit with an explicit message.
9
+ *
10
+ * This script is the side-effect layer only — no decision logic, no
11
+ * measurement. The pure gate decision lives in test-support/perf-gate.mjs
12
+ * and serves the test file; do not merge the layers (spec §3.2/§4).
13
+ */
14
+ import { spawn } from "node:child_process";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ if (process.env.NODE_V8_COVERAGE !== undefined) {
18
+ console.error(
19
+ "run-perf-gate: refusing to measure under coverage instrumentation " +
20
+ "(NODE_V8_COVERAGE is set). Perf assertions are only valid without c8; " +
21
+ "run `npm run test:perf` directly, outside any coverage wrapper.",
22
+ );
23
+ process.exit(1);
24
+ }
25
+
26
+ const perfTest = fileURLToPath(new URL("../test/terminal-model-perf.test.mjs", import.meta.url));
27
+ // No --test-concurrency=1: the flag requires Node 21+ but package.json
28
+ // declares engines >=20, and it is a no-op while the gate runs a single
29
+ // file (spec D3, revised 2026-09-20). Re-add it when a second perf file
30
+ // lands — parallel measurement across files must stay impossible.
31
+ const child = spawn(
32
+ process.execPath,
33
+ ["--test", perfTest],
34
+ { stdio: "inherit", env: { ...process.env, AGENT_BOARD_PERF_GATE: "1" } },
35
+ );
36
+ child.on("error", (err) => {
37
+ console.error(`run-perf-gate: failed to spawn the perf suite: ${err.message}`);
38
+ process.exit(1);
39
+ });
40
+ child.on("exit", (code) => process.exit(code ?? 1));
@@ -106,7 +106,7 @@ export async function openDashboard(
106
106
  currentThinkingLevel: options.currentThinkingLevel ?? "off",
107
107
  });
108
108
  interval = setInterval(() => {
109
- service.reconcile();
109
+ void service.reconcile().catch(() => {});
110
110
  comp.refresh();
111
111
  requestDashboardRender(tui);
112
112
  }, POLL_MS);
@@ -146,8 +146,8 @@ export async function dashboardAttachLoop(
146
146
  let again = true;
147
147
  while (again) {
148
148
  const currentId = currentViewId(ctx, service);
149
- if (currentId) service.markVisited?.(currentId);
150
- service.reconcile();
149
+ if (currentId) void service.markVisited?.(currentId)?.catch(() => {});
150
+ void service.reconcile().catch(() => {});
151
151
  const result = await openDashboard(ctx, service, {
152
152
  initialSelectedId: selectedId,
153
153
  currentThinkingLevel: getThinkingLevel?.(),
@@ -185,9 +185,9 @@ async function attach(
185
185
  // Single async resolver entry: real probe + bounded recovery decide readiness (issue #70).
186
186
  const plan = planAttachResolved(await service.resolveAttachTarget(viewId));
187
187
  if (plan.plan === "open-pty") {
188
- service.markVisited?.(viewId);
188
+ void service.markVisited?.(viewId)?.catch(() => {});
189
189
  const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name, plan.socketPath);
190
- service.markVisited?.(viewId);
190
+ void service.markVisited?.(viewId)?.catch(() => {});
191
191
  return { action: result.action === "closed" ? "closed" : "detached" };
192
192
  }
193
193
  if (plan.plan === "session-switch") {
@@ -197,7 +197,7 @@ async function attach(
197
197
  return { action: "none" };
198
198
  }
199
199
  const name = latest.meta.name;
200
- service.markVisited?.(viewId);
200
+ void service.markVisited?.(viewId)?.catch(() => {});
201
201
  const switchingOverlay = await showSwitchingOverlay(ctx, name, "PTY unavailable");
202
202
  const result = await ctx.switchSession(latest.meta.sessionFile, {
203
203
  withSession: async (replaced) => {
@@ -296,8 +296,8 @@ function installBackToDashboard(ctx: ExtensionCommandContext, service: ReturnTyp
296
296
  try {
297
297
  let selectedId = currentViewId(ctx, service);
298
298
  while (true) {
299
- if (selectedId) service.markVisited?.(selectedId);
300
- service.reconcile();
299
+ if (selectedId) void service.markVisited?.(selectedId)?.catch(() => {});
300
+ void service.reconcile().catch(() => {});
301
301
  const result = await openDashboard(ctx, service, { initialSelectedId: selectedId });
302
302
  if (result.action !== "attach") return;
303
303
  selectedId = result.viewId;