@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
@@ -1,6 +1,8 @@
1
1
  // Initial event type registry v1 per docs/v2/SPEC-0001-events-v1.md §4.
2
2
  // Semantics are frozen; the registry and per-type data may only grow additively.
3
3
 
4
+ import { isAbsolute } from "node:path";
5
+
4
6
  import {
5
7
  ACTOR_KINDS,
6
8
  BUDGET_KINDS,
@@ -58,6 +60,23 @@ const EVENT_ENUMS = {
58
60
  TRIAGE_RECORDED: { action: TRIAGE_ACTIONS },
59
61
  };
60
62
 
63
+ const REPO_REF_FIELDS = {
64
+ WORKSPACE_BOUND: ["repo", "worktreePath"],
65
+ EVIDENCE_RECORDED: ["evidenceRef"],
66
+ RUN_COMPACTED: ["runRecordRef"],
67
+ INTENT_DRAFTED: ["intentRef"],
68
+ TRIAGE_RECORDED: ["intentRef"],
69
+ };
70
+
71
+ function unsafeRepoRef(value) {
72
+ return (
73
+ typeof value !== "string" ||
74
+ value.length === 0 ||
75
+ isAbsolute(value) ||
76
+ /(^|[\\/])\.\.([\\/]|$)/.test(value)
77
+ );
78
+ }
79
+
61
80
  export class EventInputError extends Error {
62
81
  constructor(message) {
63
82
  super(message);
@@ -87,6 +106,11 @@ export function validateEventInput(type, actor, data) {
87
106
  throw new EventInputError(`event ${type} missing required data field: ${field}`);
88
107
  }
89
108
  }
109
+ for (const field of REPO_REF_FIELDS[type] ?? []) {
110
+ if (unsafeRepoRef(data[field])) {
111
+ throw new EventInputError(`event ${type} field ${field} must be a repository-relative reference`);
112
+ }
113
+ }
90
114
  const enums = EVENT_ENUMS[type];
91
115
  if (enums) {
92
116
  for (const [field, allowed] of Object.entries(enums)) {
@@ -140,6 +140,11 @@ export function applyEvent(state, event) {
140
140
  status: data.status,
141
141
  grade: data.grade,
142
142
  ...(data.findings ? { findings: data.findings } : {}),
143
+ ...(data.suppressedFingerprints
144
+ ? { suppressedFingerprints: data.suppressedFingerprints }
145
+ : {}),
146
+ ...(data.cacheKey ? { cacheKey: data.cacheKey } : {}),
147
+ ...(data.reused ? { reused: data.reused } : {}),
143
148
  });
144
149
  break;
145
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
 
@@ -17,21 +17,32 @@ export function collectCommandEvidence({
17
17
  kind = "command",
18
18
  grade = "L2",
19
19
  coverage = null,
20
+ redact = [],
20
21
  }) {
21
22
  const logsDir = join(runtimeDir, "runs", runId, "logs");
22
23
  mkdirSync(logsDir, { recursive: true });
23
24
  const logName = `${step}-${attempt}.log`;
24
25
  const logPath = join(logsDir, logName);
26
+ // Redaction (iteration 08, C6): patterns from the run config are applied
27
+ // to worker output before it becomes evidence. The digest binds the
28
+ // redacted text — what is on disk is what was hashed.
29
+ const scrub = (text) => {
30
+ let out = String(text ?? "");
31
+ for (const pattern of redact) {
32
+ out = out.replace(pattern, "<REDACTED>");
33
+ }
34
+ return out;
35
+ };
25
36
  const logBody = [
26
- `command: ${execResult.command}`,
37
+ `command: ${scrub(execResult.command)}`,
27
38
  `exitCode: ${execResult.exitCode}`,
28
39
  `signal: ${execResult.signal}`,
29
40
  `timedOut: ${execResult.timedOut}`,
30
41
  `spawnError: ${execResult.spawnError}`,
31
42
  "--- stdout ---",
32
- execResult.stdout,
43
+ scrub(execResult.stdout),
33
44
  "--- stderr ---",
34
- execResult.stderr,
45
+ scrub(execResult.stderr),
35
46
  ].join("\n");
36
47
  writeFileSync(logPath, logBody, "utf8");
37
48
  const digest = `sha256:${createHash("sha256").update(logBody, "utf8").digest("hex")}`;
@@ -22,6 +22,7 @@ import { collectCommandEvidence } from "../evidence/collector.js";
22
22
  import { EventLedger } from "../storage/event-ledger.js";
23
23
  import { loadObserveConfig, severityRank } from "./observe-config.js";
24
24
  import { OBSERVE_REDUCER } from "./observe-reducer.js";
25
+ import { toRepoRef } from "../runtime/repo-ref.js";
25
26
 
26
27
  const OBSERVE_IDS = { run: "OBSERVE", work: "OBSERVE" };
27
28
  const KERNEL_ACTOR = { kind: "kernel", id: "observe" };
@@ -142,7 +143,7 @@ function recordEvidence({ ledger, config, cycle, provider, execResult, kind, ste
142
143
  type: "EVIDENCE_RECORDED",
143
144
  actor: { kind: "provider", id: provider.id },
144
145
  data: {
145
- evidenceRef: record.location,
146
+ evidenceRef: toRepoRef(config.repoRoot, record.location),
146
147
  kind: record.kind,
147
148
  subject: record.subject,
148
149
  digest: record.digest,
@@ -350,7 +351,13 @@ export function runObserveCycle({ configPath, now = new Date().toISOString() })
350
351
  data: { cycle, providersRun: config.providers.length },
351
352
  ...OBSERVE_IDS,
352
353
  });
353
- return { cycle, ledgerPath: ledger.path, results, state: ledger.state };
354
+ return {
355
+ cycle,
356
+ ledgerPath: ledger.path,
357
+ ledgerRef: toRepoRef(config.repoRoot, ledger.path),
358
+ results,
359
+ state: ledger.state,
360
+ };
354
361
  }
355
362
 
356
363
  export function triageIntent({ repoRoot, intentRef, action, by = "unknown", note, now = new Date().toISOString() }) {
@@ -0,0 +1,36 @@
1
+ # Release readback lane (iteration 08, C8). The kernel still has no deploy
2
+ # capability (invariant 20): every production action stays a human's. What
3
+ # this lane fixes is the bookkeeping around it — a pilot go-live was
4
+ # ledgered by hand as forty "step N readback" commits. Here each step is a
5
+ # read-only command that proves the world is in the expected state; every
6
+ # failure stops for a human (maxAttempts 1, no fix edge), and evidence is
7
+ # graded L4 because it was read from the real environment.
8
+ #
9
+ # Shape: preflight (before the action) → [human performs the action; the
10
+ # risk preset "release" stops here] → apply-readback (proves it happened) →
11
+ # observe (proves it is healthy) → wait-close (human closes the window).
12
+ kind: workflow
13
+ version: 1
14
+ name: release-readback
15
+ entry: preflight
16
+ steps:
17
+ - id: preflight
18
+ worker: readback
19
+ readonly: true
20
+ grade: L4
21
+ - id: apply-readback
22
+ worker: readback
23
+ readonly: true
24
+ grade: L4
25
+ - id: observe
26
+ worker: observe
27
+ readonly: true
28
+ grade: L4
29
+ - id: wait-close
30
+ terminal:
31
+ - wait-close
32
+ budgets:
33
+ maxAttempts:
34
+ preflight: 1
35
+ apply-readback: 1
36
+ observe: 1
@@ -0,0 +1,21 @@
1
+ # Release lane (iteration 08, C8): pairs with the release-readback workflow.
2
+ # Automation stops before apply-readback — that pause is where the human
3
+ # performs the production action — and the window may only close behind L4
4
+ # evidence read from the real environment. (No finding floor: the lane has
5
+ # no reviewer step, and a rule that cannot be satisfied is not a gate.)
6
+ kind: risk-preset
7
+ version: 1
8
+ name: release
9
+ stopAt:
10
+ - apply-readback
11
+ policies:
12
+ - kind: policy
13
+ version: 1
14
+ name: close-evidence-floor
15
+ type: transition
16
+ appliesTo: enter-wait-close
17
+ enforcement: LOCAL_ENFORCED
18
+ rule:
19
+ evidence.exists:
20
+ kind: command
21
+ minGrade: L4
@@ -37,3 +37,8 @@ transitions:
37
37
  to: fix
38
38
  terminal:
39
39
  - wait-merge
40
+ # Review rounds are capped by default (deploy-campaign charter: two review
41
+ # rounds per run, then a human). Override per project via budgets.maxAttempts.
42
+ budgets:
43
+ maxAttempts:
44
+ review: 2
@@ -0,0 +1,124 @@
1
+ // Verification reuse and incremental review (iteration 08, C7).
2
+ //
3
+ // Reuse: a verify step whose input is byte-identical to one that already
4
+ // passed — same tree, same worker command, same envelope — is not re-run; the
5
+ // earlier evidence is referenced and the reuse is visible in the ledger
6
+ // (`reused`) and in status. Only *passed* results are ever reused; a failure
7
+ // always runs again. Real number: the deploy campaign's envelope-side cache
8
+ // cut verify from 25 to 13 minutes per round.
9
+ //
10
+ // Incremental review: a reviewer is told which candidate the last review
11
+ // looked at (when it is an ancestor of the current one) so it can focus on
12
+ // the delta instead of re-reading the whole change every round.
13
+
14
+ import { execFileSync } from "node:child_process";
15
+ import { createHash } from "node:crypto";
16
+ import { existsSync, readdirSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ import { EventLedger } from "../storage/event-ledger.js";
20
+ import { canonicalJson } from "../storage/event-ledger.js";
21
+
22
+ function git(cwd, args) {
23
+ return execFileSync("git", ["-C", cwd, ...args], {
24
+ encoding: "utf8",
25
+ stdio: ["ignore", "pipe", "ignore"],
26
+ }).trim();
27
+ }
28
+
29
+ export function treeHash(worktreePath) {
30
+ return git(worktreePath, ["rev-parse", "HEAD^{tree}"]);
31
+ }
32
+
33
+ export function cacheKey({ tree, worker, adapterSpec, adapterName, envelopeDigest }) {
34
+ const body = canonicalJson({
35
+ tree,
36
+ worker,
37
+ adapter: adapterSpec ?? adapterName ?? null,
38
+ envelope: envelopeDigest ?? null,
39
+ });
40
+ return `sha256:${createHash("sha256").update(body, "utf8").digest("hex")}`;
41
+ }
42
+
43
+ function eachLedger(repoRoot, { excludeRun = null } = {}) {
44
+ const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
45
+ const out = [];
46
+ if (!existsSync(runsDir)) {
47
+ return out;
48
+ }
49
+ for (const entry of readdirSync(runsDir).sort()) {
50
+ if (entry === excludeRun) {
51
+ continue;
52
+ }
53
+ const path = join(runsDir, entry, "events.jsonl");
54
+ if (!existsSync(path)) {
55
+ continue;
56
+ }
57
+ const ledger = EventLedger.open(path);
58
+ if (ledger.state.run) {
59
+ out.push(ledger);
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+
65
+ // Latest passed command evidence carrying this cache key, across the
66
+ // repository's runs (the current run included: a re-verify after an
67
+ // unrelated fix step on the same tree is the common case).
68
+ export function findReusableEvidence(repoRoot, key) {
69
+ let best = null;
70
+ for (const ledger of eachLedger(repoRoot)) {
71
+ for (const event of ledger.events) {
72
+ if (
73
+ event.type === "EVIDENCE_RECORDED" &&
74
+ event.data.cacheKey === key &&
75
+ event.data.status === "passed" &&
76
+ event.data.kind === "command" &&
77
+ !event.data.reused
78
+ ) {
79
+ if (!best || event.ts > best.ts) {
80
+ best = { run: ledger.state.run.id, evidenceRef: event.data.evidenceRef, digest: event.data.digest, grade: event.data.grade, ts: event.ts };
81
+ }
82
+ }
83
+ }
84
+ }
85
+ return best;
86
+ }
87
+
88
+ // The most recent review evidence for this work whose subject is an ancestor
89
+ // of (and not equal to) the current head — the anchor for an incremental
90
+ // review. Null when there is no such review.
91
+ export function lastReviewedCandidate(repoRoot, workId, worktreePath, head) {
92
+ let best = null;
93
+ for (const ledger of eachLedger(repoRoot)) {
94
+ if (ledger.state.run.work !== workId) {
95
+ continue;
96
+ }
97
+ for (const event of ledger.events) {
98
+ if (event.type !== "EVIDENCE_RECORDED" || event.data.kind !== "review") {
99
+ continue;
100
+ }
101
+ const subject = event.data.subject;
102
+ if (!subject || subject === head) {
103
+ continue;
104
+ }
105
+ if (best && event.ts <= best.ts) {
106
+ continue;
107
+ }
108
+ let ancestor = false;
109
+ try {
110
+ execFileSync("git", ["-C", worktreePath, "merge-base", "--is-ancestor", subject, head], { stdio: "ignore" });
111
+ ancestor = true;
112
+ } catch {
113
+ ancestor = false;
114
+ }
115
+ if (ancestor) {
116
+ best = { candidate: subject, run: ledger.state.run.id, evidenceRef: event.data.evidenceRef, ts: event.ts };
117
+ }
118
+ }
119
+ }
120
+ if (!best) {
121
+ return null;
122
+ }
123
+ return { candidate: best.candidate, run: best.run, evidenceRef: best.evidenceRef, range: `${best.candidate}..${head}` };
124
+ }
@@ -13,6 +13,7 @@ import { evaluatePolicies, sha256Text } from "../policy/policy.js";
13
13
  import { EventLedger } from "../storage/event-ledger.js";
14
14
  import { acquireLock, readback, releaseLock } from "../workspace/workspace-manager.js";
15
15
  import { writeRunRecord } from "./run-record.js";
16
+ import { resolveRepoRef } from "./repo-ref.js";
16
17
 
17
18
  const KERNEL = { kind: "kernel", id: "orchestrator" };
18
19
 
@@ -68,10 +69,11 @@ export function approveRun(repoRoot, runId, { by = "human", transition, ts, poli
68
69
  acquireLock(repoRoot, runId);
69
70
  try {
70
71
  const bound = ledger.state.workspaces[runId];
71
- if (!bound || !existsSync(bound.worktreePath)) {
72
+ const worktreePath = bound ? resolveRepoRef(repoRoot, bound.worktreePath) : null;
73
+ if (!bound || !existsSync(worktreePath)) {
72
74
  throw new DecisionError(`worktree missing for ${runId}; cannot verify the approval subject`);
73
75
  }
74
- const tree = readback(bound.worktreePath);
76
+ const tree = readback(worktreePath);
75
77
  const when = ts ?? new Date().toISOString();
76
78
  if (tree.dirty || tree.head !== pending.subject.candidate) {
77
79
  const lastEvidence = ledger.state.evidence[ledger.state.evidence.length - 1];
@@ -102,8 +104,8 @@ export function approveRun(repoRoot, runId, { by = "human", transition, ts, poli
102
104
  state: ledger.state,
103
105
  candidate: pending.subject.candidate,
104
106
  workDir: join(repoRoot, "delivery", "work", ledger.state.run.work),
105
- worktreePath: bound.worktreePath,
106
- readWorktree: () => readback(bound.worktreePath),
107
+ worktreePath,
108
+ readWorktree: () => readback(worktreePath),
107
109
  },
108
110
  );
109
111
  for (const row of policyRows) {
@@ -0,0 +1,135 @@
1
+ // Environment contract: a run config may declare the binaries (and minimum
2
+ // versions) its frozen envelope silently depends on, and the kernel checks
3
+ // them fail-closed before a run starts. Absorbed from real incidents: a
4
+ // frozen verifier needed `rg` that only a vendored PATH provided, candidate
5
+ // scripts assumed bash >= 4 on a /bin/bash 3.2 host, and a fresh shell
6
+ // resolved Node 14 — each burned runs before anyone saw the real cause.
7
+
8
+ import { spawnSync } from "node:child_process";
9
+
10
+ export class EnvContractError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = "EnvContractError";
14
+ }
15
+ }
16
+
17
+ // Version detection in --version output needs at least major.minor (a bare
18
+ // integer in arbitrary output is too easy to false-match); a declared min
19
+ // may be major-only ("20").
20
+ function parseVersion(text) {
21
+ const match = String(text).match(/(\d+)\.(\d+)(?:\.(\d+))?/);
22
+ if (!match) {
23
+ return null;
24
+ }
25
+ return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)];
26
+ }
27
+
28
+ function parseMin(value) {
29
+ const match = String(value).trim().match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
30
+ if (!match) {
31
+ return null;
32
+ }
33
+ return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)];
34
+ }
35
+
36
+ function compareVersions(left, right) {
37
+ for (let index = 0; index < 3; index += 1) {
38
+ if ((left[index] ?? 0) !== (right[index] ?? 0)) {
39
+ return (left[index] ?? 0) - (right[index] ?? 0);
40
+ }
41
+ }
42
+ return 0;
43
+ }
44
+
45
+ // Checks every entry and reports all problems at once (a run that dies on
46
+ // the first missing binary hides the second). Non-zero --version exits are
47
+ // tolerated; only a spawn failure means "not runnable".
48
+ export function checkRequires(requires) {
49
+ const problems = [];
50
+ const checked = [];
51
+ for (const entry of requires ?? []) {
52
+ // Probe entries (iteration 08, C10): an environment fact that is not a
53
+ // binary version — "Redis answers PING on the target", "python on the
54
+ // host is >= 3.9" — expressed as a shell command whose exit code (and
55
+ // optionally output) must match. Absorbed from the first-proof rerun:
56
+ // Redis < 7 and Python 3.6 on the target burned a window each because
57
+ // nothing checked them before the run.
58
+ if (entry && typeof entry === "object" && typeof entry.probe === "string" && entry.probe.length > 0) {
59
+ const label = entry.name ?? entry.probe;
60
+ const run = spawnSync("bash", ["-lc", entry.probe], { encoding: "utf8", timeout: entry.timeoutMs ?? 30_000 });
61
+ if (run.error) {
62
+ problems.push(`${label}: probe could not run (${run.error.code ?? run.error.message})`);
63
+ continue;
64
+ }
65
+ const output = `${run.stdout ?? ""}\n${run.stderr ?? ""}`;
66
+ if (run.status !== 0) {
67
+ problems.push(`${label}: probe exited ${run.status}${output.trim() ? ` — ${output.trim().split("\n").slice(-1)[0].slice(0, 160)}` : ""}`);
68
+ continue;
69
+ }
70
+ if (entry.expect !== undefined) {
71
+ let matcher;
72
+ try {
73
+ matcher = new RegExp(String(entry.expect));
74
+ } catch {
75
+ problems.push(`${label}: expect ${JSON.stringify(entry.expect)} is not a valid regular expression`);
76
+ continue;
77
+ }
78
+ if (!matcher.test(output)) {
79
+ problems.push(`${label}: probe output does not match expect ${JSON.stringify(entry.expect)}`);
80
+ continue;
81
+ }
82
+ }
83
+ checked.push({ command: label, version: null, probe: true });
84
+ continue;
85
+ }
86
+ if (!entry || typeof entry !== "object" || typeof entry.command !== "string" || entry.command.length === 0) {
87
+ problems.push(`requires entries need a command name or a probe, got: ${JSON.stringify(entry)}`);
88
+ continue;
89
+ }
90
+ // A generous timeout: a missing binary fails instantly (ENOENT), while a
91
+ // loaded host must not turn a present binary into a false "not runnable".
92
+ const probe = spawnSync(entry.command, [entry.versionFlag ?? "--version"], {
93
+ encoding: "utf8",
94
+ timeout: 30_000,
95
+ });
96
+ if (probe.error) {
97
+ problems.push(
98
+ `${entry.command}: not runnable in this environment (${probe.error.code ?? probe.error.message})`,
99
+ );
100
+ continue;
101
+ }
102
+ const version = parseVersion(`${probe.stdout ?? ""}\n${probe.stderr ?? ""}`);
103
+ if (entry.min !== undefined) {
104
+ const min = parseMin(entry.min);
105
+ if (!min) {
106
+ problems.push(`${entry.command}: min ${JSON.stringify(entry.min)} is not a version`);
107
+ continue;
108
+ }
109
+ if (!version) {
110
+ problems.push(
111
+ `${entry.command}: version undetectable but min ${entry.min} is required (fail closed)`,
112
+ );
113
+ continue;
114
+ }
115
+ if (compareVersions(version, min) < 0) {
116
+ problems.push(
117
+ `${entry.command}: resolves to ${version.join(".")}, below required ${entry.min}`,
118
+ );
119
+ continue;
120
+ }
121
+ }
122
+ checked.push({ command: entry.command, version: version ? version.join(".") : null });
123
+ }
124
+ return { ok: problems.length === 0, problems, checked };
125
+ }
126
+
127
+ export function assertRequires(requires) {
128
+ const result = checkRequires(requires);
129
+ if (!result.ok) {
130
+ throw new EnvContractError(
131
+ `environment contract not satisfied:\n - ${result.problems.join("\n - ")}`,
132
+ );
133
+ }
134
+ return result;
135
+ }