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

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 (63) hide show
  1. package/CHANGELOG.md +47 -7
  2. package/SKILL.md +86 -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.0.0-BETA.4-RELEASE-EVIDENCE-2026-09-03.md +9 -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 +7 -6
  25. package/docs/v2/guide/00-how-to-talk.md +57 -0
  26. package/docs/v2/guide/01-quickstart.md +4 -0
  27. package/docs/v2/guide/02-workflow-guide.md +48 -1
  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 +4 -0
  31. package/docs/v2/guide/07-approval-guide.md +47 -0
  32. package/docs/v2/guide/10-recovery.md +25 -3
  33. package/docs/v2/guide/README.md +3 -0
  34. package/example/.buildbeat/manifest.json +1 -1
  35. package/lessons.md +37 -0
  36. package/package.json +1 -1
  37. package/src/v2/adapters/mock.js +9 -2
  38. package/src/v2/adapters/shell.js +87 -14
  39. package/src/v2/cli/run.js +607 -30
  40. package/src/v2/domain/event-registry.js +1 -0
  41. package/src/v2/engine/reducer.js +29 -1
  42. package/src/v2/engine/workflow.js +8 -1
  43. package/src/v2/evidence/collector.js +14 -3
  44. package/src/v2/presets/release-readback.yaml +36 -0
  45. package/src/v2/presets/risk/release.yaml +21 -0
  46. package/src/v2/runtime/cache.js +124 -0
  47. package/src/v2/runtime/decisions.js +80 -0
  48. package/src/v2/runtime/env-contract.js +35 -1
  49. package/src/v2/runtime/envelope.js +183 -0
  50. package/src/v2/runtime/gc.js +182 -0
  51. package/src/v2/runtime/liveness.js +193 -0
  52. package/src/v2/runtime/metrics.js +8 -0
  53. package/src/v2/runtime/notify.js +223 -0
  54. package/src/v2/runtime/orchestrator.js +267 -19
  55. package/src/v2/runtime/overview.js +301 -0
  56. package/src/v2/runtime/run-record.js +3 -0
  57. package/src/v2/runtime/work-cost.js +147 -0
  58. package/src/v2/workspace/workspace-manager.js +14 -1
  59. package/templates/contracts/PROTOCOL.md +4 -0
  60. package/templates/gitignore.template +5 -0
  61. package/templates/scripts/bus-check.sh +37 -12
  62. package/templates/v2/AGENTS.md +73 -0
  63. package/templates/v2//346/214/207/346/214/245/345/217/260.md +36 -0
package/src/v2/cli/run.js CHANGED
@@ -2,19 +2,26 @@
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";
13
- import { loadWorkflow } from "../engine/workflow.js";
20
+ import { loadWorkflow, nextStep } from "../engine/workflow.js";
14
21
  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
- import { acceptArtifact, approveRun, listInbox, rejectRun } from "../runtime/decisions.js";
24
+ import { acceptArtifact, adoptCandidate, approveRun, listInbox, rejectRun } from "../runtime/decisions.js";
18
25
  import { checkRequires } from "../runtime/env-contract.js";
19
26
  import {
20
27
  adjudicateFinding,
@@ -22,22 +29,40 @@ 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 { artifactStatus, computeOverview, readJsonl, 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";
29
53
  import { EventLedger } from "../storage/event-ledger.js";
30
- import { acquireLock, releaseLock } from "../workspace/workspace-manager.js";
54
+ import { acquireLock, listHeldRunLocks, releaseLock } from "../workspace/workspace-manager.js";
31
55
 
32
56
  const KERNEL = { kind: "kernel", id: "cli" };
33
57
 
34
58
  const USAGE = `BuildBeat v2 runtime
35
59
 
36
60
  Usage:
37
- run.js start --config <run-config.yaml>
38
- run.js resume --config <run-config.yaml>
39
- run.js status --repo <path> --run <RUN-ID>
61
+ run.js start --config <run-config.yaml> [--attempt new]
62
+ run.js resume --config <run-config.yaml> [--adopt <sha> --by <name>] # --adopt: hand fix committed in the worktree; skip fix, resume at verify
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,72 @@ 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
+ // budgets: run config beats the preset (the preset's two review rounds
285
+ // could not be raised per run before; a pilot's shipped candidates ended
286
+ // as CANCELLED runs because of it).
287
+ const budgets = {};
288
+ if (config.budgets !== undefined) {
289
+ if (!config.budgets || typeof config.budgets !== "object" || Array.isArray(config.budgets)) {
290
+ throw new Error("budgets must be a map");
291
+ }
292
+ for (const key of Object.keys(config.budgets)) {
293
+ if (!["maxAttempts", "reviewRoundsPerWork"].includes(key)) {
294
+ throw new Error(`unknown budgets key: ${key} (known: maxAttempts, reviewRoundsPerWork)`);
295
+ }
296
+ }
297
+ if (config.budgets.maxAttempts !== undefined) {
298
+ const map = config.budgets.maxAttempts;
299
+ if (!map || typeof map !== "object" || Array.isArray(map)) {
300
+ throw new Error("budgets.maxAttempts must be a map of step -> positive integer");
301
+ }
302
+ budgets.maxAttempts = {};
303
+ for (const [step, value] of Object.entries(map)) {
304
+ if (!workflow.stepIds.has(step)) {
305
+ throw new Error(`budgets.maxAttempts.${step}: step not in workflow`);
306
+ }
307
+ if (!Number.isInteger(Number(value)) || Number(value) < 1) {
308
+ throw new Error(`budgets.maxAttempts.${step} must be a positive integer, got: ${value}`);
309
+ }
310
+ budgets.maxAttempts[step] = Number(value);
311
+ }
312
+ }
313
+ if (config.budgets.reviewRoundsPerWork !== undefined) {
314
+ const value = Number(config.budgets.reviewRoundsPerWork);
315
+ if (!Number.isInteger(value) || value < 1) {
316
+ throw new Error(`budgets.reviewRoundsPerWork must be a positive integer, got: ${config.budgets.reviewRoundsPerWork}`);
317
+ }
318
+ budgets.reviewRoundsPerWork = value;
319
+ }
320
+ }
321
+ const cache = {};
322
+ for (const [step, mode] of Object.entries(config.cache ?? {})) {
323
+ if (mode !== "tree") {
324
+ throw new Error(`cache.${step} must be "tree", got: ${mode}`);
325
+ }
326
+ if (!workflow.stepIds.has(step)) {
327
+ throw new Error(`cache.${step}: step not in workflow`);
328
+ }
329
+ cache[step] = mode;
330
+ }
331
+ const redact = (config.redact ?? []).map((pattern) => {
332
+ try {
333
+ return new RegExp(String(pattern), "g");
334
+ } catch {
335
+ throw new Error(`redact pattern is not a valid regular expression: ${pattern}`);
336
+ }
337
+ });
338
+ const envelope = loadEnvelope(config, configDir, Object.keys(config.workers ?? {}));
159
339
 
160
340
  return {
341
+ envelope,
342
+ cache,
343
+ redact,
161
344
  repoRoot,
162
345
  workflow,
163
346
  workflowDigest,
@@ -171,58 +354,221 @@ function loadRunConfig(flags, command) {
171
354
  policies,
172
355
  riskPreset,
173
356
  maxAttemptsPerStep: config.maxAttemptsPerStep ?? 4,
357
+ budgets,
174
358
  stepTimeoutMs: config.stepTimeoutMs,
175
359
  allowedPaths: config.allowedPaths,
176
360
  requires: config.requires ?? [],
177
361
  reviewTriage: config.reviewTriage === "required" ? "required" : null,
362
+ supersede: config.supersede ?? "waiting",
363
+ stallAfterMs: config.stallAfterMs !== undefined ? Number(config.stallAfterMs) : DEFAULT_STALL_AFTER_MS,
178
364
  planDigest: digestOfWorkFile("plan.md"),
179
365
  intentDigest: digestOfWorkFile("intent.md"),
180
366
  };
181
367
  }
182
368
 
183
- function commandStart(flags) {
369
+ // Notification config is optional and never fatal for a run: a broken file
370
+ // is reported once and the run proceeds without outbound messages.
371
+ function notifyConfigFor(repoRoot) {
372
+ try {
373
+ return { config: loadNotifyConfig(repoRoot), error: null };
374
+ } catch (error) {
375
+ return { config: null, error: error.message };
376
+ }
377
+ }
378
+
379
+ async function notifyForState(repoRoot, repoLabel, state) {
380
+ const { config, error } = notifyConfigFor(repoRoot);
381
+ if (error) {
382
+ console.log(`notify: config ignored (${error})`);
383
+ return;
384
+ }
385
+ if (!config || !state.run) {
386
+ return;
387
+ }
388
+ const kinds = [];
389
+ if (state.pendingHuman && state.run.status === "WAITING_HUMAN") {
390
+ kinds.push("HUMAN_REQUESTED");
391
+ }
392
+ if (state.terminal) {
393
+ kinds.push("RUN_TERMINAL");
394
+ }
395
+ for (const kind of kinds) {
396
+ if (!subscribes(config, kind)) {
397
+ continue;
398
+ }
399
+ const results = await dispatchNotification(config, buildNotification(kind, { repoLabel, state }), { repoRoot });
400
+ for (const row of results) {
401
+ const outcome = row.ok ? "sent" : row.skipped ? `skipped (${row.error})` : `FAILED (${row.error})`;
402
+ console.log(`notify ${kind} -> ${row.channel}: ${outcome}`);
403
+ }
404
+ }
405
+ }
406
+
407
+ // A stall watcher is a separate detached process: the orchestrator blocks in
408
+ // spawnSync while a worker runs, so it cannot look at the clock itself. The
409
+ // watcher exits on its own once the run is no longer RUNNING or the parent
410
+ // process is gone.
411
+ function spawnStallWatcher(repoRoot, runId, stallAfterMs) {
412
+ const { config } = notifyConfigFor(repoRoot);
413
+ if (!subscribes(config, "STALLED")) {
414
+ return false;
415
+ }
416
+ const child = spawn(
417
+ process.execPath,
418
+ [
419
+ fileURLToPath(import.meta.url),
420
+ "watch",
421
+ "--repo",
422
+ repoRoot,
423
+ "--run",
424
+ runId,
425
+ "--stall-after-ms",
426
+ String(stallAfterMs),
427
+ "--parent",
428
+ String(process.pid),
429
+ ],
430
+ { detached: true, stdio: "ignore" },
431
+ );
432
+ child.unref();
433
+ return true;
434
+ }
435
+
436
+ async function commandStart(flags) {
184
437
  const options = loadRunConfig(flags, "start");
438
+ if (flags.attempt !== undefined) {
439
+ if (flags.attempt !== "new") {
440
+ throw new Error(`--attempt must be "new" (auto-number the next run of this family), got: ${flags.attempt}`);
441
+ }
442
+ // The run config names the family; the kernel numbers the attempt. One
443
+ // config per work, not one per retry (the campaign hand-numbered -01..-30).
444
+ options.runId = nextAttemptId(options.repoRoot, options.workId, options.runId);
445
+ console.log(`attempt: ${options.runId}`);
446
+ }
447
+ if (options.envelope) {
448
+ console.log(`envelope: ${options.envelope.source} (${Object.keys(options.envelope.prompts).join(", ")}) ${options.envelope.digest}`);
449
+ }
185
450
  if (process.stdout.isTTY) {
186
451
  // Run launch discipline (real incident: a host-tool timeout killed a
187
452
  // verify worker mid-run): anything longer than minutes belongs in a
188
453
  // detached process, not an interactive foreground shell.
189
454
  console.log("tip: long runs should be started detached (nohup/setsid); interactive shells die with their host");
190
455
  }
191
- const result = startRun(options);
456
+ const watching = spawnStallWatcher(options.repoRoot, options.runId, options.stallAfterMs);
457
+ if (watching) {
458
+ console.log(`stall watcher armed (no output for ${formatMs(options.stallAfterMs)} notifies STALLED)`);
459
+ }
460
+ let result;
461
+ try {
462
+ result = startRun(options);
463
+ } catch (error) {
464
+ if (/another run is active/.test(error.message ?? "")) {
465
+ // Say who holds the repository and how to watch it: a pilot session
466
+ // waited 3h23m behind another work's run with nothing but the lock
467
+ // message to go on ("二十分钟了哎").
468
+ const label = repoLabelFor(options.repoRoot);
469
+ const holders = listHeldRunLocks(options.repoRoot);
470
+ if (holders.length === 0) {
471
+ console.error("blocked by: a stale active-run lock with no run holding it (a killed process?); `gc` clears locks of terminal runs, or remove .buildbeat/runtime/locks/active-run.lock after checking no driver process is alive");
472
+ }
473
+ for (const holder of holders) {
474
+ const ledgerPath = join(options.repoRoot, ".buildbeat", "runtime", "runs", holder, "events.jsonl");
475
+ let summary = "(no ledger found)";
476
+ if (existsSync(ledgerPath)) {
477
+ const ledger = EventLedger.open(ledgerPath);
478
+ const state = ledger.state;
479
+ const step = state.currentStep ? `step ${state.currentStep} attempt ${state.steps[state.currentStep]?.attempts ?? "?"}` : "between steps";
480
+ const since = ledger.events[ledger.events.length - 1]?.ts;
481
+ summary = `${state.run?.work ?? "?"} ${state.run?.status ?? "?"} ${step}${since ? `, last event ${formatMs(Date.now() - Date.parse(since))} ago` : ""}`;
482
+ }
483
+ console.error(`blocked by ${holder}: ${summary}`);
484
+ console.error(` watch it: buildbeat-v2 status --repo ${label} --run ${holder}`);
485
+ }
486
+ console.error("queue position: next after the holder(s) above stop or wait on a human (the repository allows one driving run at a time; worktrees are already isolated)");
487
+ }
488
+ throw error;
489
+ }
490
+ const repoLabel = repoLabelFor(options.repoRoot);
491
+ for (const run of result.superseded ?? []) {
492
+ console.log(`superseded ${run} (was waiting on a human for the same work; now SUPERSEDED)`);
493
+ }
494
+ for (const row of result.supersedeSkipped ?? []) {
495
+ console.log(`could not supersede ${row.run}: ${row.reason}`);
496
+ }
192
497
  console.log(`ledger: ${toRepoRef(options.repoRoot, result.ledgerPath)}`);
193
- printState(result.state, { corruption: null });
498
+ const ledger = EventLedger.open(result.ledgerPath);
499
+ printState(ledger.state, ledger, {
500
+ repoRoot: options.repoRoot,
501
+ repoLabel,
502
+ stallAfterMs: options.stallAfterMs,
503
+ });
504
+ await notifyForState(options.repoRoot, repoLabel, ledger.state);
194
505
  }
195
506
 
196
- function commandResume(flags) {
507
+ async function commandResume(flags) {
197
508
  const options = loadRunConfig(flags, "resume");
509
+ if (flags.adopt !== undefined) {
510
+ const resumeAt = nextStep(options.workflow, "fix", "succeeded") ?? "verify";
511
+ const adopted = adoptCandidate(options.repoRoot, options.runId, {
512
+ sha: flags.adopt,
513
+ by: flags.by ?? "human",
514
+ resumeAt,
515
+ });
516
+ console.log(`adopted ${adopted.adopted} as candidate (${adopted.decisionRef}, answers ${adopted.transition}); resuming at ${adopted.resumeAt}`);
517
+ }
518
+ const watching = spawnStallWatcher(options.repoRoot, options.runId, options.stallAfterMs);
519
+ if (watching) {
520
+ console.log(`stall watcher armed (no output for ${formatMs(options.stallAfterMs)} notifies STALLED)`);
521
+ }
198
522
  const result = resumeRun(options);
523
+ const repoLabel = repoLabelFor(options.repoRoot);
199
524
  if (!result.resumed) {
200
525
  console.log(`nothing to resume: ${result.reason}`);
201
526
  }
202
527
  console.log(`ledger: ${toRepoRef(options.repoRoot, result.ledgerPath)}`);
203
- printState(result.state, { corruption: null });
528
+ const ledger = EventLedger.open(result.ledgerPath);
529
+ printState(ledger.state, ledger, {
530
+ repoRoot: options.repoRoot,
531
+ repoLabel,
532
+ stallAfterMs: options.stallAfterMs,
533
+ });
534
+ if (result.resumed) {
535
+ await notifyForState(options.repoRoot, repoLabel, ledger.state);
536
+ }
204
537
  }
205
538
 
206
539
  function commandInbox(flags) {
207
540
  if (!flags.repo) {
208
541
  throw new Error("inbox requires --repo");
209
542
  }
210
- const rows = listInbox(resolve(flags.repo));
543
+ const repoRoot = resolve(flags.repo);
544
+ const rows = listInbox(repoRoot);
211
545
  if (rows.length === 0) {
212
546
  console.log("inbox empty: no runs waiting on a human");
213
547
  return;
214
548
  }
215
- for (const row of rows) {
549
+ const sorted = [...rows].sort((a, b) => `${a.work ?? ""}${a.run}`.localeCompare(`${b.work ?? ""}${b.run}`));
550
+ let lastWork = null;
551
+ for (const row of sorted) {
216
552
  if (row.corrupted) {
217
553
  console.log(`${row.run}: LEDGER CORRUPTED after seq=${row.corrupted.afterSeq} (${row.corrupted.reason})`);
218
554
  continue;
219
555
  }
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}`);
556
+ if (row.work !== lastWork) {
557
+ console.log(`work ${row.work}:`);
558
+ lastWork = row.work;
559
+ }
560
+ const ledger = EventLedger.open(ledgerPathFor(repoRoot, row.run));
561
+ const requested = [...ledger.events].reverse().find((event) => event.type === "HUMAN_REQUESTED");
562
+ const age = requested ? formatMs(Date.now() - Date.parse(requested.ts)) : "?";
563
+ console.log(` ${row.run} [${row.kind}] ${row.transition} — waiting ${age}${requested ? ` (since ${requested.ts})` : ""}`);
564
+ console.log(` candidate: ${row.subject.candidate}`);
565
+ console.log(` planDigest: ${row.subject.planDigest}`);
566
+ console.log(` evidenceDigest: ${row.subject.evidenceDigest}`);
224
567
  for (const reason of row.reasons) {
225
- console.log(` reason: ${reason}`);
568
+ console.log(` reason: ${reason}`);
569
+ }
570
+ for (const line of nextReply({ repoLabel: repoLabelFor(repoRoot, flags.repo), state: ledger.state })) {
571
+ console.log(` next: ${line}`);
226
572
  }
227
573
  }
228
574
  }
@@ -279,6 +625,26 @@ function commandAccept(flags) {
279
625
  console.log(" note: editing the artifact after acceptance makes this acceptance stale");
280
626
  }
281
627
 
628
+ // Artifacts a policy rule requires to be accepted (artifact.accepted leaves
629
+ // anywhere under all/any/not).
630
+ function artifactsRequiredBy(rule, found = []) {
631
+ if (!rule || typeof rule !== "object") {
632
+ return found;
633
+ }
634
+ for (const [key, value] of Object.entries(rule)) {
635
+ if (key === "artifact.accepted" && value && typeof value.artifact === "string") {
636
+ found.push(value.artifact);
637
+ } else if (Array.isArray(value)) {
638
+ for (const item of value) {
639
+ artifactsRequiredBy(item, found);
640
+ }
641
+ } else if (value && typeof value === "object") {
642
+ artifactsRequiredBy(value, found);
643
+ }
644
+ }
645
+ return found;
646
+ }
647
+
282
648
  function commandDoctor(flags) {
283
649
  const options = loadRunConfig(flags, "doctor");
284
650
  console.log(`risk preset: ${options.riskPreset}`);
@@ -323,6 +689,51 @@ function commandDoctor(flags) {
323
689
  console.log("push protection: repository has no remotes (nothing to protect)");
324
690
  }
325
691
  console.log("kernel capabilities: merge/deploy/publish have no call path in the runner (invariant 20)");
692
+ const budgetLines = [];
693
+ for (const step of options.workflow.steps) {
694
+ if (!step.worker) {
695
+ continue;
696
+ }
697
+ const fromRun = options.budgets.maxAttempts?.[step.id];
698
+ const fromPreset = options.workflow.budgets?.maxAttempts?.[step.id];
699
+ const effective = fromRun ?? fromPreset ?? options.maxAttemptsPerStep;
700
+ const source = fromRun !== undefined ? "run config" : fromPreset !== undefined ? "workflow preset" : "default";
701
+ budgetLines.push(`${step.id}=${effective} (${source})`);
702
+ }
703
+ console.log(`budgets (maxAttempts per step; approving resume-<step> after exhaustion grants +1): ${budgetLines.join(", ")}`);
704
+ if (options.budgets.reviewRoundsPerWork !== undefined) {
705
+ console.log(`budgets.reviewRoundsPerWork: ${options.budgets.reviewRoundsPerWork} (counted across every run of the work, superseded ones included)`);
706
+ }
707
+ // Same preconditions start's first gate will read (real incident, twice:
708
+ // doctor passed, start stopped at build because plan.md was not mirrored
709
+ // into the repository the run was started in).
710
+ const workDir = join(options.repoRoot, "delivery", "work", options.workId);
711
+ const decisions = readJsonl(join(workDir, "decisions.jsonl"));
712
+ console.log(`work artifacts in this repository (delivery/work/${options.workId}):`);
713
+ const artifactState = {};
714
+ for (const artifact of ["intent", "plan"]) {
715
+ const status = artifactStatus(workDir, decisions, artifact);
716
+ artifactState[artifact] = status;
717
+ const label = !status.exists
718
+ ? "MISSING"
719
+ : status.stale
720
+ ? "accepted but edited since (stale)"
721
+ : status.accepted
722
+ ? `accepted${status.by ? ` by ${status.by}` : ""}`
723
+ : "draft (not accepted)";
724
+ console.log(` ${artifact}.md: ${label}`);
725
+ }
726
+ for (const policy of options.policies) {
727
+ for (const artifact of artifactsRequiredBy(policy.rule)) {
728
+ const status = artifactState[artifact] ?? artifactStatus(workDir, decisions, artifact);
729
+ if (!status.accepted) {
730
+ console.log(
731
+ ` WARNING policy ${policy.name} (${policy.type} ${policy.appliesTo}) needs an accepted ${artifact}.md; start will stop at ${policy.appliesTo}` +
732
+ (!status.exists ? " (file missing here: mirror it into this repository, then accept)" : " (accept it first)"),
733
+ );
734
+ }
735
+ }
736
+ }
326
737
  if (options.requires.length > 0) {
327
738
  console.log("environment contract (requires):");
328
739
  const check = checkRequires(options.requires);
@@ -335,6 +746,20 @@ function commandDoctor(flags) {
335
746
  } else {
336
747
  console.log("environment contract: none declared (implicit PATH facts stay unchecked)");
337
748
  }
749
+ console.log(`supersede: ${options.supersede} (new run for the same work ${options.supersede === "off" ? "leaves" : "supersedes"} older WAITING_HUMAN runs)`);
750
+ console.log(`stall threshold: ${formatMs(options.stallAfterMs)} without worker output`);
751
+ const { config: notify, error: notifyError } = notifyConfigFor(options.repoRoot);
752
+ if (notifyError) {
753
+ console.log(`notify: PROBLEM ${notifyError}`);
754
+ } else if (!notify) {
755
+ console.log(`notify: none (${NOTIFY_CONFIG} absent; a waiting run reaches nobody until someone runs inbox)`);
756
+ } else {
757
+ console.log("notify channels:");
758
+ for (const channel of notify.channels) {
759
+ const urlState = process.env[channel.urlEnv] ? "url env set" : `WARNING env ${channel.urlEnv} not set in this shell`;
760
+ console.log(` ${channel.id}: type=${channel.type} events=${channel.events.join(",")} (${urlState})`);
761
+ }
762
+ }
338
763
  }
339
764
 
340
765
  // Preflight: run one step's configured worker command directly in the main
@@ -469,7 +894,7 @@ function commandReplay(flags) {
469
894
  } else {
470
895
  console.log(`chain OK: ${ledger.events.length} events verified (digest/prev/seq)`);
471
896
  }
472
- printState(ledger.state, { corruption: null });
897
+ printState(ledger.state, { corruption: null }, {});
473
898
  }
474
899
 
475
900
  function commandMetrics(flags) {
@@ -488,8 +913,13 @@ function commandStatus(flags) {
488
913
  if (!flags.repo || !flags.run) {
489
914
  throw new Error("status requires --repo and --run");
490
915
  }
491
- const ledger = EventLedger.open(ledgerPathFor(flags.repo, flags.run));
492
- printState(ledger.state, ledger);
916
+ const repoRoot = resolve(flags.repo);
917
+ const ledger = EventLedger.open(ledgerPathFor(repoRoot, flags.run));
918
+ printState(ledger.state, ledger, {
919
+ repoRoot,
920
+ repoLabel: repoLabelFor(repoRoot, flags.repo),
921
+ stallAfterMs: stallAfterFromFlags(flags),
922
+ });
493
923
  }
494
924
 
495
925
  function commandStop(flags) {
@@ -519,6 +949,147 @@ function commandStop(flags) {
519
949
  }
520
950
  }
521
951
 
952
+ function commandOverview(flags) {
953
+ if (!flags.repo) {
954
+ throw new Error("overview requires --repo");
955
+ }
956
+ const repoRoot = resolve(flags.repo);
957
+ const rows = computeOverview(repoRoot, { work: flags.work ?? null, repoLabel: repoLabelFor(repoRoot, flags.repo) });
958
+ if (flags.json === "true") {
959
+ console.log(JSON.stringify(rows, null, 2));
960
+ return;
961
+ }
962
+ console.log(renderOverview(rows));
963
+ }
964
+
965
+ function commandGc(flags) {
966
+ if (!flags.repo) {
967
+ throw new Error("gc requires --repo");
968
+ }
969
+ const repoRoot = resolve(flags.repo);
970
+ const rows = planGc(repoRoot);
971
+ if (rows.length === 0) {
972
+ console.log("gc: no run ledgers found");
973
+ return;
974
+ }
975
+ let actionable = 0;
976
+ for (const row of rows) {
977
+ const summary = row.actions.map((action) => {
978
+ if (action.kind === "remove-worktree") {
979
+ return `remove worktree${action.dirty ? " (DIRTY, needs --force)" : ""}`;
980
+ }
981
+ if (action.kind === "delete-branch") {
982
+ return `delete branch ${action.branch} (${action.reason})`;
983
+ }
984
+ return "remove stale lock";
985
+ });
986
+ actionable += row.actions.length;
987
+ const keep = row.keep.map((reason) => `keep: ${reason}`);
988
+ const parts = [...summary, ...keep];
989
+ console.log(`${row.run} [${row.status}] ${parts.length > 0 ? parts.join("; ") : "nothing to do"}`);
990
+ }
991
+ if (flags.apply !== "true") {
992
+ console.log(
993
+ actionable > 0
994
+ ? `plan only: ${actionable} action(s); rerun with --apply true to execute (branches whose candidate lives only there are always kept)`
995
+ : "nothing to collect",
996
+ );
997
+ return;
998
+ }
999
+ const results = applyGc(repoRoot, rows, { force: flags.force === "true" });
1000
+ let done = 0;
1001
+ for (const result of results) {
1002
+ const target = result.kind === "delete-branch" ? result.branch : result.path;
1003
+ if (result.done) {
1004
+ done += 1;
1005
+ console.log(` ${result.kind} ${target}: done`);
1006
+ } else {
1007
+ console.log(` ${result.kind} ${target}: NOT done (${result.error})`);
1008
+ }
1009
+ }
1010
+ console.log(`gc applied: ${done}/${results.length} action(s)`);
1011
+ }
1012
+
1013
+ function processAlive(pid) {
1014
+ try {
1015
+ process.kill(pid, 0);
1016
+ return true;
1017
+ } catch {
1018
+ return false;
1019
+ }
1020
+ }
1021
+
1022
+ const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
1023
+
1024
+ // Watches one RUNNING run and reports a stall once per step attempt. Exits
1025
+ // when the run leaves RUNNING, the parent process (if given) dies, or after a
1026
+ // single pass with --once true.
1027
+ async function commandWatch(flags) {
1028
+ if (!flags.repo || !flags.run) {
1029
+ throw new Error("watch requires --repo and --run");
1030
+ }
1031
+ const repoRoot = resolve(flags.repo);
1032
+ const stallAfterMs = stallAfterFromFlags(flags);
1033
+ const intervalMs = Number(flags.interval ?? 30) * 1000;
1034
+ const parent = flags.parent ? Number(flags.parent) : null;
1035
+ const repoLabel = repoLabelFor(repoRoot, flags.repo);
1036
+ const { config } = notifyConfigFor(repoRoot);
1037
+ const notified = new Set();
1038
+ let notifiedThisPass = false;
1039
+ for (;;) {
1040
+ notifiedThisPass = false;
1041
+ const ledger = EventLedger.open(ledgerPathFor(repoRoot, flags.run));
1042
+ if (!ledger.state.run) {
1043
+ console.log(`watch: no ledger for ${flags.run} yet`);
1044
+ } else if (ledger.state.run.status !== "RUNNING") {
1045
+ console.log(`watch: run is ${ledger.state.run.status}; done`);
1046
+ return;
1047
+ } else {
1048
+ const liveness = describeLiveness({ repoRoot, runId: flags.run, ledger, stallAfterMs });
1049
+ const live = liveness.inFlight;
1050
+ if (live?.stalled) {
1051
+ const key = `${live.step}#${live.attempt}`;
1052
+ if (!notified.has(key)) {
1053
+ notified.add(key);
1054
+ notifiedThisPass = true;
1055
+ console.log(
1056
+ `watch: STALLED ${live.step} attempt ${live.attempt} — no output for ${formatMs(live.sinceOutputMs)} (threshold ${formatMs(stallAfterMs)})`,
1057
+ );
1058
+ if (subscribes(config, "STALLED")) {
1059
+ const notification = buildNotification("STALLED", {
1060
+ repoLabel,
1061
+ state: ledger.state,
1062
+ detail: {
1063
+ step: live.step,
1064
+ attempt: live.attempt,
1065
+ startedAt: live.startedAt,
1066
+ elapsed: formatMs(live.elapsedMs),
1067
+ sinceOutput: formatMs(live.sinceOutputMs),
1068
+ threshold: formatMs(stallAfterMs),
1069
+ command: live.command,
1070
+ },
1071
+ });
1072
+ const results = await dispatchNotification(config, notification, { repoRoot });
1073
+ for (const row of results) {
1074
+ console.log(`watch: notify -> ${row.channel}: ${row.ok ? "sent" : row.error}`);
1075
+ }
1076
+ }
1077
+ }
1078
+ } else if (live) {
1079
+ console.log(`watch: ${live.step} attempt ${live.attempt} elapsed ${formatMs(live.elapsedMs)}, last output ${formatMs(live.sinceOutputMs)} ago`);
1080
+ }
1081
+ }
1082
+ if (flags.once === "true") {
1083
+ return notifiedThisPass;
1084
+ }
1085
+ if (parent !== null && !processAlive(parent)) {
1086
+ console.log("watch: parent process gone; done");
1087
+ return;
1088
+ }
1089
+ await sleep(intervalMs);
1090
+ }
1091
+ }
1092
+
522
1093
  function commandObserve(rest) {
523
1094
  const [sub, ...args] = rest;
524
1095
  const flags = parseFlags(args);
@@ -577,7 +1148,7 @@ function commandObserve(rest) {
577
1148
  }
578
1149
  }
579
1150
 
580
- function main() {
1151
+ async function main() {
581
1152
  const [command, ...rest] = process.argv.slice(2);
582
1153
  if (command === "observe" || command === "findings") {
583
1154
  try {
@@ -591,9 +1162,9 @@ function main() {
591
1162
  try {
592
1163
  const flags = parseFlags(rest);
593
1164
  if (command === "start") {
594
- commandStart(flags);
1165
+ await commandStart(flags);
595
1166
  } else if (command === "resume") {
596
- commandResume(flags);
1167
+ await commandResume(flags);
597
1168
  } else if (command === "inbox") {
598
1169
  commandInbox(flags);
599
1170
  } else if (command === "approve") {
@@ -614,6 +1185,12 @@ function main() {
614
1185
  commandStatus(flags);
615
1186
  } else if (command === "stop") {
616
1187
  commandStop(flags);
1188
+ } else if (command === "gc") {
1189
+ commandGc(flags);
1190
+ } else if (command === "overview") {
1191
+ commandOverview(flags);
1192
+ } else if (command === "watch") {
1193
+ await commandWatch(flags);
617
1194
  } else if (command === "preflight") {
618
1195
  commandPreflight(flags);
619
1196
  } else {
@@ -627,4 +1204,4 @@ function main() {
627
1204
  }
628
1205
  }
629
1206
 
630
- main();
1207
+ await main();