@haiyangbg/buildbeat 2.0.0-beta.2 → 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 (62) hide show
  1. package/CHANGELOG.md +42 -6
  2. package/SKILL.md +76 -2
  3. package/bin/buildbeat-v2.js +13 -1
  4. package/docs/CLI-PILOT-2026-08-23.md +1 -1
  5. package/docs/CLI.md +1 -1
  6. package/docs/EXECUTION-PLAN.md +2 -2
  7. package/docs/PHASE2-PILOT-PREFLIGHT-2026-08-25.md +2 -2
  8. package/docs/PHASE4-V1.20-PILOT-2026-08-25.md +2 -2
  9. package/docs/RELEASING.md +1 -1
  10. package/docs/V2-D2-DECISION-CARD.md +2 -2
  11. package/docs/V2-DECISIONS.md +2 -2
  12. package/docs/V2-ITERATION-01.md +13 -13
  13. package/docs/V2-ITERATION-06.md +2 -2
  14. package/docs/V2-ITERATION-08.md +62 -0
  15. package/docs/V2-PLAN.md +6 -6
  16. package/docs/V2-PROPOSAL.md +2 -2
  17. package/docs/V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md +1 -1
  18. package/docs/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md +8 -0
  19. package/docs/V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md +8 -0
  20. package/docs/v2/M4-EXTERNAL-PILOT-2026-08-28.md +11 -11
  21. package/docs/v2/{M4-CHICKAI-PILOT-2026-08-28.md → M4-PILOT-APP-2026-08-28.md} +4 -4
  22. package/docs/v2/M4-SELFHOST-2026-08-28.md +1 -1
  23. package/docs/v2/RFC-0001-product-definition.md +2 -2
  24. package/docs/v2/SPEC-0001-events-v1.md +2 -2
  25. package/docs/v2/guide/00-how-to-talk.md +57 -0
  26. package/docs/v2/guide/01-quickstart.md +6 -0
  27. package/docs/v2/guide/02-workflow-guide.md +31 -0
  28. package/docs/v2/guide/04-adapter-guide.md +4 -0
  29. package/docs/v2/guide/05-worker-contract.md +10 -0
  30. package/docs/v2/guide/06-evidence-guide.md +10 -0
  31. package/docs/v2/guide/07-approval-guide.md +50 -0
  32. package/docs/v2/guide/10-recovery.md +25 -2
  33. package/docs/v2/guide/README.md +3 -0
  34. package/example/.buildbeat/manifest.json +1 -1
  35. package/lessons.md +12 -0
  36. package/package.json +1 -1
  37. package/src/v2/adapters/shell.js +87 -14
  38. package/src/v2/cli/run.js +602 -30
  39. package/src/v2/domain/event-registry.js +24 -0
  40. package/src/v2/engine/reducer.js +5 -0
  41. package/src/v2/engine/workflow.js +8 -1
  42. package/src/v2/evidence/collector.js +14 -3
  43. package/src/v2/observe/observe.js +9 -2
  44. package/src/v2/presets/release-readback.yaml +36 -0
  45. package/src/v2/presets/risk/release.yaml +21 -0
  46. package/src/v2/presets/software-delivery.yaml +5 -0
  47. package/src/v2/runtime/cache.js +124 -0
  48. package/src/v2/runtime/decisions.js +6 -4
  49. package/src/v2/runtime/env-contract.js +135 -0
  50. package/src/v2/runtime/envelope.js +183 -0
  51. package/src/v2/runtime/findings.js +158 -0
  52. package/src/v2/runtime/gc.js +182 -0
  53. package/src/v2/runtime/liveness.js +193 -0
  54. package/src/v2/runtime/metrics.js +8 -0
  55. package/src/v2/runtime/notify.js +223 -0
  56. package/src/v2/runtime/orchestrator.js +252 -24
  57. package/src/v2/runtime/overview.js +264 -0
  58. package/src/v2/runtime/repo-ref.js +38 -0
  59. package/src/v2/runtime/run-record.js +18 -3
  60. package/src/v2/workspace/workspace-manager.js +4 -1
  61. package/templates/v2/AGENTS.md +72 -0
  62. 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 } 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, 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";
@@ -15,9 +22,34 @@ import { parseYamlSubset } from "../engine/yaml-subset.js";
15
22
  import { parsePolicyDoc } from "../policy/policy.js";
16
23
  import { observeStatus, runObserveCycle, triageIntent } from "../observe/observe.js";
17
24
  import { acceptArtifact, approveRun, listInbox, rejectRun } from "../runtime/decisions.js";
25
+ import { checkRequires } from "../runtime/env-contract.js";
26
+ import {
27
+ adjudicateFinding,
28
+ findingsAccountRef,
29
+ latestAdjudications,
30
+ readFindingsAccount,
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";
18
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";
19
50
  import { writeRunRecord } from "../runtime/run-record.js";
20
51
  import { resumeRun, startRun } from "../runtime/orchestrator.js";
52
+ import { toRepoRef } from "../runtime/repo-ref.js";
21
53
  import { EventLedger } from "../storage/event-ledger.js";
22
54
  import { acquireLock, releaseLock } from "../workspace/workspace-manager.js";
23
55
 
@@ -26,10 +58,11 @@ const KERNEL = { kind: "kernel", id: "cli" };
26
58
  const USAGE = `BuildBeat v2 runtime
27
59
 
28
60
  Usage:
29
- run.js start --config <run-config.yaml>
61
+ run.js start --config <run-config.yaml> [--attempt new]
30
62
  run.js resume --config <run-config.yaml>
31
- run.js status --repo <path> --run <RUN-ID>
63
+ run.js status --repo <path> --run <RUN-ID> [--stall-after <minutes>]
32
64
  run.js inbox --repo <path>
65
+ run.js overview --repo <path> [--work <WORK-ID>] [--json true]
33
66
  run.js approve --repo <path> --run <RUN-ID> --transition <t> [--by <name>] [--config <run-config.yaml>]
34
67
  run.js reject --repo <path> --run <RUN-ID> [--transition <t>] [--reason <text>] [--by <name>]
35
68
  run.js accept --repo <path> --work <WORK-ID> --artifact <plan|intent|spec> [--by <name>]
@@ -38,9 +71,14 @@ Usage:
38
71
  run.js replay --repo <path> --run <RUN-ID>
39
72
  run.js metrics --repo <path> [--json true]
40
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]
41
76
  run.js observe run --config <observe.yaml>
42
77
  run.js observe status --repo <path>
43
78
  run.js observe triage --repo <path> --intent <ref> --action <fix_now|schedule|dismiss> [--by <name>] [--note <text>]
79
+ run.js preflight --config <run-config.yaml> --step <id>
80
+ run.js findings list --repo <path> --work <WORK-ID>
81
+ run.js findings adjudicate --repo <path> --work <WORK-ID> --fingerprint <fp> --action <accept|dismiss> [--by <name>] [--note <text>]
44
82
  `;
45
83
 
46
84
  function parseFlags(argv) {
@@ -60,7 +98,45 @@ function ledgerPathFor(repo, runId) {
60
98
  return join(resolve(repo), ".buildbeat", "runtime", "runs", runId, "events.jsonl");
61
99
  }
62
100
 
63
- 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 = {}) {
64
140
  if (ledger.corruption) {
65
141
  console.log(
66
142
  `WARNING: ledger corrupted after seq=${ledger.corruption.afterSeq} at line ${ledger.corruption.atLine}: ${ledger.corruption.reason}`,
@@ -70,6 +146,15 @@ function printState(state, ledger) {
70
146
  console.log("no run recorded");
71
147
  return;
72
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 };
73
158
  console.log(`run: ${state.run.id} (work ${state.run.work})`);
74
159
  console.log(`status: ${state.run.status}`);
75
160
  console.log(`workflow: ${state.run.workflowRef} @ ${state.run.workflowDigest}`);
@@ -79,16 +164,62 @@ function printState(state, ledger) {
79
164
  );
80
165
  }
81
166
  for (const [step, info] of Object.entries(state.steps)) {
82
- 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
+ }
83
209
  }
84
210
  for (const item of state.evidence) {
85
- console.log(`evidence [${item.status}/${item.grade}] ${item.kind} ${item.ref}`);
211
+ const ref = isAbsolute(item.ref) ? "<legacy-absolute-evidence-ref>" : item.ref;
212
+ const reused = item.reused ? ` (reused from ${item.reused.run})` : "";
213
+ console.log(`evidence [${item.status}/${item.grade}] ${item.kind} ${ref}${reused}`);
86
214
  }
87
215
  if (state.pendingHuman) {
88
216
  console.log(`waiting on human: ${state.pendingHuman.transition}`);
89
217
  for (const reason of state.pendingHuman.reasons) {
90
218
  console.log(` reason: ${reason}`);
91
219
  }
220
+ if (view.repoLabel) {
221
+ printNextReply(view.repoLabel, state);
222
+ }
92
223
  }
93
224
  if (state.terminal) {
94
225
  console.log(`terminal: ${state.terminal.status} (${state.terminal.reason})`);
@@ -141,7 +272,38 @@ function loadRunConfig(flags, command) {
141
272
  policies.push(parsePolicyDoc(parseYamlSubset(readFileSync(resolve(configDir, policyPath), "utf8"))));
142
273
  }
143
274
 
275
+ if (config.reviewTriage !== undefined && !["required", "off"].includes(config.reviewTriage)) {
276
+ throw new Error(`reviewTriage must be "required" or "off", got: ${config.reviewTriage}`);
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 ?? {}));
302
+
144
303
  return {
304
+ envelope,
305
+ cache,
306
+ redact,
145
307
  repoRoot,
146
308
  workflow,
147
309
  workflowDigest,
@@ -157,48 +319,180 @@ function loadRunConfig(flags, command) {
157
319
  maxAttemptsPerStep: config.maxAttemptsPerStep ?? 4,
158
320
  stepTimeoutMs: config.stepTimeoutMs,
159
321
  allowedPaths: config.allowedPaths,
322
+ requires: config.requires ?? [],
323
+ reviewTriage: config.reviewTriage === "required" ? "required" : null,
324
+ supersede: config.supersede ?? "waiting",
325
+ stallAfterMs: config.stallAfterMs !== undefined ? Number(config.stallAfterMs) : DEFAULT_STALL_AFTER_MS,
160
326
  planDigest: digestOfWorkFile("plan.md"),
161
327
  intentDigest: digestOfWorkFile("intent.md"),
162
328
  };
163
329
  }
164
330
 
165
- 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) {
166
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
+ }
412
+ if (process.stdout.isTTY) {
413
+ // Run launch discipline (real incident: a host-tool timeout killed a
414
+ // verify worker mid-run): anything longer than minutes belongs in a
415
+ // detached process, not an interactive foreground shell.
416
+ console.log("tip: long runs should be started detached (nohup/setsid); interactive shells die with their host");
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
+ }
167
422
  const result = startRun(options);
168
- console.log(`ledger: ${result.ledgerPath}`);
169
- printState(result.state, { corruption: null });
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
+ }
430
+ console.log(`ledger: ${toRepoRef(options.repoRoot, result.ledgerPath)}`);
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);
170
438
  }
171
439
 
172
- function commandResume(flags) {
440
+ async function commandResume(flags) {
173
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
+ }
174
446
  const result = resumeRun(options);
447
+ const repoLabel = repoLabelFor(options.repoRoot);
175
448
  if (!result.resumed) {
176
449
  console.log(`nothing to resume: ${result.reason}`);
177
450
  }
178
- console.log(`ledger: ${result.ledgerPath}`);
179
- printState(result.state, { corruption: null });
451
+ console.log(`ledger: ${toRepoRef(options.repoRoot, result.ledgerPath)}`);
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
+ }
180
461
  }
181
462
 
182
463
  function commandInbox(flags) {
183
464
  if (!flags.repo) {
184
465
  throw new Error("inbox requires --repo");
185
466
  }
186
- const rows = listInbox(resolve(flags.repo));
467
+ const repoRoot = resolve(flags.repo);
468
+ const rows = listInbox(repoRoot);
187
469
  if (rows.length === 0) {
188
470
  console.log("inbox empty: no runs waiting on a human");
189
471
  return;
190
472
  }
191
- 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) {
192
476
  if (row.corrupted) {
193
477
  console.log(`${row.run}: LEDGER CORRUPTED after seq=${row.corrupted.afterSeq} (${row.corrupted.reason})`);
194
478
  continue;
195
479
  }
196
- console.log(`${row.run} (work ${row.work}) [${row.kind}] ${row.transition}`);
197
- console.log(` candidate: ${row.subject.candidate}`);
198
- console.log(` planDigest: ${row.subject.planDigest}`);
199
- 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}`);
200
491
  for (const reason of row.reasons) {
201
- 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}`);
202
496
  }
203
497
  }
204
498
  }
@@ -299,6 +593,130 @@ function commandDoctor(flags) {
299
593
  console.log("push protection: repository has no remotes (nothing to protect)");
300
594
  }
301
595
  console.log("kernel capabilities: merge/deploy/publish have no call path in the runner (invariant 20)");
596
+ if (options.requires.length > 0) {
597
+ console.log("environment contract (requires):");
598
+ const check = checkRequires(options.requires);
599
+ for (const row of check.checked) {
600
+ console.log(` ${row.command}: OK${row.version ? ` (${row.version})` : ""}`);
601
+ }
602
+ for (const problem of check.problems) {
603
+ console.log(` PROBLEM ${problem}`);
604
+ }
605
+ } else {
606
+ console.log("environment contract: none declared (implicit PATH facts stay unchecked)");
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
+ }
622
+ }
623
+
624
+ // Preflight: run one step's configured worker command directly in the main
625
+ // checkout — no worktree, no ledger, no evidence. Minute-level dry loops
626
+ // against the first failure boundary before a full run is what turned the
627
+ // deploy campaign's idle phase around; the output is a dry signal only and a
628
+ // Run must reproduce anything it finds.
629
+ function commandPreflight(flags) {
630
+ const options = loadRunConfig(flags, "preflight");
631
+ if (!flags.step) {
632
+ throw new Error("preflight requires --step");
633
+ }
634
+ const stepDef = options.workflow.steps.find((candidate) => candidate.id === flags.step);
635
+ if (!stepDef) {
636
+ throw new Error(`step not in workflow: ${flags.step}`);
637
+ }
638
+ const spec = stepDef.worker ? options.adapterConfigs[stepDef.worker] : null;
639
+ if (!spec) {
640
+ throw new Error(`step ${flags.step} has no configured worker command to preflight`);
641
+ }
642
+ if (options.requires.length > 0) {
643
+ const check = checkRequires(options.requires);
644
+ for (const problem of check.problems) {
645
+ console.log(`requires PROBLEM: ${problem}`);
646
+ }
647
+ }
648
+ const fill = (text) =>
649
+ String(text)
650
+ .replaceAll("{workspace}", options.repoRoot)
651
+ .replaceAll("{step}", flags.step)
652
+ .replaceAll("{worker}", stepDef.worker);
653
+ const args = (spec.args ?? []).map(fill);
654
+ let env;
655
+ if (spec.inheritEnv === true) {
656
+ env = { ...process.env };
657
+ } else {
658
+ env = {};
659
+ for (const key of ["PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "TERM", "USER", "SHELL"]) {
660
+ if (process.env[key] !== undefined) {
661
+ env[key] = process.env[key];
662
+ }
663
+ }
664
+ }
665
+ Object.assign(env, spec.env ?? {});
666
+ env.BUILDBEAT_PREFLIGHT = "1";
667
+ console.log(`PREFLIGHT (dry signal, never evidence): step ${flags.step} -> ${spec.command} ${args.join(" ")}`);
668
+ console.log(`cwd: main checkout (no worktree, no ledger, no evidence written)`);
669
+ const result = spawnSync(spec.command, args, {
670
+ cwd: options.repoRoot,
671
+ stdio: "inherit",
672
+ env,
673
+ timeout: spec.timeoutMs,
674
+ });
675
+ if (result.error) {
676
+ throw new Error(`preflight could not run the command: ${result.error.message}`);
677
+ }
678
+ const exitCode = result.status ?? 1;
679
+ console.log(`preflight exit=${exitCode} — a Run must reproduce this before it counts`);
680
+ process.exitCode = exitCode;
681
+ }
682
+
683
+ function commandFindings(rest) {
684
+ const [sub, ...args] = rest;
685
+ const flags = parseFlags(args);
686
+ if (sub === "list") {
687
+ if (!flags.repo || !flags.work) {
688
+ throw new Error("findings list requires --repo and --work");
689
+ }
690
+ const rows = readFindingsAccount(resolve(flags.repo), flags.work);
691
+ const findings = rows.filter((row) => row.kind === "finding");
692
+ if (findings.length === 0) {
693
+ console.log(`no recorded findings (${findingsAccountRef(flags.work)})`);
694
+ return;
695
+ }
696
+ const adjudicated = latestAdjudications(rows);
697
+ for (const row of findings) {
698
+ const verdict = adjudicated.get(row.fingerprint);
699
+ const status = verdict ? `${verdict.action} by ${verdict.by}` : "open";
700
+ const reRaised = row.reRaised ? " RE-RAISED" : "";
701
+ console.log(`[${row.severity} ${row.fingerprint}] (${status})${reRaised} ${row.summary}`);
702
+ }
703
+ } else if (sub === "adjudicate") {
704
+ if (!flags.repo || !flags.work || !flags.fingerprint || !flags.action) {
705
+ throw new Error("findings adjudicate requires --repo, --work, --fingerprint and --action");
706
+ }
707
+ const row = adjudicateFinding(resolve(flags.repo), flags.work, {
708
+ fingerprint: flags.fingerprint,
709
+ action: flags.action,
710
+ by: flags.by,
711
+ note: flags.note,
712
+ });
713
+ console.log(`adjudicated ${row.fingerprint} -> ${row.action} ([${row.severity}] ${row.summary})`);
714
+ if (row.action === "dismiss") {
715
+ console.log("dismissed: this fingerprint no longer blocks; an escalated severity reopens on its own");
716
+ }
717
+ } else {
718
+ throw new Error(`findings subcommand must be list|adjudicate, got: ${sub ?? "(none)"}`);
719
+ }
302
720
  }
303
721
 
304
722
  function commandEvents(flags) {
@@ -335,7 +753,7 @@ function commandReplay(flags) {
335
753
  } else {
336
754
  console.log(`chain OK: ${ledger.events.length} events verified (digest/prev/seq)`);
337
755
  }
338
- printState(ledger.state, { corruption: null });
756
+ printState(ledger.state, { corruption: null }, {});
339
757
  }
340
758
 
341
759
  function commandMetrics(flags) {
@@ -354,8 +772,13 @@ function commandStatus(flags) {
354
772
  if (!flags.repo || !flags.run) {
355
773
  throw new Error("status requires --repo and --run");
356
774
  }
357
- const ledger = EventLedger.open(ledgerPathFor(flags.repo, flags.run));
358
- 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
+ });
359
782
  }
360
783
 
361
784
  function commandStop(flags) {
@@ -385,6 +808,147 @@ function commandStop(flags) {
385
808
  }
386
809
  }
387
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
+
388
952
  function commandObserve(rest) {
389
953
  const [sub, ...args] = rest;
390
954
  const flags = parseFlags(args);
@@ -393,7 +957,7 @@ function commandObserve(rest) {
393
957
  throw new Error("observe run requires --config <observe.yaml>");
394
958
  }
395
959
  const result = runObserveCycle({ configPath: flags.config });
396
- console.log(`observe cycle ${result.cycle} finished (ledger: ${result.ledgerPath})`);
960
+ console.log(`observe cycle ${result.cycle} finished (ledger: ${result.ledgerRef})`);
397
961
  for (const row of result.results) {
398
962
  const bands = row.bands.length > 0 ? ` bands=${row.bands.join(",")}` : "";
399
963
  const intent = row.intent ? ` intent=${row.intent.outcome}:${row.intent.intentRef}` : "";
@@ -443,11 +1007,11 @@ function commandObserve(rest) {
443
1007
  }
444
1008
  }
445
1009
 
446
- function main() {
1010
+ async function main() {
447
1011
  const [command, ...rest] = process.argv.slice(2);
448
- if (command === "observe") {
1012
+ if (command === "observe" || command === "findings") {
449
1013
  try {
450
- commandObserve(rest);
1014
+ (command === "observe" ? commandObserve : commandFindings)(rest);
451
1015
  } catch (error) {
452
1016
  console.error(`error: ${error.message}`);
453
1017
  process.exitCode = 1;
@@ -457,9 +1021,9 @@ function main() {
457
1021
  try {
458
1022
  const flags = parseFlags(rest);
459
1023
  if (command === "start") {
460
- commandStart(flags);
1024
+ await commandStart(flags);
461
1025
  } else if (command === "resume") {
462
- commandResume(flags);
1026
+ await commandResume(flags);
463
1027
  } else if (command === "inbox") {
464
1028
  commandInbox(flags);
465
1029
  } else if (command === "approve") {
@@ -480,6 +1044,14 @@ function main() {
480
1044
  commandStatus(flags);
481
1045
  } else if (command === "stop") {
482
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);
1053
+ } else if (command === "preflight") {
1054
+ commandPreflight(flags);
483
1055
  } else {
484
1056
  process.stdout.write(USAGE);
485
1057
  process.exitCode = command ? 2 : 0;
@@ -491,4 +1063,4 @@ function main() {
491
1063
  }
492
1064
  }
493
1065
 
494
- main();
1066
+ await main();