@event4u/agent-config 14.2.0 → 14.4.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 (116) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +286 -218
  3. package/agents/templates/.ai-council.yml.example +35 -0
  4. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
  5. package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
  6. package/dist/agent-src/scripts/resume_probe.ts +154 -9
  7. package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
  8. package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
  9. package/dist/agent-src/templates/agent-settings.md +70 -0
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  12. package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  13. package/dist/cli/python/workspace_drive.js +8 -3
  14. package/dist/cli/python/workspace_drive.js.map +1 -1
  15. package/dist/cli/python/workspace_hosts.js +16 -12
  16. package/dist/cli/python/workspace_hosts.js.map +1 -1
  17. package/dist/cli/registry.js +2 -0
  18. package/dist/cli/registry.js.map +1 -1
  19. package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
  20. package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
  21. package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
  22. package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
  23. package/dist/cli-delegate/cmd_conformance.js +26 -11
  24. package/dist/cli-delegate/cmd_doctor.js +2 -2
  25. package/dist/cli-delegate/cmd_handoff.js +3 -3
  26. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  27. package/dist/cli-delegate/cmd_settings_set.js +17 -3
  28. package/dist/discovery/deprecation-report.md +1 -1
  29. package/dist/discovery/discovery-manifest.json +4 -4
  30. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  31. package/dist/discovery/discovery-manifest.summary.md +1 -1
  32. package/dist/discovery/dormancy-report.md +1 -1
  33. package/dist/discovery/orphan-report.md +1 -1
  34. package/dist/discovery/packs.json +2 -2
  35. package/dist/discovery/trust-report.md +1 -1
  36. package/dist/discovery/workspaces.json +2 -2
  37. package/dist/hooks/dispatch.js +3684 -1719
  38. package/dist/mcp/registry-manifest.json +2 -2
  39. package/dist/mcp/server.json +2 -2
  40. package/dist/mcp/server.mjs +1070 -408
  41. package/dist/scripts/_lib/host_launch.js +66 -0
  42. package/dist/scripts/_lib/host_launch.js.map +1 -0
  43. package/dist/server/io/yamlIO.js +175 -3
  44. package/dist/server/io/yamlIO.js.map +1 -1
  45. package/dist/server/routes/wizard.js +20 -1
  46. package/dist/server/routes/wizard.js.map +1 -1
  47. package/dist/shared/settingsConsent.js +25 -0
  48. package/dist/shared/settingsConsent.js.map +1 -1
  49. package/docs/contracts/settings-classes.md +11 -0
  50. package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
  51. package/package.json +1 -1
  52. package/src/agent-src/scripts/resume_probe.ts +154 -9
  53. package/src/agent-src/scripts/roadmap_gates.ts +210 -6
  54. package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  55. package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  56. package/src/config/estate-count-budget.json +120 -0
  57. package/src/config/evaluator-budgets.json +4 -4
  58. package/src/config/gate-coverage.yml +32 -4
  59. package/src/config/gate-violation-baselines.json +63 -63
  60. package/src/config/gitignore-block.txt +5 -0
  61. package/src/config/hook-latency-budget.json +40 -1
  62. package/src/config/hook-token-budget.json +16 -0
  63. package/src/config/rule-stub-ceilings.json +11 -3
  64. package/src/scripts/_dispatch.bash +42 -1
  65. package/src/scripts/_lib/council_fallback_posture.ts +81 -0
  66. package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
  67. package/src/scripts/_lib/council_settings_block.ts +152 -0
  68. package/src/scripts/_lib/headless_invocation.ts +285 -0
  69. package/src/scripts/_lib/host_launch.ts +72 -0
  70. package/src/scripts/_lib/map_to_object.ts +16 -0
  71. package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
  72. package/src/scripts/_lib/run_checkpoint.ts +409 -0
  73. package/src/scripts/_lib/session_register.ts +32 -1
  74. package/src/scripts/_lib/stdin.ts +35 -1
  75. package/src/scripts/_lib/turn_end_refusals.ts +12 -4
  76. package/src/scripts/_lib/unattended_guard.ts +371 -0
  77. package/src/scripts/ai_council/config.ts +27 -1
  78. package/src/scripts/ai_council/events_log.ts +12 -1
  79. package/src/scripts/ai_council/fallback_config.ts +114 -0
  80. package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
  81. package/src/scripts/ai_council/orchestrator.ts +251 -292
  82. package/src/scripts/ai_council/py_parity.ts +139 -0
  83. package/src/scripts/ai_council/response_render.ts +99 -0
  84. package/src/scripts/ai_council/spend_gate.ts +123 -0
  85. package/src/scripts/ai_council/transport_resolver.ts +81 -13
  86. package/src/scripts/bench_hook_injection.ts +100 -6
  87. package/src/scripts/bench_hook_latency.ts +442 -16
  88. package/src/scripts/check_estate_count.ts +924 -0
  89. package/src/scripts/compile_hook_manifest.ts +67 -0
  90. package/src/scripts/council_cli.ts +100 -125
  91. package/src/scripts/decision_memo.ts +325 -0
  92. package/src/scripts/hook_manifest.json +1 -0
  93. package/src/scripts/hook_manifest.yaml +172 -25
  94. package/src/scripts/hooks/concern_registry.ts +6 -0
  95. package/src/scripts/hooks/dispatch_hook.ts +183 -10
  96. package/src/scripts/hooks/dispatch_issues.ts +26 -0
  97. package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
  98. package/src/scripts/hooks/hook_stdin.ts +111 -5
  99. package/src/scripts/hooks/injection_budget.ts +595 -0
  100. package/src/scripts/hooks/payload_stub.ts +446 -0
  101. package/src/scripts/hooks/run_continuation_hook.ts +627 -0
  102. package/src/scripts/hooks/session_eol_hook.ts +52 -0
  103. package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
  104. package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
  105. package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
  106. package/src/scripts/hooks_doctor.ts +16 -2
  107. package/src/scripts/interruption_report.ts +330 -13
  108. package/src/scripts/lint_hook_manifest.ts +167 -0
  109. package/src/scripts/roadmap_progress_hook.ts +254 -46
  110. package/src/scripts/run_supervise.ts +623 -0
  111. package/src/scripts/session_register_hook.ts +108 -8
  112. package/src/scripts/sessions_cli.ts +18 -6
  113. package/src/scripts/telemetry_disclosure_hook.ts +244 -0
  114. package/src/server/io/yamlIO.ts +167 -3
  115. package/src/shared/settingsConsent.ts +49 -2
  116. package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The checkbox vocabulary and the phase-span rule, in one place.
3
+ *
4
+ * R2 review, findings 11 and 15. Two scanners on the autonomous-run path —
5
+ * `run_checkpoint.countRoadmap` and `run_continuation_hook.scanOpenSteps` —
6
+ * each carried their own narrower regex and each scanned the WHOLE file, while
7
+ * both docblocks claimed the dashboard's vocabulary. Two consequences, and
8
+ * they pull in opposite directions:
9
+ *
10
+ * - NARROWER VOCABULARY under-counts. Only `-` bullets and a lowercase `x`
11
+ * matched, so a roadmap authored with `*` bullets or `[X]` read
12
+ * `open === 0` and `run_supervise.classify` reported disposition
13
+ * `complete` — "the run finished, the session just never said so" — for a
14
+ * run with real open work. That is the exact confusion the watcher exists
15
+ * to remove.
16
+ * - WHOLE-FILE SCANNING over-counts. The dashboard attributes checkboxes to
17
+ * `## Phase` spans; these did not. On the very roadmap under review every
18
+ * phase step is closed and the only `- [ ]` sits under `## Acceptance criteria`, so
19
+ * the continuation would have named "A killed session resumes via the
20
+ * watcher…" as the next step — an observation of a live multi-day run, not
21
+ * an executable step, and by construction with no `verify:` line. Steps
22
+ * under `## Blockers` and `## Risk Register` are the same shape.
23
+ *
24
+ * The vocabulary is deliberately the dashboard's, character for character:
25
+ * where an autonomous run and the dashboard disagree about whether a roadmap
26
+ * is finished, the disagreement is the defect.
27
+ */
28
+
29
+ /** Bullet `-` or `*`, mark ` `, `x`, `X`, `~` or `-`; body captured. */
30
+ export const CHECKBOX_LINE = /^[ \t]*[-*][ \t]+\[([ xX~-])\][ \t]+(.*)$/;
31
+
32
+ /** H2/H3 heading, its text captured. */
33
+ const HEADING = /^(#{2,3})[ \t]+(.*?)[ \t]*$/;
34
+
35
+ /** An H2/H3 that opens a phase span, matching the dashboard's PHASE_RE shape. */
36
+ const PHASE_HEADING = /^Phase[ \t]+(?:\d+(?:\.\d+)*[a-z]?|[IVX]+|[A-Z](?:\d+)?)\b/;
37
+
38
+ /**
39
+ * The lines of `text` that sit inside a `## Phase …` span.
40
+ *
41
+ * A phase span runs from its heading to the next heading AT ITS OWN LEVEL OR
42
+ * SHALLOWER. A DEEPER heading does not close it — an `### Measurement A`
43
+ * inside a `## Phase 1` is part of that phase, not the end of it.
44
+ *
45
+ * R2 round 6, critical finding 2, and it was live in this tree rather than
46
+ * hypothetical. The first version closed the span at ANY H2/H3, so on
47
+ * `road-to-ui-track-integrity-followup.md` — whose `## Phase 1` is immediately
48
+ * followed by three `###` sub-headings — `countRoadmap` returned `open: 0`
49
+ * against ten real open checkboxes. `ladder` then answers `complete` and
50
+ * `run_supervise` reports "the run finished, the session just never said so".
51
+ * That is the exact false-completion this module was written to prevent,
52
+ * produced by the module itself.
53
+ *
54
+ * `## Blockers`, `## Acceptance criteria` and `## Risk Register` still close a
55
+ * `## Phase` because they are H2s — the level rule gives that for free, and it
56
+ * is why the rule is a level comparison rather than a list of section names
57
+ * nobody would keep current.
58
+ *
59
+ * A roadmap with NO phase heading at all yields every line. That is the
60
+ * deliberate choice and it is the safe direction: an unphased roadmap is a
61
+ * legitimate shape, and returning nothing for it would make every such run
62
+ * read as `complete`, which is the more dangerous of the two errors.
63
+ */
64
+ export function phaseLines(text: string): string[] {
65
+ const lines = text.split('\n');
66
+ const out: string[] = [];
67
+ // The heading level of the phase currently open, or 0 when none is.
68
+ let phaseLevel = 0;
69
+ let sawPhase = false;
70
+ for (const line of lines) {
71
+ const h = HEADING.exec(line);
72
+ if (h !== null) {
73
+ const level = (h[1] as string).length;
74
+ const isPhase = PHASE_HEADING.test(h[2] ?? '');
75
+ if (isPhase) {
76
+ phaseLevel = level;
77
+ sawPhase = true;
78
+ } else if (phaseLevel !== 0 && level <= phaseLevel) {
79
+ // A sibling or shallower section ends the phase.
80
+ phaseLevel = 0;
81
+ }
82
+ // A DEEPER non-phase heading leaves the span open.
83
+ continue;
84
+ }
85
+ if (phaseLevel !== 0) out.push(line);
86
+ }
87
+ return sawPhase ? out : lines;
88
+ }
@@ -0,0 +1,409 @@
1
+ /**
2
+ * run_checkpoint — the deterministic checkpoint a dying run leaves behind,
3
+ * and the re-verification a resumed run owes before trusting it.
4
+ *
5
+ * road-to-long-horizon-execution Phase 3.0 / 3.2 (sequencing UOTL Phase 6.1).
6
+ *
7
+ * ## Why "deterministic" is the load-bearing word
8
+ *
9
+ * A handoff summary is authored — a model writes prose about where the work
10
+ * stands, and prose can be wrong in ways nothing catches. A checkpoint is
11
+ * DERIVED: every field here is recomputed from files on disk, so writing one
12
+ * costs no judgement and reading one back can be checked against the tree
13
+ * that produced it. The two are complements, not alternatives: the summary
14
+ * says what the run was thinking, this says what the run had actually done.
15
+ *
16
+ * ## Why a resumed run re-verifies rather than trusts
17
+ *
18
+ * The reference design this borrows from resumes by BOOKKEEPING — the daemon
19
+ * believes its own record of where a session was. That is fine while nothing
20
+ * else touches the tree, and wrong the moment something does: a human
21
+ * committing, a sibling worktree, a partially-applied edit the dying session
22
+ * never finished. So `verifyCheckpoint` recomputes the same fields and reports
23
+ * a per-field agreement rather than a boolean — a resumed run should be able
24
+ * to say WHICH claim went stale, not merely that something did.
25
+ *
26
+ * A disagreement is explicitly NOT an error here. Work landing between the
27
+ * checkpoint and the resume is the normal case, and a verifier that treats
28
+ * progress as corruption would refuse every healthy resume.
29
+ */
30
+
31
+ import * as fs from 'node:fs';
32
+ import * as path from 'node:path';
33
+
34
+ import { CHECKBOX_LINE, phaseLines } from './roadmap_checkboxes.js';
35
+
36
+ export const CHECKPOINT_DIR_REL = path.join('agents', 'runtime', 'state', 'checkpoints');
37
+
38
+ /**
39
+ * A run's derived state at the moment its session ended.
40
+ *
41
+ * Every field is recomputed, never remembered — that is what makes
42
+ * `verifyCheckpoint` possible at all.
43
+ */
44
+ export interface RunCheckpoint {
45
+ readonly schema_version: 1;
46
+ /** The session-derived run id this checkpoint belongs to. */
47
+ readonly run_id: string;
48
+ /** Roadmap slug from the session's `sessions:claim` file. */
49
+ readonly roadmap: string;
50
+ /** Steps still `[ ]` at checkpoint time. */
51
+ readonly open_steps: number;
52
+ /** Steps already `[x]` at checkpoint time. */
53
+ readonly done_steps: number;
54
+ /** `[~]` deferred plus `[-]` cancelled — neither is open work. */
55
+ readonly parked_steps: number;
56
+ /** The next `[ ]` step's text, trimmed, or `null` when none remains. */
57
+ readonly next_step: string | null;
58
+ /** Commit the tree was on. A resume onto a different commit is normal, and visible. */
59
+ readonly head: string | null;
60
+ readonly written_at: string;
61
+ }
62
+
63
+ /** Counts derived from a roadmap body — the same vocabulary the loop uses. */
64
+ export interface RoadmapCounts {
65
+ open: number;
66
+ done: number;
67
+ parked: number;
68
+ next: string | null;
69
+ }
70
+
71
+ /**
72
+ * Count checkboxes in a roadmap body.
73
+ *
74
+ * `[~]` (deferred) and `[-]` (cancelled) are PARKED, not open: a resumed run
75
+ * that treated them as work to do would re-engage into the exact items a human
76
+ * decided not to do, which is the anti-stall mechanism manufacturing a stall.
77
+ */
78
+ export function countRoadmap(text: string): RoadmapCounts {
79
+ const counts: RoadmapCounts = { open: 0, done: 0, parked: 0, next: null };
80
+ // Phase spans only, and the dashboard's own vocabulary — see
81
+ // `_lib/roadmap_checkboxes.ts` for what each half was getting wrong.
82
+ for (const raw of phaseLines(text)) {
83
+ const m = CHECKBOX_LINE.exec(raw);
84
+ if (m === null) continue;
85
+ const mark = m[1];
86
+ if (mark === 'x' || mark === 'X') {
87
+ counts.done += 1;
88
+ } else if (mark === '~' || mark === '-') {
89
+ counts.parked += 1;
90
+ } else {
91
+ counts.open += 1;
92
+ if (counts.next === null) {
93
+ counts.next = (m[2] ?? '').trim() || null;
94
+ }
95
+ }
96
+ }
97
+ return counts;
98
+ }
99
+
100
+ export function checkpointFile(repoRoot: string, runId: string): string {
101
+ return path.join(repoRoot, CHECKPOINT_DIR_REL, `${runId.replace(/[^A-Za-z0-9_-]/g, '_')}.json`);
102
+ }
103
+
104
+ export function roadmapPath(repoRoot: string, slug: string): string {
105
+ return path.join(repoRoot, 'agents', 'roadmaps', `${slug}.md`);
106
+ }
107
+
108
+ export interface BuildOptions {
109
+ /** Injected so a test does not depend on the ambient git state. */
110
+ readonly head?: string | null;
111
+ readonly now?: () => Date;
112
+ }
113
+
114
+ /**
115
+ * The current commit, read straight off `.git` — no subprocess.
116
+ *
117
+ * R2 round 2, finding 5. `head` was populated only from `opts.head`, and the
118
+ * ONE production caller (`session_eol_hook`) never passed it, so the field was
119
+ * always `null` while the checkpoint's own docblock, the loop contract and the
120
+ * roadmap all describe it as carrying the commit the tree was on. Only the
121
+ * test injected a value, which is why nothing noticed.
122
+ *
123
+ * No `git rev-parse`: this runs on the Stop path, where a spawn is a cost paid
124
+ * on every reply. Two files answer it — and a WORKTREE is the case a naive
125
+ * reader gets wrong, because there `.git` is a FILE holding `gitdir: <path>`
126
+ * rather than a directory. This package is developed in worktrees, so the naive
127
+ * version would have returned null exactly where it was being exercised.
128
+ *
129
+ * Returns `null` on anything unexpected. A checkpoint without a commit is
130
+ * still useful; a Stop path that throws is not.
131
+ */
132
+ export function readHead(repoRoot: string): string | null {
133
+ try {
134
+ let gitDir = path.join(repoRoot, '.git');
135
+ if (fs.statSync(gitDir).isFile()) {
136
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitDir, 'utf-8').trim());
137
+ if (m === null) return null;
138
+ gitDir = path.resolve(repoRoot, m[1] as string);
139
+ }
140
+ // HEAD is per-worktree; REFS are not. A linked worktree's gitdir holds
141
+ // its own HEAD and a `commondir` pointer, and `refs/` + `packed-refs`
142
+ // live at that commondir — never beside HEAD.
143
+ //
144
+ // R2 round 3, finding 1, and the round-2 version of this function got
145
+ // it exactly half right: it handled `.git`-as-a-file and then resolved
146
+ // refs against the worktree gitdir, so `head` was null in every linked
147
+ // worktree — including the one this package is developed in. Its test
148
+ // passed because the fixture wrote refs beside HEAD, a layout git never
149
+ // produces. A fixture that agrees with the code instead of with reality
150
+ // reads as coverage and is worse than no test at all.
151
+ let refRoot = gitDir;
152
+ try {
153
+ const common = fs.readFileSync(path.join(gitDir, 'commondir'), 'utf-8').trim();
154
+ if (common !== '') refRoot = path.resolve(gitDir, common);
155
+ } catch {
156
+ // No commondir — a plain repository, where gitDir already is it.
157
+ }
158
+ const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf-8').trim();
159
+ // A detached HEAD is the sha itself.
160
+ if (/^[0-9a-f]{40}$/.test(head)) return head;
161
+ const ref = /^ref:\s*(.+)$/.exec(head);
162
+ if (ref === null) return null;
163
+ const refName = ref[1] as string;
164
+ try {
165
+ return fs.readFileSync(path.join(refRoot, refName), 'utf-8').trim() || null;
166
+ } catch {
167
+ // Packed refs: a loose file does not exist for every branch.
168
+ const packed = fs.readFileSync(path.join(refRoot, 'packed-refs'), 'utf-8');
169
+ for (const line of packed.split('\n')) {
170
+ const pm = /^([0-9a-f]{40})\s+(.+)$/.exec(line.trim());
171
+ if (pm !== null && pm[2] === refName) return pm[1] as string;
172
+ }
173
+ return null;
174
+ }
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Derive a checkpoint from the tree. Returns `null` when the roadmap is
182
+ * unreadable — a checkpoint that guesses is worse than none, because the whole
183
+ * contract of this file is that its fields were computed rather than recalled.
184
+ */
185
+ export function buildCheckpoint(
186
+ repoRoot: string,
187
+ runId: string,
188
+ slug: string,
189
+ opts: BuildOptions = {},
190
+ ): RunCheckpoint | null {
191
+ let text: string;
192
+ try {
193
+ text = fs.readFileSync(roadmapPath(repoRoot, slug), 'utf-8');
194
+ } catch {
195
+ return null;
196
+ }
197
+ const counts = countRoadmap(text);
198
+ return {
199
+ schema_version: 1,
200
+ run_id: runId,
201
+ roadmap: slug,
202
+ open_steps: counts.open,
203
+ done_steps: counts.done,
204
+ parked_steps: counts.parked,
205
+ next_step: counts.next,
206
+ head: opts.head ?? readHead(repoRoot),
207
+ written_at: (opts.now ?? ((): Date => new Date()))().toISOString(),
208
+ };
209
+ }
210
+
211
+ /** Write a checkpoint. Best-effort: a failed write must never break a Stop. */
212
+ export function writeCheckpoint(repoRoot: string, cp: RunCheckpoint): string | null {
213
+ const file = checkpointFile(repoRoot, cp.run_id);
214
+ try {
215
+ fs.mkdirSync(path.dirname(file), { recursive: true });
216
+ fs.writeFileSync(file, `${JSON.stringify(cp, null, 2)}\n`, 'utf-8');
217
+ return file;
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+
223
+ export function readCheckpoint(repoRoot: string, runId: string): RunCheckpoint | null {
224
+ try {
225
+ const parsed: unknown = JSON.parse(fs.readFileSync(checkpointFile(repoRoot, runId), 'utf-8'));
226
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
227
+ const o = parsed as Record<string, unknown>;
228
+ if (typeof o['run_id'] !== 'string' || typeof o['roadmap'] !== 'string') return null;
229
+ if (typeof o['open_steps'] !== 'number' || typeof o['done_steps'] !== 'number') return null;
230
+ return parsed as RunCheckpoint;
231
+ } catch {
232
+ return null;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * The most recent checkpoint for a ROADMAP, across every run id.
238
+ *
239
+ * R2 review, finding 7. `readCheckpoint` keys on the run id, which is derived
240
+ * from the session id of the session that DIED — so a relaunched session, which
241
+ * by definition has a new id and no index from slug to old id, could not reach
242
+ * its own checkpoint through any path in the tree. `verifyCheckpoint` and
243
+ * `renderVerification` had zero production call sites, and the loop contract
244
+ * nonetheless instructed a resumed run that "the first act is verifyCheckpoint".
245
+ * The mitigation Risk 5 names was unreachable as shipped.
246
+ *
247
+ * The roadmap slug is the one key a relaunched run genuinely holds: it claims
248
+ * the same roadmap, which is what makes it a resume rather than a new run.
249
+ *
250
+ * Ties are broken by `written_at` descending, then by filename, so the result
251
+ * is deterministic when two checkpoints share a timestamp. A malformed or
252
+ * unreadable file is skipped rather than failing the lookup — a resume that
253
+ * refuses to start because one stale JSON file is corrupt is worse than a
254
+ * resume that verifies against the newest readable one.
255
+ */
256
+ export function latestCheckpointFor(repoRoot: string, roadmap: string): RunCheckpoint | null {
257
+ const dir = path.join(repoRoot, CHECKPOINT_DIR_REL);
258
+ let names: string[];
259
+ try {
260
+ names = fs.readdirSync(dir).filter((n) => n.endsWith('.json'));
261
+ } catch {
262
+ return null;
263
+ }
264
+ const found: RunCheckpoint[] = [];
265
+ for (const name of names.sort()) {
266
+ let parsed: unknown;
267
+ try {
268
+ parsed = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf-8'));
269
+ } catch {
270
+ continue;
271
+ }
272
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
273
+ const o = parsed as Record<string, unknown>;
274
+ if (o['roadmap'] !== roadmap) continue;
275
+ if (typeof o['run_id'] !== 'string') continue;
276
+ if (typeof o['open_steps'] !== 'number' || typeof o['done_steps'] !== 'number') continue;
277
+ found.push(parsed as RunCheckpoint);
278
+ }
279
+ if (found.length === 0) return null;
280
+ found.sort((a, b) => {
281
+ const at = String(a.written_at ?? '');
282
+ const bt = String(b.written_at ?? '');
283
+ if (at !== bt) return at < bt ? 1 : -1;
284
+ return a.run_id < b.run_id ? 1 : -1;
285
+ });
286
+ return found[0] ?? null;
287
+ }
288
+
289
+ /** One claim's verdict — the field, what was claimed, what is true now. */
290
+ export interface FieldVerdict {
291
+ readonly field: string;
292
+ readonly claimed: string | number | null;
293
+ readonly actual: string | number | null;
294
+ readonly agrees: boolean;
295
+ }
296
+
297
+ export interface VerifyResult {
298
+ /** `false` when the roadmap named by the checkpoint no longer reads. */
299
+ readonly readable: boolean;
300
+ readonly fields: readonly FieldVerdict[];
301
+ /** Every field agreed. NOT a precondition for resuming — see the header. */
302
+ readonly agrees: boolean;
303
+ }
304
+
305
+ /**
306
+ * Recompute the checkpoint's claims against the tree as it is NOW.
307
+ *
308
+ * Reports per-field rather than a boolean on purpose: "the checkpoint is
309
+ * stale" tells a resumed run nothing it can act on, while "open_steps claimed
310
+ * 4, actual 3" tells it a step landed after the checkpoint was written and
311
+ * which count to trust.
312
+ */
313
+ export function verifyCheckpoint(repoRoot: string, cp: RunCheckpoint): VerifyResult {
314
+ let text: string;
315
+ try {
316
+ text = fs.readFileSync(roadmapPath(repoRoot, cp.roadmap), 'utf-8');
317
+ } catch {
318
+ return { readable: false, fields: [], agrees: false };
319
+ }
320
+ const counts = countRoadmap(text);
321
+ // Read ONCE, so the recorded `actual` and the verdict about it are one
322
+ // expression rather than two that happen to agree.
323
+ const head = readHead(repoRoot);
324
+ const fields: FieldVerdict[] = [
325
+ { field: 'open_steps', claimed: cp.open_steps, actual: counts.open, agrees: cp.open_steps === counts.open },
326
+ { field: 'done_steps', claimed: cp.done_steps, actual: counts.done, agrees: cp.done_steps === counts.done },
327
+ { field: 'parked_steps', claimed: cp.parked_steps, actual: counts.parked, agrees: cp.parked_steps === counts.parked },
328
+ { field: 'next_step', claimed: cp.next_step, actual: counts.next, agrees: cp.next_step === counts.next },
329
+ // `head` is recomputed like every other field. R2 round 3, finding 2:
330
+ // round 2 fixed only the WRITE half of finding 5, so the loop
331
+ // contract's "every field is recomputed" was false for exactly the
332
+ // field that says whether the tree moved under the run — which is the
333
+ // one a resume most needs to see. A disagreement here is the NORMAL
334
+ // case (a human committed, a sibling worktree moved) and is reported,
335
+ // never treated as corruption.
336
+ {
337
+ field: 'head',
338
+ claimed: cp.head,
339
+ actual: head,
340
+ // A `null` on EITHER side is "not known", never "different".
341
+ //
342
+ // R2 round 4, finding 2. `readHead` is explicitly designed to
343
+ // return null on any failure — no git, an unreadable ref, a
344
+ // packed-refs miss — so a strict `===` turned "I could not read
345
+ // the commit" into "the tree moved since the checkpoint", on a
346
+ // resume at the SAME commit with every other field agreeing. That
347
+ // is a false alarm on the one field whose whole job is to tell a
348
+ // human whether anything moved, and a false alarm there costs more
349
+ // than a missing one: it trains the reader to skip the line.
350
+ agrees: cp.head === null || head === null || cp.head === head,
351
+ },
352
+ ];
353
+ return { readable: true, fields, agrees: fields.every((f) => f.agrees) };
354
+ }
355
+
356
+ /** A one-line-per-field report a resumed run can put in front of a human. */
357
+ export function renderVerification(cp: RunCheckpoint, res: VerifyResult): string {
358
+ if (!res.readable) {
359
+ return (
360
+ `checkpoint ${cp.run_id}: roadmap '${cp.roadmap}' no longer reads — ` +
361
+ `the claimed state cannot be re-verified, so none of it may be assumed.`
362
+ );
363
+ }
364
+ const lines = [`checkpoint ${cp.run_id} · roadmap ${cp.roadmap} · written ${cp.written_at}`];
365
+ // Three states, not two. R2 round 5, finding 3: the null-head tolerance
366
+ // added in round 4 is correct as a VERDICT — an unknown commit is not a
367
+ // moved one — but rendering it as `ok head: null` alongside "the tree
368
+ // still matches the checkpoint" tells the reader something was checked
369
+ // when nothing could be. UNKNOWN is a third word because the reader's next
370
+ // action differs: `ok` needs nothing, `CHANGED` names what moved, and
371
+ // UNKNOWN says this axis carries no information and the other four are
372
+ // what the verdict rests on.
373
+ // ONLY `head` can be unknown. R2 round 6, finding 6: the predicate was
374
+ // written for `head` — where `null` means "could not read" — and applied
375
+ // to all five fields, but for `next_step` a `null` is a documented VALUE
376
+ // ("or `null` when none remains"). A finished roadmap therefore rendered
377
+ // `UNKNOWN next_step: not readable` and claimed a field had not been
378
+ // compared when it had been, on the exact state a resume most wants to
379
+ // see. The field name is the discriminator because the meaning of `null`
380
+ // is a property of the field, not of the value.
381
+ const UNKNOWNABLE: ReadonlySet<string> = new Set(['head']);
382
+ let unknown = 0;
383
+ for (const f of res.fields) {
384
+ if (UNKNOWNABLE.has(f.field) && (f.claimed === null || f.actual === null)) {
385
+ unknown += 1;
386
+ lines.push(
387
+ ` UNKNOWN ${f.field}: not readable ` +
388
+ `(claimed ${String(f.claimed)}, actual ${String(f.actual)}) — not compared`,
389
+ );
390
+ continue;
391
+ }
392
+ lines.push(
393
+ f.agrees
394
+ ? ` ok ${f.field}: ${String(f.actual)}`
395
+ : ` CHANGED ${f.field}: claimed ${String(f.claimed)} → actual ${String(f.actual)}`,
396
+ );
397
+ }
398
+ if (!res.agrees) {
399
+ lines.push(' the tree moved since the checkpoint — the ACTUAL column is what to resume from.');
400
+ } else if (unknown > 0) {
401
+ lines.push(
402
+ ` the ${res.fields.length - unknown} comparable field(s) match; ` +
403
+ `${unknown} could not be read and were not compared.`,
404
+ );
405
+ } else {
406
+ lines.push(' the tree still matches the checkpoint.');
407
+ }
408
+ return lines.join('\n');
409
+ }
@@ -321,10 +321,37 @@ export function delete_record(dir: string, session_id: string): boolean {
321
321
  export interface ReadLiveOptions {
322
322
  /** Unlink expired records as they are encountered. Default `false`. */
323
323
  prune?: boolean;
324
+ /**
325
+ * How long an EXPIRED record survives the prune. Default 24 h.
326
+ *
327
+ * R2 round 4, finding 5. The register has two consumers with opposite
328
+ * needs and only one of them knew: `sessions:list` and the session-start
329
+ * hook both read with `prune: true`, while `run:supervise` exists
330
+ * ENTIRELY to classify expired records — the runs whose session died. So
331
+ * the watcher's whole input was deleted by two routine read paths, the
332
+ * hook one of them, which fires on every session start. The digest could
333
+ * then report nothing, and round 3's `clearCompleted` release could never
334
+ * fire because no completed candidate ever reached it.
335
+ *
336
+ * The 24 h is derived, not picked: `digest` is documented as the MORNING
337
+ * report, so a once-a-day reader must be able to see a full day of deaths.
338
+ * A shorter grace loses runs that died overnight, which is precisely the
339
+ * population the watcher was built for.
340
+ *
341
+ * `0` restores the old delete-on-sight behaviour for a caller that really
342
+ * wants it. Nothing in the tree passes it today.
343
+ */
344
+ prune_grace_ms?: number;
324
345
  /** Clock injection for tests. */
325
346
  now?: Date;
326
347
  }
327
348
 
349
+ /**
350
+ * How long an expired record outlives its expiry before a prune may remove it.
351
+ * See {@link ReadLiveOptions.prune_grace_ms} for why this is a day.
352
+ */
353
+ export const PRUNE_GRACE_MS = 24 * 60 * 60 * 1000;
354
+
328
355
  /**
329
356
  * Every live record in the register, optionally pruning expired ones as it goes.
330
357
  *
@@ -339,6 +366,7 @@ export interface ReadLiveOptions {
339
366
  export function read_live_records(dir: string, options: ReadLiveOptions = {}): SessionRecord[] {
340
367
  const now = options.now ?? new Date();
341
368
  const prune = options.prune === true;
369
+ const grace = options.prune_grace_ms ?? PRUNE_GRACE_MS;
342
370
  let names: string[];
343
371
  try {
344
372
  names = fs.readdirSync(dir).filter((n) => n.endsWith('.json'));
@@ -356,7 +384,10 @@ export function read_live_records(dir: string, options: ReadLiveOptions = {}): S
356
384
  rec = null;
357
385
  }
358
386
  if (rec === null || is_expired(rec, now)) {
359
- if (prune) {
387
+ // A record is dropped from the LIVE set the moment it expires, and
388
+ // deleted from disk only after the grace window — the watcher
389
+ // reads the register directly and needs the expired ones.
390
+ if (prune && (rec === null || is_expired(rec, new Date(now.getTime() - grace)))) {
360
391
  try {
361
392
  fs.unlinkSync(full);
362
393
  } catch {
@@ -41,6 +41,31 @@ function _sleepSync(ms: number): void {
41
41
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
42
42
  }
43
43
 
44
+ /** Options for {@link readStdinText}. */
45
+ export interface ReadStdinOptions {
46
+ /**
47
+ * Give up and return `''` when the FIRST byte has not arrived within this
48
+ * many milliseconds. Undefined (the default) keeps the historical behaviour:
49
+ * the full {@link MAX_RETRIES} budget applies from the first read, which is
50
+ * what a `gh pr diff` pipe needs — its first byte can legitimately take a
51
+ * while, and reporting that as empty input is the defect this module exists
52
+ * to prevent.
53
+ *
54
+ * A HOOK is the opposite case, and it cost a CI hang to learn the difference.
55
+ * The host writes the payload synchronously on spawn, so a byte that has not
56
+ * arrived promptly is not a slow writer — it is an fd 0 nobody will ever
57
+ * write to, which is what a child inherits when a test spawns it without
58
+ * piping stdin. With no cap the dispatcher then span the full ~10 s budget on
59
+ * EVERY such invocation and returned "" anyway; measured 12.4 s per call
60
+ * against an immediate return before the retry loop existed. Hundreds of
61
+ * those hung three Node-Tests shards for over an hour.
62
+ *
63
+ * Once ANY byte has arrived the cap no longer applies: a writer demonstrably
64
+ * exists, so the full budget is right and the large-payload fix is intact.
65
+ */
66
+ firstByteTimeoutMs?: number;
67
+ }
68
+
44
69
  /**
45
70
  * Read all of fd 0 as UTF-8.
46
71
  *
@@ -48,10 +73,13 @@ function _sleepSync(ms: number): void {
48
73
  * {@link MAX_RETRIES} consecutive EAGAINs — a caller must not be able to
49
74
  * mistake a failed read for empty input.
50
75
  */
51
- export function readStdinText(fd = 0): string {
76
+ export function readStdinText(fd = 0, opts: ReadStdinOptions = {}): string {
52
77
  const chunks: Buffer[] = [];
53
78
  const buf = Buffer.alloc(CHUNK);
54
79
  let retries = 0;
80
+ let got_any = false;
81
+ const first_byte_deadline =
82
+ opts.firstByteTimeoutMs === undefined ? null : Date.now() + opts.firstByteTimeoutMs;
55
83
  for (;;) {
56
84
  let read: number;
57
85
  try {
@@ -60,6 +88,11 @@ export function readStdinText(fd = 0): string {
60
88
  } catch (exc) {
61
89
  const code = (exc as NodeJS.ErrnoException).code;
62
90
  if (code === 'EAGAIN') {
91
+ // An idle fd 0 is "no input", not a slow writer — but only
92
+ // before the first byte. See ReadStdinOptions.firstByteTimeoutMs.
93
+ if (!got_any && first_byte_deadline !== null && Date.now() >= first_byte_deadline) {
94
+ return '';
95
+ }
63
96
  retries += 1;
64
97
  if (retries > MAX_RETRIES) {
65
98
  throw new Error(
@@ -76,6 +109,7 @@ export function readStdinText(fd = 0): string {
76
109
  throw exc;
77
110
  }
78
111
  if (read === 0) break;
112
+ got_any = true;
79
113
  chunks.push(Buffer.from(buf.subarray(0, read)));
80
114
  }
81
115
  return Buffer.concat(chunks).toString('utf-8');
@@ -45,10 +45,18 @@ import { read_lockfile } from './installed_lock.js';
45
45
  *
46
46
  * The roadmap's § 0 names three (A promissory, B language, C verification).
47
47
  * That was true of the draft and is not true of the tree: detector D
48
- * (`completion`) landed under round 7 § Phase 1 and is in the same
49
- * unconditional list as the other three. Counting three would silently drop a
50
- * detector's refusals, so the set is read off `DetectorId` in the gate rather
51
- * than off the prose.
48
+ * (`completion`) landed under round 7 § Phase 1 and runs in the same detector
49
+ * list as the other three. Counting three would silently drop a detector's
50
+ * refusals, so the set is read off `DetectorId` in the gate rather than off the
51
+ * prose.
52
+ *
53
+ * This said "the same UNCONDITIONAL list" until 2026-08-18, which was wrong and
54
+ * is worth naming rather than quietly rewording: `main()` runs A and D only when
55
+ * no subagent dispatch is open. It does not change this set — every detector
56
+ * that CAN fire needs a counter — but it does change what a reader may conclude
57
+ * from a zero here: A's and D's counts are censored by that third allow path,
58
+ * and `docs/contracts/turn-end-detector-demotion.md` is where that consequence
59
+ * is carried.
52
60
  */
53
61
  export const DETECTOR_IDS = [
54
62
  'promissory',