@event4u/agent-config 14.1.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 (120) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +291 -181
  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 +3773 -1731
  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 +22 -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/skill_catalogue_series.ts +91 -0
  75. package/src/scripts/_lib/stdin.ts +35 -1
  76. package/src/scripts/_lib/turn_end_refusals.ts +12 -4
  77. package/src/scripts/_lib/unattended_guard.ts +371 -0
  78. package/src/scripts/ai_council/config.ts +27 -1
  79. package/src/scripts/ai_council/events_log.ts +12 -1
  80. package/src/scripts/ai_council/fallback_config.ts +114 -0
  81. package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
  82. package/src/scripts/ai_council/orchestrator.ts +251 -292
  83. package/src/scripts/ai_council/py_parity.ts +139 -0
  84. package/src/scripts/ai_council/response_render.ts +99 -0
  85. package/src/scripts/ai_council/spend_gate.ts +123 -0
  86. package/src/scripts/ai_council/transport_resolver.ts +81 -13
  87. package/src/scripts/bench_hook_injection.ts +100 -6
  88. package/src/scripts/bench_hook_latency.ts +442 -16
  89. package/src/scripts/check_estate_count.ts +924 -0
  90. package/src/scripts/compile_hook_manifest.ts +67 -0
  91. package/src/scripts/council_cli.ts +100 -125
  92. package/src/scripts/decision_memo.ts +325 -0
  93. package/src/scripts/dispatch_r2_reviewer.ts +169 -15
  94. package/src/scripts/hook_manifest.json +1 -0
  95. package/src/scripts/hook_manifest.yaml +172 -25
  96. package/src/scripts/hooks/concern_registry.ts +6 -0
  97. package/src/scripts/hooks/dispatch_hook.ts +183 -10
  98. package/src/scripts/hooks/dispatch_issues.ts +26 -0
  99. package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
  100. package/src/scripts/hooks/hook_stdin.ts +111 -5
  101. package/src/scripts/hooks/injection_budget.ts +595 -0
  102. package/src/scripts/hooks/payload_stub.ts +446 -0
  103. package/src/scripts/hooks/run_continuation_hook.ts +627 -0
  104. package/src/scripts/hooks/session_eol_hook.ts +52 -0
  105. package/src/scripts/hooks/skill_route_hook.ts +172 -11
  106. package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
  107. package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
  108. package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
  109. package/src/scripts/hooks_doctor.ts +16 -2
  110. package/src/scripts/install-hooks.sh +26 -0
  111. package/src/scripts/interruption_report.ts +330 -13
  112. package/src/scripts/lint_hook_manifest.ts +167 -0
  113. package/src/scripts/roadmap_progress_hook.ts +254 -46
  114. package/src/scripts/run_supervise.ts +623 -0
  115. package/src/scripts/session_register_hook.ts +108 -8
  116. package/src/scripts/sessions_cli.ts +18 -6
  117. package/src/scripts/telemetry_disclosure_hook.ts +244 -0
  118. package/src/server/io/yamlIO.ts +167 -3
  119. package/src/shared/settingsConsent.ts +49 -2
  120. package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
@@ -0,0 +1,623 @@
1
+ /**
2
+ * run_supervise — the out-of-process watcher for runs whose session died.
3
+ *
4
+ * road-to-long-horizon-execution Phase 3.1 (sequencing UOTL Phase 6.2).
5
+ *
6
+ * ## The defect
7
+ *
8
+ * A session dies with its terminal. Nothing anywhere has the job of noticing
9
+ * that a claimed run stopped moving, so a roadmap 40 % done and a roadmap
10
+ * finished look identical from outside the dead session.
11
+ *
12
+ * ## What this is, and what it deliberately is not
13
+ *
14
+ * A FOREGROUND loop, not a daemon. The roadmap says so, and the reason is
15
+ * falsifiability: a design that has never been watched running is not a
16
+ * design that has been tested, and a v1 daemon hides its own behaviour behind
17
+ * a log file. A foreground loop can be read while it works.
18
+ *
19
+ * **It never merges.** The reference design this borrows the loop shape from
20
+ * also auto-merges ready worktrees; that borrowing is REJECTED, by name, in
21
+ * the roadmap's harvest section. Merge stays human and conversational. This
22
+ * process may report, and — only with `--relaunch` — start a fresh session.
23
+ * It may never merge, push, or close anything.
24
+ *
25
+ * **It reports by default.** Relaunching spawns a coding agent that spends
26
+ * tokens without a human watching, so the acting path is behind an explicit
27
+ * flag rather than behind a default that a stray invocation trips.
28
+ *
29
+ * ## Liveness, and an honest departure from the borrowed design
30
+ *
31
+ * The reference resumes crashed sessions by PID liveness. This register has
32
+ * no PID field — liveness here is the heartbeat (`last_seen`) against a
33
+ * per-platform TTL, which is what the register actually maintains. That is a
34
+ * WEAKER signal in one specific way and it is stated rather than papered
35
+ * over: a session killed seconds ago still reads live until its TTL expires,
36
+ * so this watcher is late by up to one TTL rather than immediate. It is also
37
+ * STRONGER in another: a wedged session that still holds its PID reads dead
38
+ * here once it stops beating, and reads alive to a PID check forever.
39
+ */
40
+
41
+ import * as fs from 'node:fs';
42
+ import * as path from 'node:path';
43
+ import { fileURLToPath, pathToFileURL } from 'node:url';
44
+
45
+ import { is_expired, register_dir, type SessionRecord } from './_lib/session_register.js';
46
+ import {
47
+ countRoadmap,
48
+ latestCheckpointFor,
49
+ readCheckpoint,
50
+ readHead,
51
+ roadmapPath,
52
+ } from './_lib/run_checkpoint.js';
53
+ import { readBudget } from './_lib/unattended_guard.js';
54
+ import {
55
+ LIVE_SPAWN_REFUSAL,
56
+ planResume,
57
+ renderResumePlans,
58
+ type ResumePlan,
59
+ } from './_lib/headless_invocation.js';
60
+
61
+ /**
62
+ * UOTL 6.2 verbatim: at most three relaunches per run — and "run" means the
63
+ * roadmap across every session that worked it, not one session id. See
64
+ * {@link RelaunchLedger}.
65
+ */
66
+ export const MAX_RELAUNCHES_PER_RUN = 3;
67
+
68
+ /** The emergency stop. Same switch the orchestration layer already honours. */
69
+ export const HALT_ENV = 'AGENT_CONFIG_ORCHESTRATION_HALT';
70
+
71
+ export const SUPERVISE_STATE_REL = path.join(
72
+ 'agents',
73
+ 'runtime',
74
+ 'state',
75
+ 'supervise-relaunches.json',
76
+ );
77
+
78
+ export type Disposition =
79
+ | 'alive'
80
+ | 'no-roadmap'
81
+ | 'roadmap-unreadable'
82
+ | 'complete'
83
+ | 'budget-exhausted'
84
+ | 'relaunchable';
85
+
86
+ export interface Candidate {
87
+ readonly session_id: string;
88
+ readonly roadmap: string | null;
89
+ readonly worktree: string;
90
+ /**
91
+ * Host platform, carried through from the register record.
92
+ *
93
+ * Needed by `--print-relaunch`: the resume command's shape is a property of
94
+ * the HOST the dead session ran on, and only the record knows which that
95
+ * was. Guessing the current process's host would build a `claude` command
96
+ * for a run that died under `codex`.
97
+ */
98
+ readonly platform: string;
99
+ readonly disposition: Disposition;
100
+ readonly open_steps: number | null;
101
+ readonly relaunches: number;
102
+ /** One sentence a human can act on without opening anything. */
103
+ readonly reason: string;
104
+ }
105
+
106
+ /** Per-run relaunch counts. A plain object so the file stays readable by eye. */
107
+ /**
108
+ * Relaunch counts, keyed by ROADMAP SLUG — never by session id.
109
+ *
110
+ * R2 round 2, finding 9. The cap is documented as "at most three relaunches
111
+ * per RUN", and a run spans generations by definition: relaunching produces a
112
+ * NEW session id, so a session-keyed ledger handed every generation a fresh
113
+ * budget of three and the cap could never bind. The roadmap is what identifies
114
+ * a run across its generations — the same key `latestCheckpointFor` uses, and
115
+ * for the same reason.
116
+ *
117
+ * Total over the relaunchable set: a record with no roadmap slug is classified
118
+ * `no-roadmap` before the cap is consulted, so there is no relaunchable run
119
+ * this key cannot name.
120
+ *
121
+ * THE COUNTER IS CLEARED WHEN THE ROADMAP COMPLETES, and that half is what
122
+ * makes the key correct rather than merely different. R2 round 3, finding 6:
123
+ * a roadmap-keyed counter with no reset swaps a cap that never binds for one
124
+ * that never releases — three deaths in March would refuse a relaunch in
125
+ * September for a run that has nothing to do with them. "Per run" needs both
126
+ * a key that spans a run's generations AND a boundary where one run ends, and
127
+ * `disposition: complete` is that boundary: zero open steps means the work the
128
+ * counter was counting attempts at is done.
129
+ *
130
+ * `clearCompleted` is the writer. It is called from `digest`, which is a
131
+ * read-report — so the clear is stated where it happens rather than hidden in
132
+ * a getter, and a caller that only classifies never mutates.
133
+ */
134
+ export type RelaunchLedger = Record<string, number>;
135
+
136
+ /**
137
+ * Drop the counters of every roadmap whose run is complete.
138
+ *
139
+ * Returns a NEW ledger; the caller decides whether to persist it. Separating
140
+ * the decision from the write keeps `classify` pure — it is called from a
141
+ * digest, from tests, and (one day) from the spawn, and only one of those
142
+ * should be writing state.
143
+ */
144
+ export function clearCompleted(
145
+ ledger: RelaunchLedger,
146
+ candidates: readonly Candidate[],
147
+ ): RelaunchLedger {
148
+ const done = new Set(
149
+ candidates.filter((c) => c.disposition === 'complete' && c.roadmap !== null).map((c) => c.roadmap as string),
150
+ );
151
+ const out: RelaunchLedger = {};
152
+ for (const [k, v] of Object.entries(ledger)) {
153
+ if (!done.has(k)) out[k] = v;
154
+ }
155
+ return out;
156
+ }
157
+
158
+ export function readLedger(repoRoot: string): RelaunchLedger {
159
+ try {
160
+ const parsed: unknown = JSON.parse(
161
+ fs.readFileSync(path.join(repoRoot, SUPERVISE_STATE_REL), 'utf-8'),
162
+ );
163
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
164
+ const out: RelaunchLedger = {};
165
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
166
+ if (typeof v === 'number' && Number.isFinite(v) && v >= 0) out[k] = v;
167
+ }
168
+ return out;
169
+ } catch {
170
+ return {};
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Persist the relaunch ledger. THROWS on a failed write — never swallows.
176
+ *
177
+ * The swallow this replaces carried a comment asserting "there is NO CALLER …
178
+ * `writeLedger` is a writer waiting for one", and that was **false in the same
179
+ * file**: `digest` calls it at the `released > 0` branch below. The comment
180
+ * described the tree as it stood before R2 round 3 added the release, and the
181
+ * two comments then contradicted each other about the same function fourteen
182
+ * lines apart.
183
+ *
184
+ * The consequence was not cosmetic. A silently-failed write leaves the counter
185
+ * high while the digest has already printed `relaunch budget reset`, so the
186
+ * operator reads a release that did not happen — and the counter that "never
187
+ * releases" is precisely the failure R2 round 3 introduced `clearCompleted` to
188
+ * fix. Throwing hands the caller the choice; the caller below reports what
189
+ * actually happened rather than what it intended.
190
+ */
191
+ export function writeLedger(repoRoot: string, ledger: RelaunchLedger): void {
192
+ const file = path.join(repoRoot, SUPERVISE_STATE_REL);
193
+ fs.mkdirSync(path.dirname(file), { recursive: true });
194
+ fs.writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`, 'utf-8');
195
+ }
196
+
197
+ /**
198
+ * Classify one register record.
199
+ *
200
+ * Order matters and is the whole logic: a live session is never a candidate
201
+ * however far behind it is, and a finished roadmap is never a candidate
202
+ * however dead its session is. Only the intersection — dead session, real
203
+ * remaining work, budget left — is relaunchable.
204
+ */
205
+ export function classify(
206
+ repoRoot: string,
207
+ rec: SessionRecord,
208
+ ledger: RelaunchLedger,
209
+ now: Date,
210
+ ): Candidate {
211
+ const base = {
212
+ session_id: rec.session_id,
213
+ roadmap: rec.roadmap_slug,
214
+ worktree: rec.worktree,
215
+ platform: rec.platform,
216
+ relaunches: ledger[rec.roadmap_slug ?? ''] ?? 0,
217
+ };
218
+ if (!is_expired(rec, now)) {
219
+ return { ...base, disposition: 'alive', open_steps: null, reason: 'session is still beating' };
220
+ }
221
+ if (rec.roadmap_slug === null || rec.roadmap_slug === '') {
222
+ return {
223
+ ...base,
224
+ disposition: 'no-roadmap',
225
+ open_steps: null,
226
+ reason: 'session died holding no roadmap claim — nothing to resume',
227
+ };
228
+ }
229
+ let text: string;
230
+ try {
231
+ text = fs.readFileSync(roadmapPath(repoRoot, rec.roadmap_slug), 'utf-8');
232
+ } catch {
233
+ return {
234
+ ...base,
235
+ disposition: 'roadmap-unreadable',
236
+ open_steps: null,
237
+ reason: `claimed roadmap '${rec.roadmap_slug}' no longer reads — likely archived; the claim is stale, not the work`,
238
+ };
239
+ }
240
+ const counts = countRoadmap(text);
241
+ if (counts.open === 0) {
242
+ return {
243
+ ...base,
244
+ disposition: 'complete',
245
+ open_steps: 0,
246
+ reason: 'no open steps remain — the run finished, the session just never said so',
247
+ };
248
+ }
249
+ if (base.relaunches >= MAX_RELAUNCHES_PER_RUN) {
250
+ return {
251
+ ...base,
252
+ disposition: 'budget-exhausted',
253
+ open_steps: counts.open,
254
+ reason:
255
+ `${base.relaunches} relaunches already spent (cap ${MAX_RELAUNCHES_PER_RUN}) — ` +
256
+ `a run that dies this often has a problem a fourth session will not fix`,
257
+ };
258
+ }
259
+ return {
260
+ ...base,
261
+ disposition: 'relaunchable',
262
+ open_steps: counts.open,
263
+ reason: `${counts.open} open step(s) remain and the session is gone`,
264
+ };
265
+ }
266
+
267
+ export interface ScanOptions {
268
+ readonly now?: Date;
269
+ }
270
+
271
+ /**
272
+ * Every record in the register, expired ones INCLUDED — and it has to be its
273
+ * own reader rather than `read_live_records`, because that one filters expired
274
+ * records out by design. Reading only live records here would return exactly
275
+ * the sessions that need no supervision and none of the ones that do.
276
+ *
277
+ * It also never prunes, and since R2 round 4 finding 5 it no longer has to
278
+ * hope the other readers do not. `read_live_records({ prune: true })` holds an
279
+ * expired record for `PRUNE_GRACE_MS` (24 h, derived from this digest's daily
280
+ * cadence) before unlinking it, so the two routine read paths that DO prune —
281
+ * `sessions:list` and the session-start hook, which fires on every start —
282
+ * can no longer delete this watcher's entire input between one morning and the
283
+ * next.
284
+ *
285
+ * `read_live_records({ prune: true })` deletes expired
286
+ * records as it walks; deleting the evidence a watcher exists to act on would
287
+ * make the first scan the last one that could see anything.
288
+ */
289
+ export function readAllRecords(dir: string): SessionRecord[] {
290
+ let names: string[];
291
+ try {
292
+ names = fs.readdirSync(dir).filter((n) => n.endsWith('.json'));
293
+ } catch {
294
+ return [];
295
+ }
296
+ const out: SessionRecord[] = [];
297
+ for (const name of names) {
298
+ try {
299
+ const parsed: unknown = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf-8'));
300
+ if (
301
+ parsed !== null &&
302
+ typeof parsed === 'object' &&
303
+ !Array.isArray(parsed) &&
304
+ typeof (parsed as Record<string, unknown>)['session_id'] === 'string' &&
305
+ typeof (parsed as Record<string, unknown>)['last_seen'] === 'string'
306
+ ) {
307
+ out.push(parsed as SessionRecord);
308
+ }
309
+ } catch {
310
+ // An unparseable file carries no evidence of a run; skipping it is
311
+ // the same call `read_live_records` makes, minus the deletion.
312
+ }
313
+ }
314
+ return out.sort((a, b) => a.session_id.localeCompare(b.session_id));
315
+ }
316
+
317
+ export function scan(repoRoot: string, opts: ScanOptions = {}): Candidate[] {
318
+ const dir = register_dir(repoRoot);
319
+ if (dir === null) return [];
320
+ const now = opts.now ?? new Date();
321
+ const ledger = readLedger(repoRoot);
322
+ return readAllRecords(dir).map((rec) => classify(repoRoot, rec, ledger, now));
323
+ }
324
+
325
+ /**
326
+ * Build one resume plan per relaunchable candidate.
327
+ *
328
+ * Only `relaunchable` ones: an alive session needs no resume, a complete
329
+ * roadmap has nothing left, and a cap-exhausted run is refused before a
330
+ * command is worth printing. Printing a command for those would hand the
331
+ * operator a line that undoes the classification the scan just made.
332
+ */
333
+ export function resumePlans(
334
+ repoRoot: string,
335
+ candidates: readonly Candidate[],
336
+ now: Date,
337
+ ): ResumePlan[] {
338
+ const out: ResumePlan[] = [];
339
+ for (const c of candidates) {
340
+ if (c.disposition !== 'relaunchable' || c.roadmap === null) continue;
341
+ out.push(
342
+ planResume(
343
+ repoRoot,
344
+ {
345
+ roadmapSlug: c.roadmap,
346
+ worktree: c.worktree,
347
+ platform: c.platform,
348
+ // A worktree whose head cannot be read yields the literal
349
+ // `unknown`, which then rides into the dedup key. That is
350
+ // deliberate: an unreadable head must not collide with a
351
+ // real commit's key, and it is visible in the output rather
352
+ // than silently absent.
353
+ head: readHead(c.worktree) ?? 'unknown',
354
+ },
355
+ now,
356
+ ),
357
+ );
358
+ }
359
+ return out;
360
+ }
361
+
362
+ export function render(candidates: readonly Candidate[]): string {
363
+ if (candidates.length === 0) {
364
+ return 'run:supervise — no sessions registered for this workspace.\n';
365
+ }
366
+ const lines: string[] = [];
367
+ for (const c of candidates) {
368
+ const tag = c.disposition.toUpperCase().padEnd(18);
369
+ const road = c.roadmap ?? '-';
370
+ lines.push(` ${tag} ${c.session_id} roadmap=${road} ${c.reason}`);
371
+ }
372
+ const n = candidates.filter((c) => c.disposition === 'relaunchable').length;
373
+ lines.push('');
374
+ lines.push(
375
+ n === 0
376
+ ? 'run:supervise — nothing to relaunch.'
377
+ : `run:supervise — ${n} run(s) relaunchable. This process NEVER merges, pushes, or ` +
378
+ `closes anything; --relaunch only starts a fresh session.`,
379
+ );
380
+ return `${lines.join('\n')}\n`;
381
+ }
382
+
383
+ /**
384
+ * The morning digest (UOTL 7.2) — what happened while nobody was watching.
385
+ *
386
+ * The point of the phrasing in UOTL is "instead of permission prompts": an
387
+ * unattended lane that interrupts is not unattended, so the reporting has to
388
+ * be something a human reads once, on their own schedule.
389
+ *
390
+ * It reports only state that ALREADY EXISTS on disk — dead runs, memos
391
+ * written, budget consumed. No scheduling, no cron entry, no spawn. A digest
392
+ * over an empty tree is the honest output of a lane that has not run yet, and
393
+ * is much better than a scheduler that schedules something nothing can
394
+ * execute.
395
+ */
396
+ export function digest(repoRoot: string, candidates: readonly Candidate[], now: Date): string {
397
+ const lines = [`run:supervise digest · ${now.toISOString().slice(0, 10)}`, ''];
398
+
399
+ // The one write this read-report performs, and it is stated here rather
400
+ // than buried: a completed roadmap's relaunch counter is released, so the
401
+ // "three per run" cap bounds a run instead of a roadmap's lifetime
402
+ // (R2 round 3, finding 6).
403
+ //
404
+ // The report follows the WRITE and the DELTA, not the intent. It used to
405
+ // push "relaunch budget reset" unconditionally after a `writeLedger` that
406
+ // swallowed its own failure, so a digest could tell the operator a
407
+ // release had happened while the counter still stood — the direction that
408
+ // matters, because a stale-high counter refuses the next relaunch and the
409
+ // digest had just said it would not. Round 1 fixed the write half and
410
+ // left the count reading off the candidate set; both halves are needed,
411
+ // and a read-report that writes when nothing changed is the smaller half
412
+ // of the same mistake.
413
+ const before = readLedger(repoRoot);
414
+ const cleared = clearCompleted(before, candidates);
415
+ // The count is the LEDGER DELTA, never the candidate set.
416
+ //
417
+ // R2 round 2, finding 7, and it is the same defect one level up from the
418
+ // one round 1 fixed. Counting completed candidates reports a release for a
419
+ // roadmap that never had a counter — and with no relaunch mechanism
420
+ // shipping, the ledger is always empty, so EVERY such line was false. It
421
+ // also made a read-report write `{}` to disk on its first run.
422
+ const released = Object.keys(before).length - Object.keys(cleared).length;
423
+ if (released > 0) {
424
+ try {
425
+ writeLedger(repoRoot, cleared);
426
+ lines.push(` released: ${released} completed roadmap(s) — relaunch budget reset`);
427
+ } catch (err) {
428
+ lines.push(
429
+ ` released: NOT RESET — ${released} completed roadmap(s) still hold their ` +
430
+ `counter (ledger write failed: ${err instanceof Error ? err.message : String(err)})`,
431
+ );
432
+ }
433
+ }
434
+
435
+ const dead = candidates.filter((c) => c.disposition !== 'alive');
436
+ const relaunchable = candidates.filter((c) => c.disposition === 'relaunchable');
437
+ lines.push(
438
+ ` sessions: ${candidates.length} registered · ${candidates.length - dead.length} alive · ` +
439
+ `${relaunchable.length} relaunchable`,
440
+ );
441
+
442
+ const budget = readBudget(repoRoot, now);
443
+ lines.push(
444
+ budget.max_usd <= 0 && budget.max_tokens <= 0
445
+ ? ' budget: unattended runs DISABLED (no ceiling configured)'
446
+ : ` budget: $${budget.spent_usd}/${budget.max_usd} · ` +
447
+ `${budget.spent_tokens}/${budget.max_tokens} tokens · window ${budget.window_opened}`,
448
+ );
449
+
450
+ const memoRoot = path.join(repoRoot, 'agents', 'runtime', 'state', 'decisions');
451
+ let memoCount = 0;
452
+ let memoRuns = 0;
453
+ let entries: string[] = [];
454
+ try {
455
+ entries = fs.readdirSync(memoRoot);
456
+ } catch {
457
+ // no memos yet — reported as zero below, which is a real answer
458
+ }
459
+ for (const run of entries) {
460
+ // The try is INSIDE the loop. R2 review, finding 6: with it outside,
461
+ // one non-directory entry under decisions/ — a .DS_Store, a stray
462
+ // note — threw ENOTDIR, aborted the whole walk and reported whatever
463
+ // partial count had been reached, which is 0 when the bad entry sorts
464
+ // first. `interruption_report.readMemoCounts` walks the same tree with
465
+ // the try inside, so the two disagreed on identical data.
466
+ try {
467
+ const files = fs
468
+ .readdirSync(path.join(memoRoot, run))
469
+ .filter((n) => /^\d{3}\.md$/.test(n));
470
+ if (files.length > 0) {
471
+ memoRuns += 1;
472
+ memoCount += files.length;
473
+ }
474
+ } catch {
475
+ // a file where a directory was expected — not a run
476
+ }
477
+ }
478
+ lines.push(` decisions: ${memoCount} memo(s) across ${memoRuns} run(s)`);
479
+
480
+ if (relaunchable.length > 0) {
481
+ lines.push('');
482
+ lines.push(' needs attention:');
483
+ for (const c of relaunchable) {
484
+ lines.push(` ${c.session_id} roadmap=${c.roadmap ?? '-'} ${c.reason}`);
485
+ }
486
+ }
487
+ lines.push('');
488
+ lines.push(' This digest reports state; it schedules nothing and starts nothing.');
489
+ return `${lines.join('\n')}\n`;
490
+ }
491
+
492
+ const USAGE = `usage: run_supervise [--root PATH] [--once] [--digest] [--interval SECONDS]
493
+ [--print-relaunch] [--relaunch]
494
+
495
+ Watches the session register for runs whose session died with open steps left.
496
+
497
+ --root PATH repository root (default: cwd)
498
+ --once one scan, then exit. REQUIRED today: looping is not
499
+ implemented, so omitting this exits 2 with that message.
500
+ --digest the morning report instead of the per-session list:
501
+ dead runs, decision memos written, budget consumed.
502
+ Reports state; schedules nothing, starts nothing.
503
+ --interval SECONDS accepted and IGNORED. It used to say "reserved for the
504
+ loop driver that lands with the Phase 4.0 primitive";
505
+ that primitive is a published refusal now, so nothing is
506
+ scheduled to land and the flag has no future caller.
507
+ Listed rather than hidden so it is not silently dropped,
508
+ and marked rather than documented with a default (an
509
+ earlier round: it advertised "default: 60" and was read
510
+ by nothing, so an operator following this help got exit 2
511
+ and no watcher).
512
+ --print-relaunch PRINT the exact command that resumes each relaunchable
513
+ run, plus what an unattended lane would decide about the
514
+ same run. Starts no SESSION and spends nothing; it does
515
+ run one \`git remote -v\` per plan, which is the guard's
516
+ production-remote precondition. The command is for a
517
+ human to paste; the unattended verdict never gates what
518
+ a human may run.
519
+ --relaunch REFUSED. Starting a session unattended is a published
520
+ refusal (road-to-long-horizon-execution 4.0, AI council
521
+ 2026-08-19), not an unbuilt feature — the flag exits 2
522
+ naming the decision and its reopen condition. The
523
+ ${MAX_RELAUNCHES_PER_RUN}-per-run cap and its ledger stay
524
+ in place for the day the refusal is reopened.
525
+
526
+ Never merges, never pushes, never closes a PR. Set
527
+ ${HALT_ENV}=1 to stop the watcher.
528
+ `;
529
+
530
+ function argValue(argv: string[], flag: string): string | null {
531
+ const i = argv.indexOf(flag);
532
+ if (i === -1 || i + 1 >= argv.length) return null;
533
+ return argv[i + 1] ?? null;
534
+ }
535
+
536
+ export function main(argv: string[] = process.argv.slice(2)): number {
537
+ if (argv.includes('--help') || argv.includes('-h')) {
538
+ process.stdout.write(USAGE);
539
+ return 0;
540
+ }
541
+ if (String(process.env[HALT_ENV] ?? '').trim() !== '') {
542
+ process.stdout.write(`run:supervise — ${HALT_ENV} is set; watcher does not start.\n`);
543
+ return 0;
544
+ }
545
+ // Report-shape flags are mutually exclusive, and a silent precedence is
546
+ // the wrong way to say so (R2 round 2, finding 10): `--digest
547
+ // --print-relaunch` used to print the digest and drop the plans without a
548
+ // word, and `--print-relaunch --relaunch` returned 0 without ever emitting
549
+ // the refusal — success as the exit code of the one flag whose entire
550
+ // purpose is to refuse.
551
+ const shapes = ['--digest', '--print-relaunch', '--relaunch'].filter((f) => argv.includes(f));
552
+ if (shapes.length > 1) {
553
+ process.stderr.write(
554
+ `run:supervise: ${shapes.join(' and ')} are mutually exclusive — each is a ` +
555
+ `different report, and one silently winning would hide the other. Pick one.\n`,
556
+ );
557
+ return 2;
558
+ }
559
+
560
+ const repoRoot = argValue(argv, '--root') ?? process.cwd();
561
+ const now = new Date();
562
+ const candidates = scan(repoRoot, { now });
563
+ if (argv.includes('--digest')) {
564
+ process.stdout.write(digest(repoRoot, candidates, now));
565
+ return 0;
566
+ }
567
+ process.stdout.write(render(candidates));
568
+
569
+ if (argv.includes('--print-relaunch')) {
570
+ process.stdout.write(renderResumePlans(resumePlans(repoRoot, candidates, now)));
571
+ return 0;
572
+ }
573
+
574
+ if (argv.includes('--relaunch')) {
575
+ // A REFUSAL, not a pending implementation. The previous wording —
576
+ // "accepted but NOT implemented … is Phase 4.0 … and is not built" —
577
+ // is the sentence that makes an operator wait for a release nobody is
578
+ // preparing, which is D-5's indefinite-pending shape reproduced inside
579
+ // the roadmap that names it. The decision and its reopen condition are
580
+ // in `LIVE_SPAWN_REFUSAL`.
581
+ process.stderr.write(`${LIVE_SPAWN_REFUSAL}\n`);
582
+ return 2;
583
+ }
584
+
585
+ if (!argv.includes('--once')) {
586
+ process.stderr.write(
587
+ 'run:supervise: looping is not implemented — use --once. The scan above is the ' +
588
+ 'full surface (Phase 3.1: a foreground loop is enough to falsify the design). ' +
589
+ 'The loop driver was tied to the Phase 4.0 spawn, which is a published refusal, ' +
590
+ 'so it is not pending — there is nothing to wait for.\n',
591
+ );
592
+ return 2;
593
+ }
594
+ return 0;
595
+ }
596
+
597
+ function _isCliEntry(): boolean {
598
+ if (process.argv[1] === undefined) return false;
599
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
600
+ if (import.meta.url === argvUrl) return true;
601
+ try {
602
+ return (
603
+ fs.realpathSync(fileURLToPath(import.meta.url)) ===
604
+ fs.realpathSync(path.resolve(process.argv[1]))
605
+ );
606
+ } catch {
607
+ return false;
608
+ }
609
+ }
610
+
611
+ if (_isCliEntry()) {
612
+ process.exit(main());
613
+ }
614
+
615
+ /**
616
+ * Re-exported so a resumed run reaches its checkpoint through one path.
617
+ *
618
+ * `latestCheckpointFor` is the one a RELAUNCHED session can actually use: it
619
+ * keys on the roadmap slug, which such a session holds by definition, whereas
620
+ * `readCheckpoint` keys on the run id derived from the session that died.
621
+ * R2 review, finding 7 — the checkpoint was write-only until this existed.
622
+ */
623
+ export { latestCheckpointFor, readCheckpoint };