faberun 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. package/src/web/server.mjs +552 -0
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Proving a resumed run is the same run.
3
+ *
4
+ * A run is pinned to a git head, a dirty-tree fingerprint, a hash per task
5
+ * packet, the agent-guidance files in force and the harness versions observed.
6
+ * `assertSourceUnchanged` is where a resume against a moved head, an edited
7
+ * packet or a different harness build is refused: continuing there would mean
8
+ * finishing work nobody approved, on evidence that no longer holds.
9
+ *
10
+ * `probeRuntimeVersionStable` retries because a cold CLI sometimes reports no
11
+ * version on its first call, and a missing version is indistinguishable from a
12
+ * changed one.
13
+ */
14
+ import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION, probeRuntime } from "../harnesses/index.mjs";
15
+ import { appendJsonl, writeJsonAtomic } from "../run/store.mjs";
16
+ import { blockingChecks, environmentPreflight, reachableRuntimes } from "../host/preflight.mjs";
17
+ import { captureSourceIdentity } from "../repo/source-identity.mjs";
18
+ import { boundedGitSync } from "../repo/worktree.mjs";
19
+ import { createHash } from "node:crypto";
20
+ import { lstatSync, readFileSync, readdirSync } from "node:fs";
21
+ import { join, relative, sep } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { stableJson } from "../util.mjs";
24
+ import { validateRunMetadata } from "../contract/snapshot.mjs";
25
+ import { contractDigest } from "../contract/index.mjs";
26
+
27
+ /** @typedef {import("../harnesses/index.mjs").HarnessRuntime} HarnessRuntime */
28
+ /** @typedef {import("../cli.mjs").LockHandle} LockHandle */
29
+ /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
30
+ /** @typedef {import("../contract/index.mjs").RunMetadata} RunMetadata */
31
+ /** @typedef {import("../contract/index.mjs").SourceIdentity} SourceIdentity */
32
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
33
+ /** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
34
+ /** @typedef {import("../contract/index.mjs").WorkspaceScopeBoundary} WorkspaceScopeBoundary */
35
+
36
+ /**
37
+ * @param {LockHandle} lock
38
+ * @param {SourceIdentity} sourceIdentity
39
+ * @param {{identityWarnings?: string[], relaunchCount?: number, lastRelaunchProgressAt?: string|null, attention?: {code: string, message: string, at: string}|null, controllerIdentity?: import("../contract/index.mjs").ControllerIdentity}} [resume]
40
+ * @param {string} [integrationRef]
41
+ * @returns {RunMetadata}
42
+ */
43
+ export function createRunMetadata(lock, sourceIdentity, resume = {}, integrationRef = undefined) {
44
+ const current = lock.current;
45
+ // Both frozen records are carried forward from the run that already exists.
46
+ // `driveRun` rewrites run.json on every controller start, so recomputing
47
+ // them from the current tree would let a resume or a heartbeat relaunch
48
+ // reinterpret history against a tree that has since drifted.
49
+ const stored = readStoredRunRecords(sourceIdentity);
50
+ const digest = stored.contractDigest ?? readContractDigest(sourceIdentity);
51
+ const controllerIdentity = stored.controllerIdentity ?? resume.controllerIdentity ?? defaultControllerIdentity();
52
+ const scopeDecision = stored.scopeDecision ?? {
53
+ at: current.startedAt,
54
+ base: sourceIdentity.gitHead ?? null,
55
+ dirtyTreeFingerprint: sourceIdentity.dirtyTreeFingerprint ?? null,
56
+ };
57
+ const metadata = {
58
+ schemaVersion: PROTOCOL_SCHEMA_VERSION,
59
+ contractVersion: CONTRACT_VERSION,
60
+ pid: current.pid,
61
+ processStartToken: current.processStartToken,
62
+ startedAt: current.startedAt,
63
+ sourceIdentity,
64
+ controllerIdentity,
65
+ ...(integrationRef ? { integrationRef } : {}),
66
+ ...(resume.identityWarnings?.length ? { identityWarnings: resume.identityWarnings } : {}),
67
+ // The supervisor's relaunch guard and its durable attention record are
68
+ // carried through a controller restart by the controller itself; a field
69
+ // not named here is dropped by the fixed field list above.
70
+ ...(resume.relaunchCount !== undefined ? { relaunchCount: resume.relaunchCount } : {}),
71
+ ...(resume.lastRelaunchProgressAt !== undefined ? { lastRelaunchProgressAt: resume.lastRelaunchProgressAt } : {}),
72
+ ...(resume.attention !== undefined ? { attention: resume.attention } : {}),
73
+ ...(digest !== null ? { contractDigest: digest } : {}),
74
+ // The one-shot auto-retry ledger is durable across a controller restart
75
+ // for the same reason: an in-memory flag would grant a fresh retry.
76
+ ...(stored.autoRetries !== undefined ? { autoRetries: stored.autoRetries } : {}),
77
+ scopeDecision,
78
+ };
79
+ return validateRunMetadata(metadata);
80
+ }
81
+
82
+ /**
83
+ * The run directory the source identity names: the run lives at
84
+ * `<cwd>/.runs/<contractId>`, which is derivable from the identity alone.
85
+ *
86
+ * @param {SourceIdentity} sourceIdentity
87
+ * @returns {string|null}
88
+ */
89
+ function runDirFor(sourceIdentity) {
90
+ if (!sourceIdentity.cwd || !sourceIdentity.contractId) return null;
91
+ return join(sourceIdentity.cwd, ".runs", sourceIdentity.contractId);
92
+ }
93
+
94
+ /**
95
+ * The records a prior run.json already froze, when one exists. On creation
96
+ * this is empty; on every later metadata rewrite it is the source of truth.
97
+ *
98
+ * @param {SourceIdentity} sourceIdentity
99
+ * @returns {{contractDigest?: string, scopeDecision?: import("../contract/index.mjs").ScopeDecision, autoRetries?: Record<string, {code: string, at: string}>, controllerIdentity?: import("../contract/index.mjs").ControllerIdentity}}
100
+ */
101
+ function readStoredRunRecords(sourceIdentity) {
102
+ const runDir = runDirFor(sourceIdentity);
103
+ if (!runDir) return {};
104
+ /** @type {Record<string, unknown>} */
105
+ let record;
106
+ try {
107
+ record = JSON.parse(readFileSync(join(runDir, "run.json"), "utf8"));
108
+ } catch {
109
+ return {};
110
+ }
111
+ return {
112
+ ...(record.contractDigest !== undefined ? { contractDigest: /** @type {string} */ (record.contractDigest) } : {}),
113
+ ...(record.scopeDecision !== undefined ? { scopeDecision: /** @type {import("../contract/index.mjs").ScopeDecision} */ (record.scopeDecision) } : {}),
114
+ ...(record.autoRetries !== undefined ? { autoRetries: /** @type {Record<string, {code: string, at: string}>} */ (record.autoRetries) } : {}),
115
+ ...(record.controllerIdentity !== undefined ? { controllerIdentity: /** @type {import("../contract/index.mjs").ControllerIdentity} */ (record.controllerIdentity) } : {}),
116
+ };
117
+ }
118
+
119
+ /**
120
+ * The digest of the contract stored in a run directory, computed over the
121
+ * stored bytes. The controller writes this file and records this digest at
122
+ * creation; a later reader must read the same file for the same answer,
123
+ * because the in-memory validated contract carries `undefined` fields the
124
+ * stored JSON dropped, so hashing the two objects disagrees.
125
+ *
126
+ * @param {string} runDir
127
+ * @returns {string}
128
+ */
129
+ export function storedContractDigest(runDir) {
130
+ const raw = JSON.parse(readFileSync(join(runDir, "contract.json"), "utf8"));
131
+ return contractDigest(raw);
132
+ }
133
+
134
+ /**
135
+ * The digest of the stored contract.json, computed at creation and only then:
136
+ * the contract bytes are frozen at launch, and a persisted load compares
137
+ * against this record rather than against the mutated tree.
138
+ *
139
+ * @param {SourceIdentity} sourceIdentity
140
+ * @returns {string|null}
141
+ */
142
+ function readContractDigest(sourceIdentity) {
143
+ const runDir = runDirFor(sourceIdentity);
144
+ if (!runDir) return null;
145
+ return storedContractDigest(runDir);
146
+ }
147
+ const HARNESS_PROBE_RETRIES = 2;
148
+ const HARNESS_PROBE_RETRY_BACKOFF_MS = 250;
149
+ /**
150
+ * Probe a runtime version, retrying transient unavailability so a loaded host
151
+ * is never misread as a changed harness. Only a concrete version or final
152
+ * unavailability leaves this function.
153
+ *
154
+ * @param {import("../harnesses/index.mjs").HarnessRuntime & {capabilities?: unknown}} runtime
155
+ * @param {string} cwd
156
+ * @returns {Promise<string|null>}
157
+ */
158
+ export async function probeRuntimeVersionStable(runtime, cwd) {
159
+ for (let attempt = 0; ; attempt += 1) {
160
+ const result = await probeRuntime(runtime, { cwd, timeoutSec: 5 });
161
+ if (result.version !== null || attempt >= HARNESS_PROBE_RETRIES) return result.version ?? null;
162
+ await new Promise((resolveRetry) => setTimeout(resolveRetry, HARNESS_PROBE_RETRY_BACKOFF_MS * 2 ** attempt));
163
+ }
164
+ }
165
+ /**
166
+ * The base ref a *creation* was launched with, set by the CLI entry before it
167
+ * calls `runContract`. It is a module rather than an option because
168
+ * `runContract` reads the contract itself and threads no base ref; a resume
169
+ * never sets it, so the identity a resume captures is the checkout's HEAD as
170
+ * before.
171
+ *
172
+ * @type {string|null}
173
+ */
174
+ let pendingLaunchBaseRef = null;
175
+
176
+ /**
177
+ * @param {string|undefined|null} baseRef
178
+ * @returns {void}
179
+ */
180
+ export function setLaunchBaseRef(baseRef) {
181
+ pendingLaunchBaseRef = baseRef ?? null;
182
+ }
183
+
184
+ /**
185
+ * Capture the run's source identity, including one version-only probe per
186
+ * distinct routed runtime (a local binary call, no model tokens) so a later
187
+ * resume can refuse a harness that was upgraded or broke mid-campaign.
188
+ *
189
+ * @param {ValidatedContract} contract
190
+ * @param {Map<string, import("../repo/workspace.mjs").WorkspaceScopeBoundary>} scopeBoundaries
191
+ * @param {string} [baseRef] the ref the run is cut from; defaults to the CLI's `--base-ref`
192
+ * @returns {Promise<SourceIdentity>}
193
+ */
194
+ export async function captureRunIdentity(contract, scopeBoundaries, baseRef = pendingLaunchBaseRef ?? undefined) {
195
+ const runtimes = reachableRuntimes(contract);
196
+ const versionsPromise = Promise.all([...runtimes.entries()].map(async ([id, { runtime }]) => {
197
+ return [id, await probeRuntimeVersionStable(runtime, contract.cwd)];
198
+ }));
199
+ const ignorePaths = [...new Set([...scopeBoundaries.values()].flatMap((boundary) => boundary.files))];
200
+ const ignoreRoots = [...new Set([...scopeBoundaries.values()].flatMap((boundary) => boundary.roots))];
201
+ const identity = captureSourceIdentity(contract, {}, { ignorePaths, ignoreRoots, ...(baseRef ? { baseRef } : {}) });
202
+ const versions = await versionsPromise;
203
+ return { ...identity, harnessVersions: Object.fromEntries(versions) };
204
+ }
205
+ /**
206
+ * Compare the recorded source identity with the current one. A HEAD that
207
+ * descends from the recorded one is accepted and recorded — workers and the
208
+ * orchestrator commit between attempts, so a retry in place expects the branch
209
+ * to have moved on — while a non-descendant HEAD is still drift. A dirty-tree
210
+ * fingerprint mismatch is only a warning: the fingerprint covers the whole
211
+ * tree, so any committed work between attempts changes it.
212
+ *
213
+ * @param {SourceIdentity|undefined} expected
214
+ * @param {SourceIdentity|undefined} actual
215
+ * @returns {{warnings: string[]}} warnings to surface in status
216
+ */
217
+ export function assertSourceUnchanged(expected, actual) {
218
+ const fields = ["cwd", "gitHead", "dirtyTreeFingerprint", "packetHashes", "harnessVersions"];
219
+ /** @type {string[]} */
220
+ const warnings = [];
221
+ for (const field of fields) {
222
+ const expectedRecord = /** @type {Record<string, unknown>|undefined} */ (expected);
223
+ const actualRecord = /** @type {Record<string, unknown>|undefined} */ (actual);
224
+ if (expectedRecord?.[field] === undefined) throw new Error(`source identity is incomplete; resume refused`);
225
+ if (field === "harnessVersions") {
226
+ const expectedVersions = /** @type {Record<string, string|null>} */ (expectedRecord?.[field] ?? {});
227
+ const actualVersions = /** @type {Record<string, string|null>} */ (actualRecord?.[field] ?? {});
228
+ const ids = new Set([...Object.keys(expectedVersions), ...Object.keys(actualVersions)]);
229
+ for (const id of ids) {
230
+ const expectedVersion = expectedVersions[id] ?? null;
231
+ const actualVersion = actualVersions[id] ?? null;
232
+ if (expectedVersion === actualVersion) continue;
233
+ if (expectedVersion === null || actualVersion === null) {
234
+ throw new Error(`harness probe unavailable for ${id}; resume refused`);
235
+ }
236
+ throw new Error("source drift detected in harnessVersions; resume refused");
237
+ }
238
+ continue;
239
+ }
240
+ if (field === "gitHead" && stableJson(expectedRecord?.gitHead ?? null) !== stableJson(actualRecord?.gitHead ?? null)) {
241
+ const expectedHead = typeof expectedRecord?.gitHead === "string" ? expectedRecord.gitHead : null;
242
+ const actualHead = typeof actualRecord?.gitHead === "string" ? actualRecord.gitHead : null;
243
+ if (expectedHead && actualHead && isDescendantHead(expected?.cwd, expectedHead, actualHead)) continue;
244
+ throw new Error(`source drift detected in gitHead; resume refused`);
245
+ }
246
+ if (field === "dirtyTreeFingerprint" && stableJson(expectedRecord?.[field] ?? null) !== stableJson(actualRecord?.[field] ?? null)) {
247
+ warnings.push("source tree fingerprint changed since the run started; work committed between attempts is expected and the run continues on the current tree");
248
+ continue;
249
+ }
250
+ if (stableJson(expectedRecord?.[field] ?? null) !== stableJson(actualRecord?.[field] ?? null)) {
251
+ throw new Error(`source drift detected in ${field}; resume refused`);
252
+ }
253
+ }
254
+ return { warnings };
255
+ }
256
+ /**
257
+ * Whether `head` is a descendant of `recorded` (or the same commit).
258
+ *
259
+ * @param {string|undefined} cwd
260
+ * @param {string} recorded
261
+ * @param {string} head
262
+ * @returns {boolean}
263
+ */
264
+ export function isDescendantHead(cwd, recorded, head) {
265
+ if (!cwd) return false;
266
+ const result = boundedGitSync(["-C", cwd, "merge-base", "--is-ancestor", recorded, head], { encoding: "utf8" });
267
+ return result.status === 0;
268
+ }
269
+ /**
270
+ * The controller snapshot a freshly created run is pinned to when the caller
271
+ * names none: the source tree of the controller that is running now.
272
+ *
273
+ * @returns {string}
274
+ */
275
+ export function controllerSnapshotPath() {
276
+ return fileURLToPath(new URL("../", import.meta.url));
277
+ }
278
+ /**
279
+ * Hash a controller executable snapshot: a single file, or every file below a
280
+ * directory, in sorted relative-path order, so two reads of an unchanged
281
+ * snapshot always agree and any edit changes the sha.
282
+ *
283
+ * @param {string} snapshotPath
284
+ * @returns {string}
285
+ */
286
+ function hashControllerSnapshot(snapshotPath) {
287
+ const hash = createHash("sha256");
288
+ if (lstatSync(snapshotPath).isDirectory()) {
289
+ for (const file of walkControllerFiles(snapshotPath)) {
290
+ hash.update(relative(snapshotPath, file).split(sep).join("/"));
291
+ hash.update("\0");
292
+ hash.update(readFileSync(file));
293
+ hash.update("\0");
294
+ }
295
+ } else {
296
+ hash.update(readFileSync(snapshotPath));
297
+ }
298
+ return hash.digest("hex");
299
+ }
300
+ /**
301
+ * @param {string} root
302
+ * @returns {string[]}
303
+ */
304
+ function walkControllerFiles(root) {
305
+ /** @type {string[]} */
306
+ const found = [];
307
+ for (const entry of readdirSync(root, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
308
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".runs") continue;
309
+ const path = join(root, entry.name);
310
+ if (entry.isDirectory()) found.push(...walkControllerFiles(path));
311
+ else if (entry.isFile()) found.push(path);
312
+ }
313
+ return found;
314
+ }
315
+ /**
316
+ * The `{path, sha}` identity of a controller snapshot. `path` is the file or
317
+ * directory a launch would execute from; `sha` is its content digest.
318
+ *
319
+ * @param {string} snapshotPath
320
+ * @returns {import("../contract/index.mjs").ControllerIdentity}
321
+ */
322
+ export function controllerSnapshotIdentity(snapshotPath) {
323
+ return { path: snapshotPath, sha: hashControllerSnapshot(snapshotPath) };
324
+ }
325
+ /**
326
+ * @returns {import("../contract/index.mjs").ControllerIdentity}
327
+ */
328
+ export function defaultControllerIdentity() {
329
+ return controllerSnapshotIdentity(controllerSnapshotPath());
330
+ }
331
+ /**
332
+ * The check the next chain node runs before launching N+1: the executable
333
+ * snapshot at the recorded path must still hash to the recorded sha. A path
334
+ * plus sha is not enough on its own because the path can be rewritten under a
335
+ * recorded sha.
336
+ *
337
+ * @param {import("../contract/index.mjs").ControllerIdentity} identity
338
+ * @param {string} [snapshotPath]
339
+ * @returns {import("../contract/index.mjs").ControllerIdentity}
340
+ */
341
+ export function verifyControllerIdentity(identity, snapshotPath = identity.path) {
342
+ let actual;
343
+ try {
344
+ actual = controllerSnapshotIdentity(snapshotPath);
345
+ } catch (error) {
346
+ const message = error instanceof Error ? error.message : String(error);
347
+ throw Object.assign(new Error(`controller snapshot at ${identity.path} is unreadable: ${message}`), { code: "controller_snapshot_missing" });
348
+ }
349
+ if (actual.sha !== identity.sha) {
350
+ throw Object.assign(
351
+ new Error(`controller snapshot at ${identity.path} does not match its recorded sha ${identity.sha}; refresh the snapshot as a declared human boundary`),
352
+ { code: "controller_snapshot_changed" },
353
+ );
354
+ }
355
+ return actual;
356
+ }
357
+ /**
358
+ * @param {Map<string, NodeSnapshot>} states
359
+ * @returns {string}
360
+ */
361
+ export function statesFingerprint(states) {
362
+ return [...states.values()].map((state) => `${state.id}:${state.status}:${state.phase}:${state.attempt ?? 0}:${state.revisions ?? 0}`).join("|");
363
+ }
364
+ /**
365
+ * @param {ValidatedContract} contract
366
+ * @returns {ValidatedContract}
367
+ */
368
+ export function serializableContract(contract) {
369
+ const { warnings, ...rest } = contract;
370
+ return {
371
+ ...rest,
372
+ warnings,
373
+ nodes: contract.nodes.map((node) => {
374
+ const copy = /** @type {Record<string, unknown>} */ ({ ...node });
375
+ delete copy.prompt;
376
+ delete copy.promptFile;
377
+ delete copy.taskPacketFile;
378
+ return /** @type {ValidatedNode} */ (copy);
379
+ }),
380
+ };
381
+ }
382
+ /**
383
+ * The dispatch gate: no node starts until the host can carry the run. The
384
+ * report is written as run evidence either way, and a blocking failure leaves
385
+ * the materialized run untouched — the operator fixes the host and resumes,
386
+ * so a run is never silently restarted and already-paid nodes are not redone.
387
+ *
388
+ * @param {ValidatedContract} contract
389
+ * @param {string} runDir
390
+ * @param {SourceIdentity|undefined} sourceIdentity
391
+ */
392
+ export function assertEnvironmentReady(contract, runDir, sourceIdentity) {
393
+ const report = environmentPreflight({
394
+ cwd: contract.cwd,
395
+ runtimes: reachableRuntimes(contract),
396
+ harnessVersions: sourceIdentity?.harnessVersions ?? {},
397
+ });
398
+ const evidence = {
399
+ schemaVersion: report.schemaVersion,
400
+ contractVersion: CONTRACT_VERSION,
401
+ at: new Date().toISOString(),
402
+ contractId: contract.id,
403
+ ok: report.ok,
404
+ checks: report.checks,
405
+ };
406
+ writeJsonAtomic(join(runDir, "env-preflight.json"), evidence);
407
+ if (report.ok) return;
408
+ appendJsonl(join(runDir, "events.jsonl"), { type: "run.env-preflight-failed", ...evidence });
409
+ const blocking = blockingChecks(report).map((check) => `${check.name}: ${check.detail}`).join(" · ");
410
+ throw Object.assign(new Error(`env_preflight_failed: ${blocking} · the run stays resumable: fix the environment and resume ${runDir}`), { code: "env_preflight_failed" });
411
+ }
@@ -0,0 +1,235 @@
1
+ import { normalizeProviderAvailability, probeRuntime } from "../harnesses/index.mjs";
2
+
3
+ // Availability normalization belongs to the adapter registry, which is where
4
+ // each provider's own exhaustion, balance, and authentication wording is
5
+ // already classified. Re-exported here so discovery callers keep one import
6
+ // site; a second copy of these two functions is how they drift apart.
7
+ export { exhaustedUntilOf, normalizeProviderAvailability } from "../harnesses/index.mjs";
8
+
9
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
10
+ /** @typedef {{harness?: string, model?: string, vendor: string, tier?: number|string, costRank?: number, [key: string]: unknown}} RuntimeLike */
11
+ /** @typedef {{runtimes: Record<string, RuntimeLike>, runtimeDefaults?: {worker?: string, judge?: string}, nodes?: {id: string, runtime?: string, gate: {enabled: boolean, runtime?: string}}[]}} RuntimeContract */
12
+ /** @typedef {{available: boolean, exhaustedUntil: string|null, reason: string}} RuntimeAvailability */
13
+ /** @typedef {{harness: string, model: string, vendor: string, tier: number, costRank: number, config?: Record<string, unknown>}} DiscoveryRuntime */
14
+ /** @typedef {{id: string, runtime: RuntimeLike, order: number}} RuntimeCandidate */
15
+ /** @typedef {import("../host/config.mjs").UserConfig} UserConfig */
16
+ /** @typedef {{config?: UserConfig|null, onWarning?: (message: string) => void}} ComposeOptions */
17
+
18
+ /**
19
+ * Candidates used when a contract omits its runtime catalogue. The catalogue
20
+ * only names harnesses; availability still comes from the installed binary.
21
+ *
22
+ * Every id is `<harness>-<model>`, saying out loud what the fields already
23
+ * say: `harness` is the harness that runs the turn, `model` is what that
24
+ * harness asks, and the two vary independently — DeepSeek answers through the
25
+ * `dsh` harness, GLM through `zcode`. An id naming only one half (the bare
26
+ * `glm` this catalogue used to carry, which was at once a model family, a
27
+ * vendor, and an adapter name) hides which harness a recorded run used. The
28
+ * separator is a dash because `contract.mjs` admits no `:` in an id.
29
+ *
30
+ * Declaration order is the tie-break `composeAssignments` applies inside a
31
+ * tier, so the cheap harnesses lead: the first available tier-1 entry works
32
+ * and the strongest available entry of another vendor judges.
33
+ *
34
+ * @type {Readonly<Record<string, DiscoveryRuntime>>}
35
+ */
36
+ export const DISCOVERY_RUNTIME_DEFINITIONS = Object.freeze({
37
+ // `dsh` defaults no vendor and no provider route, so both are declared here
38
+ // or nothing can build a command from this entry.
39
+ "dsh-deepseek": {
40
+ harness: "dsh",
41
+ model: "deepseek-flash",
42
+ vendor: "deepseek",
43
+ config: { provider: "deepseek-official", "api_key.env_key": "DEEPSEEK_API_KEY" },
44
+ tier: 1,
45
+ costRank: 1,
46
+ },
47
+ "zcode-glm": {
48
+ harness: "zcode",
49
+ model: "glm-5.3",
50
+ vendor: "zhipu",
51
+ config: { "auth_token.env_key": "ZAI_API_KEY" },
52
+ tier: 1,
53
+ costRank: 1,
54
+ },
55
+ "agy-gemini": { harness: "agy", model: "gemini-3.8-flash-low", vendor: "google", tier: 1, costRank: 1 },
56
+ "codex-gpt": { harness: "codex", model: "gpt-5.6", vendor: "openai", tier: 2, costRank: 2 },
57
+ "claude-sonnet": { harness: "claude", model: "claude-sonnet-5", vendor: "anthropic", tier: 2, costRank: 2 },
58
+ });
59
+
60
+ /**
61
+ * Discover runtime binaries without sending a model prompt. Tests can pass
62
+ * recorded responses so no network call is needed.
63
+ *
64
+ * @param {Record<string, import("../harnesses/index.mjs").HarnessRuntime>} runtimes
65
+ * @param {{cwd?: string, responses?: Record<string, unknown>, exitCodes?: Record<string, number|null>, signals?: Record<string, string|null>}} [options]
66
+ * @returns {Promise<Record<string, RuntimeAvailability>>}
67
+ */
68
+ export async function discoverRuntimes(runtimes, options = {}) {
69
+ const entries = await Promise.all(Object.entries(runtimes).map(async ([id, runtime]) => {
70
+ const response = options.responses?.[id];
71
+ if (response !== undefined) {
72
+ return [id, normalizeProviderAvailability(runtime, response, options.exitCodes?.[id] ?? 0, options.signals?.[id] ?? null)];
73
+ }
74
+ const probe = await probeRuntime(runtime, { cwd: options.cwd });
75
+ return [id, probe.availability ?? (probe.ok
76
+ ? { available: true, exhaustedUntil: null, reason: "ready" }
77
+ : { available: false, exhaustedUntil: null, reason: probe.detail ?? "provider_unavailable" })];
78
+ }));
79
+ return Object.fromEntries(entries);
80
+ }
81
+
82
+ /**
83
+ * The available candidates in declaration order — the unit both
84
+ * `composeAssignments` and `setup`'s defaults select from.
85
+ *
86
+ * @param {Record<string, RuntimeLike>} runtimes
87
+ * @param {Record<string, RuntimeAvailability>} [availability]
88
+ * @returns {RuntimeCandidate[]}
89
+ */
90
+ export function availableCandidates(runtimes, availability = {}) {
91
+ return Object.entries(runtimes)
92
+ .filter(([id]) => isAvailable(availability[id]))
93
+ .map(([id, runtime], order) => ({ id, runtime, order }));
94
+ }
95
+
96
+ /**
97
+ * Compose only omitted assignments. Explicit node and default declarations are
98
+ * copied exactly; callers persist the returned pair in run state.
99
+ *
100
+ * An optional `options.config` narrows the candidate set to the harnesses the
101
+ * operator enabled and prefers its named worker and judge. An empty narrowed
102
+ * set falls back to the unrestricted candidates and is reported through
103
+ * `options.onWarning`; config never overrides an explicit node, gate or
104
+ * runtime-default declaration, and the cross-vendor judge rule still applies.
105
+ *
106
+ * @param {RuntimeContract} contract
107
+ * @param {Record<string, RuntimeAvailability>} availability
108
+ * @param {ComposeOptions} [options]
109
+ * @returns {Record<string, {worker: string, judge: string}>}
110
+ */
111
+ export function composeAssignments(contract, availability = {}, options = {}) {
112
+ const allCandidates = availableCandidates(contract.runtimes, availability);
113
+ const config = options.config ?? null;
114
+ let candidates = allCandidates;
115
+ if (config && Array.isArray(config.harnesses)) {
116
+ const enabled = new Set(config.harnesses);
117
+ const restricted = allCandidates.filter(({ runtime }) => typeof runtime.harness === "string" && enabled.has(runtime.harness));
118
+ if (restricted.length) candidates = restricted;
119
+ else if (allCandidates.length) options.onWarning?.(`config harnesses · none of ${config.harnesses.join(", ")} is available · using all available runtimes`);
120
+ }
121
+ /** @type {Record<string, {worker: string, judge: string}>} */
122
+ const assignments = {};
123
+ for (const node of contract.nodes ?? []) {
124
+ const workerOmitted = node.runtime === undefined && contract.runtimeDefaults?.worker === undefined;
125
+ const preferredWorker = workerOmitted ? candidateById(candidates, config?.worker) : undefined;
126
+ const worker = node.runtime ?? contract.runtimeDefaults?.worker ?? preferredWorker?.id ?? cheapest(candidates)?.id;
127
+ if (!worker || !contract.runtimes[worker]) throw new Error(`runtime_assignment_worker_unavailable: no available worker runtime for node ${node.id}`);
128
+ const workerRuntime = contract.runtimes[worker];
129
+ const judgeOmitted = node.gate.runtime === undefined && contract.runtimeDefaults?.judge === undefined;
130
+ const preferredJudge = judgeOmitted ? candidateById(candidates, config?.judge) : undefined;
131
+ const judge = node.gate.runtime ?? contract.runtimeDefaults?.judge
132
+ ?? (preferredJudge && preferredJudge.runtime.vendor !== workerRuntime.vendor ? preferredJudge.id : undefined)
133
+ ?? strongest(candidates, workerRuntime.vendor)?.id;
134
+ if (node.gate.enabled && (!judge || !contract.runtimes[judge])) {
135
+ throw new Error(`runtime_assignment_judge_unavailable: no available cross-vendor judge for node ${node.id} and worker ${worker}`);
136
+ }
137
+ const judgeRuntime = judge ? contract.runtimes[judge] : undefined;
138
+ if (node.gate.enabled && workerRuntime && judgeRuntime && judgeRuntime.vendor === workerRuntime.vendor) {
139
+ throw new Error(`runtime_assignment_judge_unavailable: no available cross-vendor judge for node ${node.id} and worker ${worker}`);
140
+ }
141
+ assignments[node.id] = { worker, judge: judge ?? worker };
142
+ }
143
+ return assignments;
144
+ }
145
+
146
+ /**
147
+ * @param {RuntimeCandidate[]} candidates
148
+ * @param {string|undefined} id
149
+ * @returns {RuntimeCandidate|undefined}
150
+ */
151
+ function candidateById(candidates, id) {
152
+ return id === undefined ? undefined : candidates.find((candidate) => candidate.id === id);
153
+ }
154
+
155
+ /**
156
+ * Select an unattempted, available runtime in the current tier. Judge
157
+ * candidates remain admissible only when their vendor differs from the worker
158
+ * runtime that actually ran the node.
159
+ *
160
+ * @param {RuntimeContract} contract
161
+ * @param {{assignments?: {worker?: string, judge?: string}, availability?: Record<string, RuntimeAvailability>}} stateRouting
162
+ * @param {"worker"|"judge"} role
163
+ * @param {string} current
164
+ * @param {Iterable<string>} attempted
165
+ * @returns {string|null}
166
+ */
167
+ export function nextSameTierRuntime(contract, stateRouting, role, current, attempted) {
168
+ const currentRuntime = contract.runtimes[current];
169
+ if (!currentRuntime) return null;
170
+ const workerId = stateRouting.assignments?.worker;
171
+ const workerVendor = workerId ? contract.runtimes[workerId]?.vendor : null;
172
+ const used = new Set(attempted);
173
+ return Object.entries(contract.runtimes)
174
+ .filter(([id, runtime]) => id !== current && !used.has(id) && sameTier(runtime, currentRuntime))
175
+ .filter(([id]) => isAvailable(stateRouting.availability?.[id]))
176
+ .filter(([, runtime]) => role !== "judge" || runtime.vendor !== workerVendor)
177
+ .sort((left, right) => runtimeOrder(left[1]) - runtimeOrder(right[1]))
178
+ .map(([id]) => id)
179
+ .at(0) ?? null;
180
+ }
181
+
182
+ /** @param {RuntimeLike} left @param {RuntimeLike} right @returns {boolean} */
183
+ function sameTier(left, right) {
184
+ return left.tier !== undefined || right.tier !== undefined
185
+ ? left.tier === right.tier
186
+ : (left.costRank ?? Number.MAX_SAFE_INTEGER) === (right.costRank ?? Number.MAX_SAFE_INTEGER);
187
+ }
188
+
189
+ /** @param {RuntimeLike} runtime @returns {number} */
190
+ function runtimeOrder(runtime) {
191
+ return runtime.costRank ?? Number.MAX_SAFE_INTEGER;
192
+ }
193
+
194
+ /**
195
+ * The cheapest available candidate: lowest tier, then lowest cost rank, then
196
+ * declaration order. Exported so `setup` suggests the same default rather than
197
+ * keeping a second ranking in step with this one.
198
+ *
199
+ * @param {RuntimeCandidate[]} candidates
200
+ * @returns {RuntimeCandidate|null}
201
+ */
202
+ export function cheapest(candidates) {
203
+ return [...candidates].sort((left, right) => tierOrder(left.runtime) - tierOrder(right.runtime)
204
+ || runtimeOrder(left.runtime) - runtimeOrder(right.runtime)
205
+ || left.order - right.order).at(0) ?? null;
206
+ }
207
+
208
+ /**
209
+ * The strongest available candidate of a vendor other than `vendor`: highest
210
+ * tier, then highest cost rank, then declaration order. Exported alongside
211
+ * `cheapest` for `setup`'s cross-vendor judge default.
212
+ *
213
+ * @param {RuntimeCandidate[]} candidates
214
+ * @param {string} vendor
215
+ * @returns {RuntimeCandidate|null}
216
+ */
217
+ export function strongest(candidates, vendor) {
218
+ return [...candidates].filter(({ runtime }) => runtime.vendor !== vendor)
219
+ .sort((left, right) => tierOrder(right.runtime) - tierOrder(left.runtime)
220
+ || runtimeOrder(right.runtime) - runtimeOrder(left.runtime)
221
+ || left.order - right.order).at(0) ?? null;
222
+ }
223
+
224
+ /** @param {RuntimeLike} runtime @returns {number} */
225
+ function tierOrder(runtime) {
226
+ return typeof runtime.tier === "number" ? runtime.tier : runtime.costRank ?? Number.MAX_SAFE_INTEGER;
227
+ }
228
+
229
+ /** @param {RuntimeAvailability|undefined} availability @returns {boolean} */
230
+ function isAvailable(availability) {
231
+ if (!availability) return false;
232
+ if (availability.available === true) return !availability.exhaustedUntil || Date.parse(availability.exhaustedUntil) <= Date.now();
233
+ return Boolean(availability.exhaustedUntil && Date.parse(availability.exhaustedUntil) <= Date.now());
234
+ }
235
+