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,185 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { realpathSync, statSync } from "node:fs";
3
+ import { isAbsolute, resolve } from "node:path";
4
+ import { fail, isContained, tailText } from "../util.mjs";
5
+
6
+ export const VERIFICATION_LIMITS = Object.freeze({
7
+ stdoutBytes: 16 * 1024,
8
+ stderrBytes: 16 * 1024,
9
+ maxCommands: 32,
10
+ maxRepeat: 8,
11
+ maxTimeoutSec: 600,
12
+ stateStdoutBytes: 2 * 1024,
13
+ stateCommands: 16,
14
+ stateAttempts: 4,
15
+ stateAttemptRecords: 16,
16
+ stateArgvBytes: 8 * 1024,
17
+ stateEnvBytes: 4 * 1024,
18
+ maxArgvBytes: 32 * 1024,
19
+ maxEnvBytes: 8 * 1024,
20
+ snapshotEntries: 4096,
21
+ snapshotPathBytes: 1024,
22
+ });
23
+
24
+ /** @typedef {"active"|"closed"|"failed"|"crashed"|"canceled"} VerificationAttemptStatus */
25
+
26
+ /**
27
+ * One declared deterministic check: an argv command run by the controller.
28
+ *
29
+ * @typedef {{argv: string[], cwd?: string, timeoutSec?: number, repeat?: number, env?: string[], mutation?: {threshold: number}}} VerificationCommand
30
+ */
31
+
32
+ /**
33
+ * A single attempt of a verification command.
34
+ *
35
+ * @typedef {{attempt: number, invocationId: string, commandIndex: number, pid: number|null, processStartToken: string|null, processGroupId: number|null, startedAt: string, deadlineAt: string, status: VerificationAttemptStatus, completedAt?: string|null, result?: VerificationAttemptResult|null}} VerificationAttempt
36
+ */
37
+
38
+ /**
39
+ * Bounded evidence captured for one attempt.
40
+ *
41
+ * @typedef {{passed: boolean, stdout: string, stderr: string, error: string|null, exitCode: number|null, signal: string|null, timedOut: boolean, durationMs: number|null}} VerificationAttemptResult
42
+ */
43
+
44
+ /**
45
+ * A command with its repeated attempts.
46
+ *
47
+ * @typedef {VerificationCommand & {passed: boolean, attempts: VerificationAttemptResult[]}} VerificationCommandResult
48
+ */
49
+
50
+ /**
51
+ * Aggregated verification result.
52
+ *
53
+ * @typedef {{passed: boolean, commands: VerificationCommandResult[]}} VerificationResult
54
+ */
55
+
56
+ /**
57
+ * Callbacks and options for {@link runVerification}.
58
+ *
59
+ * @typedef {{signal?: AbortSignal, logDir?: string, writeFiles?: string[], onAttemptStart?: (attempt: VerificationAttempt) => void, onAttemptSpawn?: (attempt: VerificationAttempt) => void, onAttemptComplete?: (attempt: VerificationAttempt) => void}} VerificationOptions
60
+ */
61
+
62
+ /**
63
+ * @param {unknown} cwd
64
+ * @param {string} label
65
+ */
66
+ function validateRelativeCwd(cwd, label) {
67
+ if (typeof cwd !== "string" || isAbsolute(cwd) || /^[A-Za-z]:[\\/]/u.test(cwd) || /(?:^|[\\/])\.\.(?:[\\/]|$)/u.test(cwd)) {
68
+ throw fail("verification_cwd_invalid", `${label}.cwd must be a relative path without ..`);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * @param {string} baseCwd
74
+ * @param {string} commandCwd
75
+ * @returns {string}
76
+ */
77
+ export function resolveVerificationCwd(baseCwd, commandCwd = ".") {
78
+ validateRelativeCwd(commandCwd, "verification command");
79
+ const baseReal = realpathSync(baseCwd);
80
+ const candidate = resolve(baseReal, commandCwd);
81
+ const targetReal = realpathSync(candidate);
82
+ if (!isContained(baseReal, targetReal)) {
83
+ throw fail("verification_cwd_escape", `verification cwd escapes workspace: ${commandCwd}`);
84
+ }
85
+ if (!statSync(targetReal).isDirectory()) throw fail("verification_cwd_invalid", `verification cwd is not a directory: ${commandCwd}`);
86
+ return targetReal;
87
+ }
88
+
89
+ /**
90
+ * @param {unknown} commands
91
+ * @param {string} label
92
+ * @returns {VerificationCommand[]}
93
+ */
94
+ export function validateVerificationCommands(commands, label = "verification") {
95
+ if (!Array.isArray(commands) || commands.length > VERIFICATION_LIMITS.maxCommands) {
96
+ throw new TypeError(`${label} must be an array of at most ${VERIFICATION_LIMITS.maxCommands} command objects`);
97
+ }
98
+ return commands.map((command, index) => validateVerificationCommand(command, `${label}[${index}]`));
99
+ }
100
+
101
+ /**
102
+ * @param {unknown} command
103
+ * @param {string} label
104
+ * @returns {VerificationCommand}
105
+ */
106
+ function validateVerificationCommand(command, label = "verification command") {
107
+ if (!command || typeof command !== "object" || Array.isArray(command)) throw new TypeError(`${label} must be an argv command object`);
108
+ const record = /** @type {Record<string, unknown>} */ (command);
109
+ const allowed = new Set(["argv", "cwd", "timeoutSec", "repeat", "env", "mutation"]);
110
+ for (const key of Object.keys(record)) if (!allowed.has(key)) throw new TypeError(`${label} has unexpected field ${key}`);
111
+ if (!Array.isArray(record.argv) || record.argv.length === 0 || record.argv.length > 64 || record.argv.some((item) => typeof item !== "string" || !item.trim() || Buffer.byteLength(item, "utf8") > 8 * 1024)) {
112
+ throw new TypeError(`${label}.argv must be a non-empty array of strings`);
113
+ }
114
+ const argvBytes = record.argv.reduce((sum, item) => sum + Buffer.byteLength(/** @type {string} */ (item), "utf8"), 0);
115
+ if (argvBytes > VERIFICATION_LIMITS.maxArgvBytes) throw new TypeError(`${label}.argv exceeds aggregate byte limit`);
116
+ if (record.cwd !== undefined) validateRelativeCwd(record.cwd, label);
117
+ const timeoutSec = record.timeoutSec === undefined ? 120 : record.timeoutSec;
118
+ if (typeof timeoutSec !== "number" || !Number.isFinite(timeoutSec) || timeoutSec <= 0 || timeoutSec > VERIFICATION_LIMITS.maxTimeoutSec) throw new TypeError(`${label}.timeoutSec must be between 0 and ${VERIFICATION_LIMITS.maxTimeoutSec}`);
119
+ // Default single attempt: the worker already ran these commands inside its
120
+ // session and the controller run is the independent confirmation; repeating
121
+ // by default doubled suite cost for no extra signal.
122
+ const repeat = record.repeat === undefined ? 1 : record.repeat;
123
+ if (typeof repeat !== "number" || !Number.isInteger(repeat) || repeat <= 0 || repeat > VERIFICATION_LIMITS.maxRepeat) throw new TypeError(`${label}.repeat must be between 1 and ${VERIFICATION_LIMITS.maxRepeat}`);
124
+ const env = record.env ?? [];
125
+ if (!Array.isArray(env) || env.some((name) => typeof name !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name))) throw new TypeError(`${label}.env must be an array of environment-variable names`);
126
+ const envBytes = env.reduce((sum, name) => sum + Buffer.byteLength(/** @type {string} */ (name), "utf8"), 0);
127
+ if (envBytes > VERIFICATION_LIMITS.maxEnvBytes) throw new TypeError(`${label}.env exceeds aggregate byte limit`);
128
+ // Mutation testing is opt-in per entry: it re-runs the same argv against
129
+ // deliberately broken copies of the node's written files. `threshold` is the
130
+ // fraction of mutants the suite must kill, so 0 accepts any suite and 1
131
+ // demands every sampled mutant fail it.
132
+ /** @type {{threshold: number}|undefined} */
133
+ let mutation;
134
+ if (record.mutation !== undefined) {
135
+ const rawMutation = record.mutation;
136
+ if (!rawMutation || typeof rawMutation !== "object" || Array.isArray(rawMutation)) throw new TypeError(`${label}.mutation must be an object with a threshold between 0 and 1`);
137
+ const mutationRecord = /** @type {Record<string, unknown>} */ (rawMutation);
138
+ for (const key of Object.keys(mutationRecord)) if (key !== "threshold") throw new TypeError(`${label}.mutation has unexpected field ${key}`);
139
+ if (typeof mutationRecord.threshold !== "number" || !Number.isFinite(mutationRecord.threshold) || mutationRecord.threshold < 0 || mutationRecord.threshold > 1) {
140
+ throw new TypeError(`${label}.mutation.threshold must be a number between 0 and 1`);
141
+ }
142
+ mutation = { threshold: mutationRecord.threshold };
143
+ }
144
+ /** @type {VerificationCommand} */
145
+ const normalized = { argv: [.../** @type {string[]} */ (record.argv)], timeoutSec, repeat, env: [.../** @type {string[]} */ (env)] };
146
+ if (record.cwd !== undefined) normalized.cwd = /** @type {string} */ (record.cwd);
147
+ if (mutation !== undefined) normalized.mutation = mutation;
148
+ return normalized;
149
+ }
150
+
151
+ /**
152
+ * Bound a verification result for persisted node state.
153
+ *
154
+ * @param {VerificationResult|undefined} result
155
+ * @returns {VerificationResult}
156
+ */
157
+ export function compactVerification(result) {
158
+ /** @type {VerificationCommandResult[]} */
159
+ const commands = [];
160
+ let argvBytes = 0;
161
+ let envBytes = 0;
162
+ for (const command of result?.commands ?? []) {
163
+ if (commands.length >= VERIFICATION_LIMITS.stateCommands) break;
164
+ const nextArgvBytes = argvBytes + command.argv.reduce((sum, item) => sum + Buffer.byteLength(String(item), "utf8"), 0);
165
+ const nextEnvBytes = envBytes + (command.env ?? []).reduce((sum, item) => sum + Buffer.byteLength(String(item), "utf8"), 0);
166
+ if (nextArgvBytes > VERIFICATION_LIMITS.stateArgvBytes || nextEnvBytes > VERIFICATION_LIMITS.stateEnvBytes) break;
167
+ argvBytes = nextArgvBytes;
168
+ envBytes = nextEnvBytes;
169
+ commands.push({
170
+ argv: command.argv,
171
+ cwd: command.cwd,
172
+ timeoutSec: command.timeoutSec,
173
+ repeat: command.repeat,
174
+ env: command.env,
175
+ passed: Boolean(command.passed),
176
+ attempts: (command.attempts ?? []).slice(0, VERIFICATION_LIMITS.stateAttempts).map((attempt) => ({
177
+ ...attempt,
178
+ stdout: tailText(attempt.stdout, VERIFICATION_LIMITS.stateStdoutBytes),
179
+ stderr: tailText(attempt.stderr, VERIFICATION_LIMITS.stateStdoutBytes),
180
+ })),
181
+ });
182
+ }
183
+ return { passed: Boolean(result?.passed), commands };
184
+ }
185
+
@@ -0,0 +1,138 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { validateTaskPacket } from "./task-packet.mjs";
3
+ import { requireText } from "./assert.mjs";
4
+
5
+ const RESULT_LIMITS = Object.freeze({
6
+ bytes: 32 * 1024,
7
+ summaryBytes: 4 * 1024,
8
+ arrayItems: 32,
9
+ itemBytes: 2 * 1024,
10
+ artifactBytes: 16 * 1024,
11
+ missingContextItems: 16,
12
+ });
13
+
14
+ /**
15
+ * Worker-result fields the controller measures and derives itself, so the
16
+ * worker protocol has no owner for them. A result that declares one is
17
+ * rejected, never dropped: a field accepted after it lost its owner is how it
18
+ * comes back in the next prompt generation. The PRD's remaining derived fields
19
+ * -- commit, digest, worktreeIdentity, durationMs, exitCode -- enter this one
20
+ * list as their nodes land, so rejection stays a single edit.
21
+ */
22
+ export const DERIVED_WORKER_RESULT_FIELDS = Object.freeze(["changedFiles"]);
23
+
24
+ /** @typedef {"done"|"blocked_context"} WorkerResultStatus */
25
+
26
+ /**
27
+ * The worker-result protocol: exactly one JSON object returned as the only
28
+ * content of the final worker message. `blocked_context` requires at least one
29
+ * missingContext entry; `done` requires none.
30
+ *
31
+ * @typedef {{status: WorkerResultStatus, summary: string, verification: string[], artifacts: string[], missingContext: string[]}} WorkerResult
32
+ */
33
+
34
+ /**
35
+ * @param {string} value
36
+ * @returns {WorkerResult}
37
+ */
38
+ export function parseWorkerResult(value) {
39
+ if (typeof value !== "string") throw new TypeError("worker result must be JSON text");
40
+ if (Buffer.byteLength(value, "utf8") > RESULT_LIMITS.bytes) {
41
+ throw new TypeError(`worker result exceeds ${RESULT_LIMITS.bytes} bytes`);
42
+ }
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(value);
46
+ } catch (error) {
47
+ throw new TypeError(`worker result is invalid JSON: ${(error instanceof Error ? error.message : String(error))}`);
48
+ }
49
+ return validateWorkerResult(parsed);
50
+ }
51
+
52
+ /**
53
+ * @param {unknown} value
54
+ * @returns {WorkerResult}
55
+ */
56
+ export function validateWorkerResult(value) {
57
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
58
+ throw new TypeError("worker result must be an object");
59
+ }
60
+ // Worker output is an external LLM boundary: models add fields beyond the
61
+ // protocol. Unknown provider extras are dropped (the normalized pick below
62
+ // keeps only canonical fields), but a field the controller derives is a
63
+ // protocol failure: silently dropping it is how the worker learns to keep
64
+ // sending it. Missing or invalid canonical fields stay fatal too.
65
+ const record = /** @type {Record<string, unknown>} */ (value);
66
+ for (const field of DERIVED_WORKER_RESULT_FIELDS) {
67
+ if (Object.hasOwn(record, field)) {
68
+ throw new TypeError(`worker result.${field} is derived by the controller and must not be declared`);
69
+ }
70
+ }
71
+ const expected = new Set(["status", "summary", "verification", "artifacts", "missingContext"]);
72
+ for (const key of expected) {
73
+ if (!Object.hasOwn(record, key)) throw new TypeError(`worker result.${key} is required`);
74
+ }
75
+ if (record.status !== "done" && record.status !== "blocked_context") {
76
+ throw new TypeError("worker result.status must be done or blocked_context");
77
+ }
78
+ requireText(record.summary, "worker result.summary", RESULT_LIMITS.summaryBytes);
79
+ requireList(record.verification, "worker result.verification", RESULT_LIMITS.arrayItems, RESULT_LIMITS.itemBytes);
80
+ requireList(record.artifacts, "worker result.artifacts", RESULT_LIMITS.arrayItems, RESULT_LIMITS.artifactBytes);
81
+ requireList(record.missingContext, "worker result.missingContext", RESULT_LIMITS.missingContextItems, RESULT_LIMITS.itemBytes);
82
+ const missingContext = /** @type {string[]} */ (record.missingContext);
83
+ if (record.status === "blocked_context" && missingContext.length === 0) {
84
+ throw new TypeError("worker result.missingContext must not be empty for blocked_context");
85
+ }
86
+ if (record.status === "done" && missingContext.length > 0) {
87
+ throw new TypeError("worker result.missingContext must be empty for done");
88
+ }
89
+ const normalized = {
90
+ status: /** @type {WorkerResultStatus} */ (record.status),
91
+ summary: /** @type {string} */ (record.summary),
92
+ verification: [.../** @type {string[]} */ (record.verification)],
93
+ artifacts: [.../** @type {string[]} */ (record.artifacts)],
94
+ missingContext: [...missingContext],
95
+ };
96
+ if (Buffer.byteLength(JSON.stringify(normalized), "utf8") > RESULT_LIMITS.bytes) {
97
+ throw new TypeError(`worker result exceeds ${RESULT_LIMITS.bytes} bytes`);
98
+ }
99
+ return normalized;
100
+ }
101
+
102
+ /**
103
+ * @param {string|WorkerResult} value
104
+ * @param {string} cwd
105
+ * @returns {WorkerResult & {discoveryPacket: import("./task-packet.mjs").TaskPacket}}
106
+ */
107
+ export function parseDiscoveryResult(value, cwd) {
108
+ const result = typeof value === "string" ? parseWorkerResult(value) : validateWorkerResult(value);
109
+ if (result.status !== "done") throw new TypeError("discovery result must have status done");
110
+ if (result.artifacts.length !== 1) throw new TypeError("discovery result must contain exactly one task packet artifact");
111
+ let packet;
112
+ try {
113
+ packet = JSON.parse(result.artifacts[0]);
114
+ } catch (error) {
115
+ throw new TypeError(`discovery task packet artifact is invalid JSON: ${(error instanceof Error ? error.message : String(error))}`);
116
+ }
117
+ validateTaskPacket(packet, 0, cwd);
118
+ if (packet.mode !== "execution") throw new TypeError("discovery task packet artifact must be execution mode");
119
+ const normalized = { ...result };
120
+ Object.defineProperty(normalized, "discoveryPacket", { value: packet, enumerable: false });
121
+ return /** @type {WorkerResult & {discoveryPacket: import("./task-packet.mjs").TaskPacket}} */ (
122
+ /** @type {unknown} */ (normalized)
123
+ );
124
+ }
125
+
126
+ /**
127
+ * @param {unknown} value
128
+ * @param {string} label
129
+ * @param {number} maxItems
130
+ * @param {number} itemBytes
131
+ */
132
+ function requireList(value, label, maxItems, itemBytes) {
133
+ if (!Array.isArray(value) || value.length > maxItems) throw new TypeError(`${label} must be an array with at most ${maxItems} items`);
134
+ for (const [index, item] of value.entries()) {
135
+ if (typeof item !== "string") throw new TypeError(`${label}[${index}] must be a string`);
136
+ if (Buffer.byteLength(item, "utf8") > itemBytes) throw new TypeError(`${label}[${index}] exceeds ${itemBytes} bytes`);
137
+ }
138
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Who runs a node, and what a failed dependency costs its dependants.
3
+ *
4
+ * `runtimeAssignments` composes the worker/judge pair only for the roles a
5
+ * contract left open, from the runtimes available right now -- which is why it
6
+ * is called on a fresh run *and* on a resume, and why it lives in neither.
7
+ * `blockDependents` walks the DAG forward from a terminal failure so a node
8
+ * whose dependency died never dispatches at all.
9
+ */
10
+ import { PARKED } from "./prompts.mjs";
11
+ import { composeAssignments, discoverRuntimes } from "./runtime-discovery.mjs";
12
+ import { readUserConfig } from "../host/config.mjs";
13
+ import { transition } from "./state.mjs";
14
+
15
+ /** @typedef {import("../cli.mjs").LockHandle} LockHandle */
16
+ /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
17
+ /** @typedef {import("./runtime-discovery.mjs").RuntimeAvailability} RuntimeAvailability */
18
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
19
+
20
+ /**
21
+ * Resolve role assignments once at run creation. Discovery is used only for
22
+ * omitted roles; the resulting pair is persisted so resume is deterministic.
23
+ *
24
+ * @param {ValidatedContract} contract
25
+ * @returns {Promise<{assignments: Record<string, {worker: string, judge: string, composedWorker: boolean, composedJudge: boolean}>, availability: Record<string, import("./runtime-discovery.mjs").RuntimeAvailability>}>}
26
+ */
27
+ export async function runtimeAssignments(contract) {
28
+ const needsComposition = contract.nodes.some((node) =>
29
+ (node.runtime === undefined && contract.runtimeDefaults?.worker === undefined)
30
+ || (node.gate.enabled && node.gate.runtime === undefined && contract.runtimeDefaults?.judge === undefined));
31
+ const availability = needsComposition ? await discoverRuntimes(contract.runtimes, { cwd: contract.cwd }) : {};
32
+ const config = readUserConfig(process.env);
33
+ const assignments = composeAssignments(contract, availability, { config });
34
+ return {
35
+ assignments: Object.fromEntries(Object.entries(assignments).map(([nodeId, assignment]) => {
36
+ const node = contract.nodes.find((candidate) => candidate.id === nodeId);
37
+ return [nodeId, {
38
+ ...assignment,
39
+ composedWorker: node?.runtime === undefined && contract.runtimeDefaults?.worker === undefined,
40
+ composedJudge: Boolean(node?.gate.enabled && node.gate.runtime === undefined && contract.runtimeDefaults?.judge === undefined),
41
+ }];
42
+ })),
43
+ availability,
44
+ };
45
+ }
46
+ /**
47
+ * @param {ValidatedContract} contract
48
+ * @param {string} runDir
49
+ * @param {Map<string, NodeSnapshot>} states
50
+ * @param {LockHandle} lock
51
+ */
52
+ export function blockDependents(contract, runDir, states, lock) {
53
+ for (const node of contract.nodes) {
54
+ const state = states.get(node.id);
55
+ if (!state) continue;
56
+ if (state.status !== "pending") continue;
57
+ // Only a parent that has parked blocks its dependants. A parent whose
58
+ // automatic retry is still unspent is re-opened to `pending` before this
59
+ // runs, so the dependants stay `pending`/`phase: "waiting"` until it parks.
60
+ const blockedBy = node.dependsOn.filter((id) => PARKED.has(states.get(id)?.status ?? ""));
61
+ if (blockedBy.length) transition(runDir, state, "blocked", { phase: "dependency", blockedBy, error: { code: "dependency_failed", message: `blocked by ${blockedBy.join(", ")}` } }, lock);
62
+ }
63
+ }