@haiyangbg/buildbeat 2.0.0-beta.3 → 2.0.0-beta.4

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 (53) hide show
  1. package/CHANGELOG.md +29 -7
  2. package/SKILL.md +76 -2
  3. package/docs/CLI-PILOT-2026-08-23.md +1 -1
  4. package/docs/CLI.md +1 -1
  5. package/docs/EXECUTION-PLAN.md +2 -2
  6. package/docs/PHASE2-PILOT-PREFLIGHT-2026-08-25.md +2 -2
  7. package/docs/PHASE4-V1.20-PILOT-2026-08-25.md +2 -2
  8. package/docs/RELEASING.md +1 -1
  9. package/docs/V2-D2-DECISION-CARD.md +2 -2
  10. package/docs/V2-DECISIONS.md +2 -2
  11. package/docs/V2-ITERATION-01.md +13 -13
  12. package/docs/V2-ITERATION-06.md +2 -2
  13. package/docs/V2-ITERATION-08.md +62 -0
  14. package/docs/V2-PLAN.md +6 -6
  15. package/docs/V2-PROPOSAL.md +2 -2
  16. package/docs/V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md +1 -1
  17. package/docs/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md +1 -1
  18. package/docs/V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md +8 -0
  19. package/docs/v2/M4-EXTERNAL-PILOT-2026-08-28.md +11 -11
  20. package/docs/v2/{M4-CHICKAI-PILOT-2026-08-28.md → M4-PILOT-APP-2026-08-28.md} +4 -4
  21. package/docs/v2/M4-SELFHOST-2026-08-28.md +1 -1
  22. package/docs/v2/RFC-0001-product-definition.md +2 -2
  23. package/docs/v2/SPEC-0001-events-v1.md +2 -2
  24. package/docs/v2/guide/00-how-to-talk.md +57 -0
  25. package/docs/v2/guide/01-quickstart.md +4 -0
  26. package/docs/v2/guide/02-workflow-guide.md +14 -0
  27. package/docs/v2/guide/04-adapter-guide.md +4 -0
  28. package/docs/v2/guide/05-worker-contract.md +10 -0
  29. package/docs/v2/guide/06-evidence-guide.md +4 -0
  30. package/docs/v2/guide/07-approval-guide.md +33 -0
  31. package/docs/v2/guide/10-recovery.md +22 -1
  32. package/docs/v2/guide/README.md +3 -0
  33. package/example/.buildbeat/manifest.json +1 -1
  34. package/lessons.md +12 -0
  35. package/package.json +1 -1
  36. package/src/v2/adapters/shell.js +87 -14
  37. package/src/v2/cli/run.js +461 -25
  38. package/src/v2/engine/reducer.js +2 -0
  39. package/src/v2/engine/workflow.js +8 -1
  40. package/src/v2/evidence/collector.js +14 -3
  41. package/src/v2/presets/release-readback.yaml +36 -0
  42. package/src/v2/presets/risk/release.yaml +21 -0
  43. package/src/v2/runtime/cache.js +124 -0
  44. package/src/v2/runtime/env-contract.js +35 -1
  45. package/src/v2/runtime/envelope.js +183 -0
  46. package/src/v2/runtime/gc.js +182 -0
  47. package/src/v2/runtime/liveness.js +193 -0
  48. package/src/v2/runtime/metrics.js +8 -0
  49. package/src/v2/runtime/notify.js +223 -0
  50. package/src/v2/runtime/orchestrator.js +145 -8
  51. package/src/v2/runtime/overview.js +264 -0
  52. package/templates/v2/AGENTS.md +72 -0
  53. package/templates/v2//346/214/207/346/214/245/345/217/260.md +36 -0
package/src/v2/cli/run.js CHANGED
@@ -2,11 +2,18 @@
2
2
  // M1 runtime CLI: run start / status / stop for a single foreground run.
3
3
  // Deliberately thin — all facts live in the event ledger; this file only
4
4
  // parses input, wires adapters, and renders derived state.
5
+ //
6
+ // Iteration 08 adds the human-facing layer the deploy campaign showed was
7
+ // missing: elapsed/typical time and live output in `status`, next-reply
8
+ // commands wherever a run waits, supersession of stale waits, `gc` for the
9
+ // runtime plane, `watch` + outbound notifications so a stopped run reaches
10
+ // its human.
5
11
 
6
- import { execFileSync, spawnSync } from "node:child_process";
12
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
7
13
  import { createHash } from "node:crypto";
8
14
  import { existsSync, readFileSync } from "node:fs";
9
- import { dirname, isAbsolute, join, resolve } from "node:path";
15
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
16
+ import { fileURLToPath } from "node:url";
10
17
 
11
18
  import { createShellAdapter } from "../adapters/shell.js";
12
19
  import { loadRiskPreset } from "../engine/risk-preset.js";
@@ -22,7 +29,24 @@ import {
22
29
  latestAdjudications,
23
30
  readFindingsAccount,
24
31
  } from "../runtime/findings.js";
32
+ import { loadEnvelope, nextAttemptId } from "../runtime/envelope.js";
33
+ import { applyGc, planGc } from "../runtime/gc.js";
34
+ import { computeOverview, renderOverview } from "../runtime/overview.js";
35
+ import {
36
+ DEFAULT_STALL_AFTER_MS,
37
+ describeLiveness,
38
+ formatMs,
39
+ tailLive,
40
+ } from "../runtime/liveness.js";
25
41
  import { computeMetrics, renderMetrics } from "../runtime/metrics.js";
42
+ import {
43
+ NOTIFY_CONFIG,
44
+ buildNotification,
45
+ dispatchNotification,
46
+ loadNotifyConfig,
47
+ nextReply,
48
+ subscribes,
49
+ } from "../runtime/notify.js";
26
50
  import { writeRunRecord } from "../runtime/run-record.js";
27
51
  import { resumeRun, startRun } from "../runtime/orchestrator.js";
28
52
  import { toRepoRef } from "../runtime/repo-ref.js";
@@ -34,10 +58,11 @@ const KERNEL = { kind: "kernel", id: "cli" };
34
58
  const USAGE = `BuildBeat v2 runtime
35
59
 
36
60
  Usage:
37
- run.js start --config <run-config.yaml>
61
+ run.js start --config <run-config.yaml> [--attempt new]
38
62
  run.js resume --config <run-config.yaml>
39
- run.js status --repo <path> --run <RUN-ID>
63
+ run.js status --repo <path> --run <RUN-ID> [--stall-after <minutes>]
40
64
  run.js inbox --repo <path>
65
+ run.js overview --repo <path> [--work <WORK-ID>] [--json true]
41
66
  run.js approve --repo <path> --run <RUN-ID> --transition <t> [--by <name>] [--config <run-config.yaml>]
42
67
  run.js reject --repo <path> --run <RUN-ID> [--transition <t>] [--reason <text>] [--by <name>]
43
68
  run.js accept --repo <path> --work <WORK-ID> --artifact <plan|intent|spec> [--by <name>]
@@ -46,6 +71,8 @@ Usage:
46
71
  run.js replay --repo <path> --run <RUN-ID>
47
72
  run.js metrics --repo <path> [--json true]
48
73
  run.js stop --repo <path> --run <RUN-ID> --reason <text>
74
+ run.js gc --repo <path> [--apply true] [--force true]
75
+ run.js watch --repo <path> --run <RUN-ID> [--stall-after <minutes>] [--interval <seconds>] [--once true]
49
76
  run.js observe run --config <observe.yaml>
50
77
  run.js observe status --repo <path>
51
78
  run.js observe triage --repo <path> --intent <ref> --action <fix_now|schedule|dismiss> [--by <name>] [--note <text>]
@@ -71,7 +98,45 @@ function ledgerPathFor(repo, runId) {
71
98
  return join(resolve(repo), ".buildbeat", "runtime", "runs", runId, "events.jsonl");
72
99
  }
73
100
 
74
- function printState(state, ledger) {
101
+ function stallAfterFromFlags(flags, fallbackMs = DEFAULT_STALL_AFTER_MS) {
102
+ if (flags["stall-after-ms"] !== undefined) {
103
+ return Number(flags["stall-after-ms"]);
104
+ }
105
+ if (flags["stall-after"] !== undefined) {
106
+ return Number(flags["stall-after"]) * 60 * 1000;
107
+ }
108
+ return fallbackMs;
109
+ }
110
+
111
+ // The --repo value to print in copyable commands. Inside the project it is
112
+ // the relative path the user would type; outside it a placeholder — printing
113
+ // a machine-local absolute path is exactly what the output must never do.
114
+ function repoLabelFor(repoRoot, typed) {
115
+ if (typed && !isAbsolute(typed)) {
116
+ return typed;
117
+ }
118
+ const rel = relative(process.cwd(), repoRoot);
119
+ if (rel === "") {
120
+ return ".";
121
+ }
122
+ if (rel.startsWith("..") || isAbsolute(rel)) {
123
+ return "<repo-path>";
124
+ }
125
+ return rel;
126
+ }
127
+
128
+ function printNextReply(repoLabel, state) {
129
+ const lines = nextReply({ repoLabel, state });
130
+ if (lines.length === 0) {
131
+ return;
132
+ }
133
+ console.log("next (copy one):");
134
+ for (const line of lines) {
135
+ console.log(` ${line}`);
136
+ }
137
+ }
138
+
139
+ function printState(state, ledger, view = {}) {
75
140
  if (ledger.corruption) {
76
141
  console.log(
77
142
  `WARNING: ledger corrupted after seq=${ledger.corruption.afterSeq} at line ${ledger.corruption.atLine}: ${ledger.corruption.reason}`,
@@ -81,6 +146,15 @@ function printState(state, ledger) {
81
146
  console.log("no run recorded");
82
147
  return;
83
148
  }
149
+ const liveness =
150
+ view.repoRoot && ledger.events
151
+ ? describeLiveness({
152
+ repoRoot: view.repoRoot,
153
+ runId: state.run.id,
154
+ ledger,
155
+ stallAfterMs: view.stallAfterMs ?? DEFAULT_STALL_AFTER_MS,
156
+ })
157
+ : { steps: {}, inFlight: null };
84
158
  console.log(`run: ${state.run.id} (work ${state.run.work})`);
85
159
  console.log(`status: ${state.run.status}`);
86
160
  console.log(`workflow: ${state.run.workflowRef} @ ${state.run.workflowDigest}`);
@@ -90,17 +164,62 @@ function printState(state, ledger) {
90
164
  );
91
165
  }
92
166
  for (const [step, info] of Object.entries(state.steps)) {
93
- console.log(`step ${step}: ${info.status} (attempts ${info.attempts})`);
167
+ const timing = liveness.steps[step];
168
+ let suffix = "";
169
+ if (timing && info.status !== "RUNNING") {
170
+ const parts = [];
171
+ if (timing.lastMs !== null) {
172
+ parts.push(`last ${formatMs(timing.lastMs)}`);
173
+ }
174
+ if (timing.attempts > 1) {
175
+ parts.push(`total ${formatMs(timing.totalMs)}`);
176
+ }
177
+ if (timing.typicalMs !== null) {
178
+ parts.push(`typical ${formatMs(timing.typicalMs)} n=${timing.samples}`);
179
+ }
180
+ if (parts.length > 0) {
181
+ suffix = ` [${parts.join(", ")}]`;
182
+ }
183
+ }
184
+ console.log(`step ${step}: ${info.status} (attempts ${info.attempts})${suffix}`);
185
+ }
186
+ const live = liveness.inFlight;
187
+ if (live) {
188
+ const typical = live.typicalMs !== null ? `, typical ${formatMs(live.typicalMs)} n=${live.samples}` : "";
189
+ console.log(
190
+ `in flight: ${live.step} attempt ${live.attempt} since ${live.startedAt} (elapsed ${formatMs(live.elapsedMs)}${typical})`,
191
+ );
192
+ if (live.command) {
193
+ console.log(` worker: ${live.command}`);
194
+ }
195
+ if (live.lastOutputAt) {
196
+ console.log(` last output: ${live.lastOutputAt} (${formatMs(live.sinceOutputMs)} ago, ${live.bytes} bytes so far)`);
197
+ } else {
198
+ console.log(` last output: (none yet, ${formatMs(live.sinceOutputMs)} since start)`);
199
+ }
200
+ if (live.stalled) {
201
+ console.log(
202
+ ` STALLED: no output for ${formatMs(live.sinceOutputMs)} (threshold ${formatMs(live.stallAfterMs)}); process not killed — inspect, then stop or wait`,
203
+ );
204
+ }
205
+ const tail = tailLive(view.repoRoot, state.run.id, 3);
206
+ for (const line of tail) {
207
+ console.log(` | ${line.slice(0, 160)}`);
208
+ }
94
209
  }
95
210
  for (const item of state.evidence) {
96
211
  const ref = isAbsolute(item.ref) ? "<legacy-absolute-evidence-ref>" : item.ref;
97
- console.log(`evidence [${item.status}/${item.grade}] ${item.kind} ${ref}`);
212
+ const reused = item.reused ? ` (reused from ${item.reused.run})` : "";
213
+ console.log(`evidence [${item.status}/${item.grade}] ${item.kind} ${ref}${reused}`);
98
214
  }
99
215
  if (state.pendingHuman) {
100
216
  console.log(`waiting on human: ${state.pendingHuman.transition}`);
101
217
  for (const reason of state.pendingHuman.reasons) {
102
218
  console.log(` reason: ${reason}`);
103
219
  }
220
+ if (view.repoLabel) {
221
+ printNextReply(view.repoLabel, state);
222
+ }
104
223
  }
105
224
  if (state.terminal) {
106
225
  console.log(`terminal: ${state.terminal.status} (${state.terminal.reason})`);
@@ -156,8 +275,35 @@ function loadRunConfig(flags, command) {
156
275
  if (config.reviewTriage !== undefined && !["required", "off"].includes(config.reviewTriage)) {
157
276
  throw new Error(`reviewTriage must be "required" or "off", got: ${config.reviewTriage}`);
158
277
  }
278
+ if (config.supersede !== undefined && !["waiting", "off"].includes(config.supersede)) {
279
+ throw new Error(`supersede must be "waiting" or "off", got: ${config.supersede}`);
280
+ }
281
+ if (config.stallAfterMs !== undefined && !(Number(config.stallAfterMs) > 0)) {
282
+ throw new Error(`stallAfterMs must be a positive number, got: ${config.stallAfterMs}`);
283
+ }
284
+ const cache = {};
285
+ for (const [step, mode] of Object.entries(config.cache ?? {})) {
286
+ if (mode !== "tree") {
287
+ throw new Error(`cache.${step} must be "tree", got: ${mode}`);
288
+ }
289
+ if (!workflow.stepIds.has(step)) {
290
+ throw new Error(`cache.${step}: step not in workflow`);
291
+ }
292
+ cache[step] = mode;
293
+ }
294
+ const redact = (config.redact ?? []).map((pattern) => {
295
+ try {
296
+ return new RegExp(String(pattern), "g");
297
+ } catch {
298
+ throw new Error(`redact pattern is not a valid regular expression: ${pattern}`);
299
+ }
300
+ });
301
+ const envelope = loadEnvelope(config, configDir, Object.keys(config.workers ?? {}));
159
302
 
160
303
  return {
304
+ envelope,
305
+ cache,
306
+ redact,
161
307
  repoRoot,
162
308
  workflow,
163
309
  workflowDigest,
@@ -175,54 +321,178 @@ function loadRunConfig(flags, command) {
175
321
  allowedPaths: config.allowedPaths,
176
322
  requires: config.requires ?? [],
177
323
  reviewTriage: config.reviewTriage === "required" ? "required" : null,
324
+ supersede: config.supersede ?? "waiting",
325
+ stallAfterMs: config.stallAfterMs !== undefined ? Number(config.stallAfterMs) : DEFAULT_STALL_AFTER_MS,
178
326
  planDigest: digestOfWorkFile("plan.md"),
179
327
  intentDigest: digestOfWorkFile("intent.md"),
180
328
  };
181
329
  }
182
330
 
183
- function commandStart(flags) {
331
+ // Notification config is optional and never fatal for a run: a broken file
332
+ // is reported once and the run proceeds without outbound messages.
333
+ function notifyConfigFor(repoRoot) {
334
+ try {
335
+ return { config: loadNotifyConfig(repoRoot), error: null };
336
+ } catch (error) {
337
+ return { config: null, error: error.message };
338
+ }
339
+ }
340
+
341
+ async function notifyForState(repoRoot, repoLabel, state) {
342
+ const { config, error } = notifyConfigFor(repoRoot);
343
+ if (error) {
344
+ console.log(`notify: config ignored (${error})`);
345
+ return;
346
+ }
347
+ if (!config || !state.run) {
348
+ return;
349
+ }
350
+ const kinds = [];
351
+ if (state.pendingHuman && state.run.status === "WAITING_HUMAN") {
352
+ kinds.push("HUMAN_REQUESTED");
353
+ }
354
+ if (state.terminal) {
355
+ kinds.push("RUN_TERMINAL");
356
+ }
357
+ for (const kind of kinds) {
358
+ if (!subscribes(config, kind)) {
359
+ continue;
360
+ }
361
+ const results = await dispatchNotification(config, buildNotification(kind, { repoLabel, state }), { repoRoot });
362
+ for (const row of results) {
363
+ const outcome = row.ok ? "sent" : row.skipped ? `skipped (${row.error})` : `FAILED (${row.error})`;
364
+ console.log(`notify ${kind} -> ${row.channel}: ${outcome}`);
365
+ }
366
+ }
367
+ }
368
+
369
+ // A stall watcher is a separate detached process: the orchestrator blocks in
370
+ // spawnSync while a worker runs, so it cannot look at the clock itself. The
371
+ // watcher exits on its own once the run is no longer RUNNING or the parent
372
+ // process is gone.
373
+ function spawnStallWatcher(repoRoot, runId, stallAfterMs) {
374
+ const { config } = notifyConfigFor(repoRoot);
375
+ if (!subscribes(config, "STALLED")) {
376
+ return false;
377
+ }
378
+ const child = spawn(
379
+ process.execPath,
380
+ [
381
+ fileURLToPath(import.meta.url),
382
+ "watch",
383
+ "--repo",
384
+ repoRoot,
385
+ "--run",
386
+ runId,
387
+ "--stall-after-ms",
388
+ String(stallAfterMs),
389
+ "--parent",
390
+ String(process.pid),
391
+ ],
392
+ { detached: true, stdio: "ignore" },
393
+ );
394
+ child.unref();
395
+ return true;
396
+ }
397
+
398
+ async function commandStart(flags) {
184
399
  const options = loadRunConfig(flags, "start");
400
+ if (flags.attempt !== undefined) {
401
+ if (flags.attempt !== "new") {
402
+ throw new Error(`--attempt must be "new" (auto-number the next run of this family), got: ${flags.attempt}`);
403
+ }
404
+ // The run config names the family; the kernel numbers the attempt. One
405
+ // config per work, not one per retry (the campaign hand-numbered -01..-30).
406
+ options.runId = nextAttemptId(options.repoRoot, options.workId, options.runId);
407
+ console.log(`attempt: ${options.runId}`);
408
+ }
409
+ if (options.envelope) {
410
+ console.log(`envelope: ${options.envelope.source} (${Object.keys(options.envelope.prompts).join(", ")}) ${options.envelope.digest}`);
411
+ }
185
412
  if (process.stdout.isTTY) {
186
413
  // Run launch discipline (real incident: a host-tool timeout killed a
187
414
  // verify worker mid-run): anything longer than minutes belongs in a
188
415
  // detached process, not an interactive foreground shell.
189
416
  console.log("tip: long runs should be started detached (nohup/setsid); interactive shells die with their host");
190
417
  }
418
+ const watching = spawnStallWatcher(options.repoRoot, options.runId, options.stallAfterMs);
419
+ if (watching) {
420
+ console.log(`stall watcher armed (no output for ${formatMs(options.stallAfterMs)} notifies STALLED)`);
421
+ }
191
422
  const result = startRun(options);
423
+ const repoLabel = repoLabelFor(options.repoRoot);
424
+ for (const run of result.superseded ?? []) {
425
+ console.log(`superseded ${run} (was waiting on a human for the same work; now SUPERSEDED)`);
426
+ }
427
+ for (const row of result.supersedeSkipped ?? []) {
428
+ console.log(`could not supersede ${row.run}: ${row.reason}`);
429
+ }
192
430
  console.log(`ledger: ${toRepoRef(options.repoRoot, result.ledgerPath)}`);
193
- printState(result.state, { corruption: null });
431
+ const ledger = EventLedger.open(result.ledgerPath);
432
+ printState(ledger.state, ledger, {
433
+ repoRoot: options.repoRoot,
434
+ repoLabel,
435
+ stallAfterMs: options.stallAfterMs,
436
+ });
437
+ await notifyForState(options.repoRoot, repoLabel, ledger.state);
194
438
  }
195
439
 
196
- function commandResume(flags) {
440
+ async function commandResume(flags) {
197
441
  const options = loadRunConfig(flags, "resume");
442
+ const watching = spawnStallWatcher(options.repoRoot, options.runId, options.stallAfterMs);
443
+ if (watching) {
444
+ console.log(`stall watcher armed (no output for ${formatMs(options.stallAfterMs)} notifies STALLED)`);
445
+ }
198
446
  const result = resumeRun(options);
447
+ const repoLabel = repoLabelFor(options.repoRoot);
199
448
  if (!result.resumed) {
200
449
  console.log(`nothing to resume: ${result.reason}`);
201
450
  }
202
451
  console.log(`ledger: ${toRepoRef(options.repoRoot, result.ledgerPath)}`);
203
- printState(result.state, { corruption: null });
452
+ const ledger = EventLedger.open(result.ledgerPath);
453
+ printState(ledger.state, ledger, {
454
+ repoRoot: options.repoRoot,
455
+ repoLabel,
456
+ stallAfterMs: options.stallAfterMs,
457
+ });
458
+ if (result.resumed) {
459
+ await notifyForState(options.repoRoot, repoLabel, ledger.state);
460
+ }
204
461
  }
205
462
 
206
463
  function commandInbox(flags) {
207
464
  if (!flags.repo) {
208
465
  throw new Error("inbox requires --repo");
209
466
  }
210
- const rows = listInbox(resolve(flags.repo));
467
+ const repoRoot = resolve(flags.repo);
468
+ const rows = listInbox(repoRoot);
211
469
  if (rows.length === 0) {
212
470
  console.log("inbox empty: no runs waiting on a human");
213
471
  return;
214
472
  }
215
- for (const row of rows) {
473
+ const sorted = [...rows].sort((a, b) => `${a.work ?? ""}${a.run}`.localeCompare(`${b.work ?? ""}${b.run}`));
474
+ let lastWork = null;
475
+ for (const row of sorted) {
216
476
  if (row.corrupted) {
217
477
  console.log(`${row.run}: LEDGER CORRUPTED after seq=${row.corrupted.afterSeq} (${row.corrupted.reason})`);
218
478
  continue;
219
479
  }
220
- console.log(`${row.run} (work ${row.work}) [${row.kind}] ${row.transition}`);
221
- console.log(` candidate: ${row.subject.candidate}`);
222
- console.log(` planDigest: ${row.subject.planDigest}`);
223
- console.log(` evidenceDigest: ${row.subject.evidenceDigest}`);
480
+ if (row.work !== lastWork) {
481
+ console.log(`work ${row.work}:`);
482
+ lastWork = row.work;
483
+ }
484
+ const ledger = EventLedger.open(ledgerPathFor(repoRoot, row.run));
485
+ const requested = [...ledger.events].reverse().find((event) => event.type === "HUMAN_REQUESTED");
486
+ const age = requested ? formatMs(Date.now() - Date.parse(requested.ts)) : "?";
487
+ console.log(` ${row.run} [${row.kind}] ${row.transition} — waiting ${age}${requested ? ` (since ${requested.ts})` : ""}`);
488
+ console.log(` candidate: ${row.subject.candidate}`);
489
+ console.log(` planDigest: ${row.subject.planDigest}`);
490
+ console.log(` evidenceDigest: ${row.subject.evidenceDigest}`);
224
491
  for (const reason of row.reasons) {
225
- console.log(` reason: ${reason}`);
492
+ console.log(` reason: ${reason}`);
493
+ }
494
+ for (const line of nextReply({ repoLabel: repoLabelFor(repoRoot, flags.repo), state: ledger.state })) {
495
+ console.log(` next: ${line}`);
226
496
  }
227
497
  }
228
498
  }
@@ -335,6 +605,20 @@ function commandDoctor(flags) {
335
605
  } else {
336
606
  console.log("environment contract: none declared (implicit PATH facts stay unchecked)");
337
607
  }
608
+ console.log(`supersede: ${options.supersede} (new run for the same work ${options.supersede === "off" ? "leaves" : "supersedes"} older WAITING_HUMAN runs)`);
609
+ console.log(`stall threshold: ${formatMs(options.stallAfterMs)} without worker output`);
610
+ const { config: notify, error: notifyError } = notifyConfigFor(options.repoRoot);
611
+ if (notifyError) {
612
+ console.log(`notify: PROBLEM ${notifyError}`);
613
+ } else if (!notify) {
614
+ console.log(`notify: none (${NOTIFY_CONFIG} absent; a waiting run reaches nobody until someone runs inbox)`);
615
+ } else {
616
+ console.log("notify channels:");
617
+ for (const channel of notify.channels) {
618
+ const urlState = process.env[channel.urlEnv] ? "url env set" : `WARNING env ${channel.urlEnv} not set in this shell`;
619
+ console.log(` ${channel.id}: type=${channel.type} events=${channel.events.join(",")} (${urlState})`);
620
+ }
621
+ }
338
622
  }
339
623
 
340
624
  // Preflight: run one step's configured worker command directly in the main
@@ -469,7 +753,7 @@ function commandReplay(flags) {
469
753
  } else {
470
754
  console.log(`chain OK: ${ledger.events.length} events verified (digest/prev/seq)`);
471
755
  }
472
- printState(ledger.state, { corruption: null });
756
+ printState(ledger.state, { corruption: null }, {});
473
757
  }
474
758
 
475
759
  function commandMetrics(flags) {
@@ -488,8 +772,13 @@ function commandStatus(flags) {
488
772
  if (!flags.repo || !flags.run) {
489
773
  throw new Error("status requires --repo and --run");
490
774
  }
491
- const ledger = EventLedger.open(ledgerPathFor(flags.repo, flags.run));
492
- printState(ledger.state, ledger);
775
+ const repoRoot = resolve(flags.repo);
776
+ const ledger = EventLedger.open(ledgerPathFor(repoRoot, flags.run));
777
+ printState(ledger.state, ledger, {
778
+ repoRoot,
779
+ repoLabel: repoLabelFor(repoRoot, flags.repo),
780
+ stallAfterMs: stallAfterFromFlags(flags),
781
+ });
493
782
  }
494
783
 
495
784
  function commandStop(flags) {
@@ -519,6 +808,147 @@ function commandStop(flags) {
519
808
  }
520
809
  }
521
810
 
811
+ function commandOverview(flags) {
812
+ if (!flags.repo) {
813
+ throw new Error("overview requires --repo");
814
+ }
815
+ const repoRoot = resolve(flags.repo);
816
+ const rows = computeOverview(repoRoot, { work: flags.work ?? null, repoLabel: repoLabelFor(repoRoot, flags.repo) });
817
+ if (flags.json === "true") {
818
+ console.log(JSON.stringify(rows, null, 2));
819
+ return;
820
+ }
821
+ console.log(renderOverview(rows));
822
+ }
823
+
824
+ function commandGc(flags) {
825
+ if (!flags.repo) {
826
+ throw new Error("gc requires --repo");
827
+ }
828
+ const repoRoot = resolve(flags.repo);
829
+ const rows = planGc(repoRoot);
830
+ if (rows.length === 0) {
831
+ console.log("gc: no run ledgers found");
832
+ return;
833
+ }
834
+ let actionable = 0;
835
+ for (const row of rows) {
836
+ const summary = row.actions.map((action) => {
837
+ if (action.kind === "remove-worktree") {
838
+ return `remove worktree${action.dirty ? " (DIRTY, needs --force)" : ""}`;
839
+ }
840
+ if (action.kind === "delete-branch") {
841
+ return `delete branch ${action.branch} (${action.reason})`;
842
+ }
843
+ return "remove stale lock";
844
+ });
845
+ actionable += row.actions.length;
846
+ const keep = row.keep.map((reason) => `keep: ${reason}`);
847
+ const parts = [...summary, ...keep];
848
+ console.log(`${row.run} [${row.status}] ${parts.length > 0 ? parts.join("; ") : "nothing to do"}`);
849
+ }
850
+ if (flags.apply !== "true") {
851
+ console.log(
852
+ actionable > 0
853
+ ? `plan only: ${actionable} action(s); rerun with --apply true to execute (branches whose candidate lives only there are always kept)`
854
+ : "nothing to collect",
855
+ );
856
+ return;
857
+ }
858
+ const results = applyGc(repoRoot, rows, { force: flags.force === "true" });
859
+ let done = 0;
860
+ for (const result of results) {
861
+ const target = result.kind === "delete-branch" ? result.branch : result.path;
862
+ if (result.done) {
863
+ done += 1;
864
+ console.log(` ${result.kind} ${target}: done`);
865
+ } else {
866
+ console.log(` ${result.kind} ${target}: NOT done (${result.error})`);
867
+ }
868
+ }
869
+ console.log(`gc applied: ${done}/${results.length} action(s)`);
870
+ }
871
+
872
+ function processAlive(pid) {
873
+ try {
874
+ process.kill(pid, 0);
875
+ return true;
876
+ } catch {
877
+ return false;
878
+ }
879
+ }
880
+
881
+ const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
882
+
883
+ // Watches one RUNNING run and reports a stall once per step attempt. Exits
884
+ // when the run leaves RUNNING, the parent process (if given) dies, or after a
885
+ // single pass with --once true.
886
+ async function commandWatch(flags) {
887
+ if (!flags.repo || !flags.run) {
888
+ throw new Error("watch requires --repo and --run");
889
+ }
890
+ const repoRoot = resolve(flags.repo);
891
+ const stallAfterMs = stallAfterFromFlags(flags);
892
+ const intervalMs = Number(flags.interval ?? 30) * 1000;
893
+ const parent = flags.parent ? Number(flags.parent) : null;
894
+ const repoLabel = repoLabelFor(repoRoot, flags.repo);
895
+ const { config } = notifyConfigFor(repoRoot);
896
+ const notified = new Set();
897
+ let notifiedThisPass = false;
898
+ for (;;) {
899
+ notifiedThisPass = false;
900
+ const ledger = EventLedger.open(ledgerPathFor(repoRoot, flags.run));
901
+ if (!ledger.state.run) {
902
+ console.log(`watch: no ledger for ${flags.run} yet`);
903
+ } else if (ledger.state.run.status !== "RUNNING") {
904
+ console.log(`watch: run is ${ledger.state.run.status}; done`);
905
+ return;
906
+ } else {
907
+ const liveness = describeLiveness({ repoRoot, runId: flags.run, ledger, stallAfterMs });
908
+ const live = liveness.inFlight;
909
+ if (live?.stalled) {
910
+ const key = `${live.step}#${live.attempt}`;
911
+ if (!notified.has(key)) {
912
+ notified.add(key);
913
+ notifiedThisPass = true;
914
+ console.log(
915
+ `watch: STALLED ${live.step} attempt ${live.attempt} — no output for ${formatMs(live.sinceOutputMs)} (threshold ${formatMs(stallAfterMs)})`,
916
+ );
917
+ if (subscribes(config, "STALLED")) {
918
+ const notification = buildNotification("STALLED", {
919
+ repoLabel,
920
+ state: ledger.state,
921
+ detail: {
922
+ step: live.step,
923
+ attempt: live.attempt,
924
+ startedAt: live.startedAt,
925
+ elapsed: formatMs(live.elapsedMs),
926
+ sinceOutput: formatMs(live.sinceOutputMs),
927
+ threshold: formatMs(stallAfterMs),
928
+ command: live.command,
929
+ },
930
+ });
931
+ const results = await dispatchNotification(config, notification, { repoRoot });
932
+ for (const row of results) {
933
+ console.log(`watch: notify -> ${row.channel}: ${row.ok ? "sent" : row.error}`);
934
+ }
935
+ }
936
+ }
937
+ } else if (live) {
938
+ console.log(`watch: ${live.step} attempt ${live.attempt} elapsed ${formatMs(live.elapsedMs)}, last output ${formatMs(live.sinceOutputMs)} ago`);
939
+ }
940
+ }
941
+ if (flags.once === "true") {
942
+ return notifiedThisPass;
943
+ }
944
+ if (parent !== null && !processAlive(parent)) {
945
+ console.log("watch: parent process gone; done");
946
+ return;
947
+ }
948
+ await sleep(intervalMs);
949
+ }
950
+ }
951
+
522
952
  function commandObserve(rest) {
523
953
  const [sub, ...args] = rest;
524
954
  const flags = parseFlags(args);
@@ -577,7 +1007,7 @@ function commandObserve(rest) {
577
1007
  }
578
1008
  }
579
1009
 
580
- function main() {
1010
+ async function main() {
581
1011
  const [command, ...rest] = process.argv.slice(2);
582
1012
  if (command === "observe" || command === "findings") {
583
1013
  try {
@@ -591,9 +1021,9 @@ function main() {
591
1021
  try {
592
1022
  const flags = parseFlags(rest);
593
1023
  if (command === "start") {
594
- commandStart(flags);
1024
+ await commandStart(flags);
595
1025
  } else if (command === "resume") {
596
- commandResume(flags);
1026
+ await commandResume(flags);
597
1027
  } else if (command === "inbox") {
598
1028
  commandInbox(flags);
599
1029
  } else if (command === "approve") {
@@ -614,6 +1044,12 @@ function main() {
614
1044
  commandStatus(flags);
615
1045
  } else if (command === "stop") {
616
1046
  commandStop(flags);
1047
+ } else if (command === "gc") {
1048
+ commandGc(flags);
1049
+ } else if (command === "overview") {
1050
+ commandOverview(flags);
1051
+ } else if (command === "watch") {
1052
+ await commandWatch(flags);
617
1053
  } else if (command === "preflight") {
618
1054
  commandPreflight(flags);
619
1055
  } else {
@@ -627,4 +1063,4 @@ function main() {
627
1063
  }
628
1064
  }
629
1065
 
630
- main();
1066
+ await main();
@@ -143,6 +143,8 @@ export function applyEvent(state, event) {
143
143
  ...(data.suppressedFingerprints
144
144
  ? { suppressedFingerprints: data.suppressedFingerprints }
145
145
  : {}),
146
+ ...(data.cacheKey ? { cacheKey: data.cacheKey } : {}),
147
+ ...(data.reused ? { reused: data.reused } : {}),
146
148
  });
147
149
  break;
148
150
  }
@@ -27,7 +27,8 @@ const TOP_FIELDS = new Set([
27
27
  "policies",
28
28
  "budgets",
29
29
  ]);
30
- const STEP_FIELDS = new Set(["id", "worker", "optional", "requiredWhen", "readonly"]);
30
+ const STEP_FIELDS = new Set(["id", "worker", "optional", "requiredWhen", "readonly", "grade"]);
31
+ const EVIDENCE_GRADES = new Set(["L0", "L1", "L2", "L3", "L4"]);
31
32
  const TRANSITION_FIELDS = new Set(["from", "on", "to"]);
32
33
 
33
34
  function rejectUnknownFields(object, allowed, where) {
@@ -82,6 +83,11 @@ export function parseWorkflow(text) {
82
83
  if (raw.readonly !== undefined && typeof raw.readonly !== "boolean") {
83
84
  throw new WorkflowError(`step ${id} readonly must be a boolean`);
84
85
  }
86
+ // Evidence grade a step's command evidence is recorded at (iteration 08:
87
+ // release readbacks run against the real environment and are L4).
88
+ if (raw.grade !== undefined && !EVIDENCE_GRADES.has(raw.grade)) {
89
+ throw new WorkflowError(`step ${id} grade must be one of L0-L4`);
90
+ }
85
91
  stepIds.add(id);
86
92
  steps.push({
87
93
  id,
@@ -89,6 +95,7 @@ export function parseWorkflow(text) {
89
95
  optional: raw.optional ?? false,
90
96
  requiredWhen: raw.requiredWhen ?? null,
91
97
  readonly: raw.readonly ?? false,
98
+ grade: raw.grade ?? "L2",
92
99
  });
93
100
  }
94
101