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,334 @@
1
+ /**
2
+ * Retry in place (TECH-SPEC lean v0.3, rule 4 and decision 9.3).
3
+ *
4
+ * A retry is the same run and the same node: attempt plus one, in the same run
5
+ * directory, with the failure attached. Instructions are frozen per run, so the
6
+ * packet never changes; what changes is the prompt, which carries a bounded
7
+ * `Previous attempt` section for the worker and for the judge.
8
+ *
9
+ * Resume adopts completed work first — an orphaned running node as before, and
10
+ * a blocking review that never produced a verdict (`judge_unavailable`) is
11
+ * re-judged from the preserved worker result instead of being reset or
12
+ * re-dispatched to a worker — and only then re-dispatches ordinary failures.
13
+ * Two stop boundaries are crossed by an explicit flag alone: a node blocked
14
+ * with `unknown_effect_reconciled` needs `--reconcile <node>`, and an
15
+ * exhausted run budget needs `--max-input-tokens <n>`.
16
+ */
17
+ import { Buffer } from "node:buffer";
18
+
19
+ /** Heading of the section appended to a retried attempt's worker and judge prompt. */
20
+ const PREVIOUS_ATTEMPT_HEADING = "Previous attempt";
21
+
22
+ /** Hard ceiling for the whole `Previous attempt` section, in bytes. */
23
+ const PREVIOUS_ATTEMPT_MAX_BYTES = 8 * 1024;
24
+
25
+ /** Heading under which an operator's answer is rendered inside the section. */
26
+ const OPERATOR_ANSWER_HEADING = "Operator answer";
27
+
28
+ /**
29
+ * Node statuses an ordinary resume re-dispatches as attempt plus one.
30
+ * `blocked` is deliberately absent: only its two error codes have a retry
31
+ * meaning, and `context_missing` stays terminal.
32
+ */
33
+ const RETRYABLE_STATUSES = new Set(["failed", "stalled", "exhausted", "canceled"]);
34
+
35
+ /**
36
+ * @param {{status?: string, error?: {code?: string}|null}} state
37
+ * @returns {boolean}
38
+ */
39
+ function isJudgeUnavailable(state) {
40
+ return state.status === "blocked" && state.error?.code === "judge_unavailable";
41
+ }
42
+
43
+ /**
44
+ * @param {{status?: string, error?: {code?: string}|null}} state
45
+ * @returns {boolean}
46
+ */
47
+ function isDependencyFailed(state) {
48
+ return state.status === "blocked" && state.error?.code === "dependency_failed";
49
+ }
50
+
51
+ /**
52
+ * @param {{status?: string, error?: {code?: string}|null}} state
53
+ * @returns {boolean}
54
+ */
55
+ export function isUnknownEffectStop(state) {
56
+ return state.status === "blocked" && state.error?.code === "unknown_effect_reconciled";
57
+ }
58
+
59
+ /**
60
+ * @param {{status?: string, error?: {code?: string}|null}} state
61
+ * @returns {boolean}
62
+ */
63
+ function isRetryableFailure(state) {
64
+ return RETRYABLE_STATUSES.has(/** @type {string} */ (state.status)) || isDependencyFailed(state);
65
+ }
66
+
67
+ /**
68
+ * The node a `--node` flag names plus every node that transitively depends on
69
+ * it: a retried dependency re-opens its dependants, so they are part of the
70
+ * same retry.
71
+ *
72
+ * @param {{id: string, dependsOn?: string[]}[]} planNodes
73
+ * @param {string} nodeId
74
+ * @returns {Set<string>}
75
+ */
76
+ function retryTargets(planNodes, nodeId) {
77
+ const targets = new Set([nodeId]);
78
+ let grew = true;
79
+ while (grew) {
80
+ grew = false;
81
+ for (const node of planNodes) {
82
+ if (targets.has(node.id)) continue;
83
+ if ((node.dependsOn ?? []).some((dependency) => targets.has(dependency))) {
84
+ targets.add(node.id);
85
+ grew = true;
86
+ }
87
+ }
88
+ }
89
+ return targets;
90
+ }
91
+
92
+ /**
93
+ * The earliest reset instant recorded among a node's tier-exhaustion
94
+ * candidates, or null when no candidate carries a parseable `exhaustedUntil`.
95
+ * Shared by `planResumeRetry`'s hold decision and `supervise`'s waiting state
96
+ * so the two can never disagree about when an exhausted node is due.
97
+ *
98
+ * @param {import("../contract/index.mjs").NodeSnapshot} state
99
+ * @returns {number|null} epoch milliseconds, or null when nothing is parseable
100
+ */
101
+ export function earliestTierReset(state) {
102
+ const candidates = state.routing?.tierExhaustion?.candidates ?? [];
103
+ const instants = candidates.map((candidate) => Date.parse(candidate.exhaustedUntil ?? "")).filter(Number.isFinite);
104
+ return instants.length ? Math.min(...instants) : null;
105
+ }
106
+
107
+ /**
108
+ * The longest a tier-exhausted node may be held waiting for its tier to
109
+ * recover before the controller stops waiting. A generation with no recorded
110
+ * reset instant would otherwise hold forever, and an announced instant can be
111
+ * arbitrarily far away; both are bounded here. After the cap the named
112
+ * post-cap transition is a re-dispatch on the current runtime (`retry`, or
113
+ * `rejudge` for a judge phase), with attention raised so the operator sees that
114
+ * the wait was abandoned rather than honoured.
115
+ */
116
+ export const TIER_EXHAUSTION_HOLD_CAP_MS = 24 * 60 * 60 * 1000;
117
+
118
+ /**
119
+ * The reason recorded when `TIER_EXHAUSTION_HOLD_CAP_MS` is exceeded. It names
120
+ * the post-cap transition explicitly so the operator-facing text and the
121
+ * classification cannot drift.
122
+ */
123
+ export const TIER_EXHAUSTION_CAP_REASON = "tier-exhaustion hold cap exceeded; post-cap transition re-dispatches on the current runtime";
124
+
125
+ /**
126
+ * How long a node has already been held on its tier exhaustion. The block
127
+ * transition wrote `updatedAt`; an unparseable or absent stamp is treated as
128
+ * "not yet held" so a snapshot that predates the timestamp cannot be pushed
129
+ * over the cap on first read.
130
+ *
131
+ * @param {import("../contract/index.mjs").NodeSnapshot} state
132
+ * @returns {number} elapsed milliseconds, never negative
133
+ */
134
+ export function tierExhaustionHeldMs(state) {
135
+ const since = Date.parse(state.updatedAt ?? state.startedAt ?? "");
136
+ return Number.isFinite(since) ? Math.max(0, Date.now() - since) : 0;
137
+ }
138
+
139
+ /**
140
+ * Decide, per node, what a resume does with it. Everything classified
141
+ * `recover` keeps today's recovery behaviour (orphan adoption, re-judge of
142
+ * finished work, pending re-dispatch). `hold` leaves the persisted state
143
+ * exactly as it is and reports attention: resetting it would either replay an
144
+ * unknown effect or re-dispatch work the operator did not ask for.
145
+ *
146
+ * @param {{nodes: {id: string, dependsOn?: string[]}[]}} contract
147
+ * @param {Map<string, import("../contract/index.mjs").NodeSnapshot>} states
148
+ * @param {{node?: string, reconcile?: string, answer?: string}} [options]
149
+ * @returns {{actions: Map<string, "recover"|"retry"|"rejudge"|"hold">, attention: {id: string, reason: string}[], targets: Set<string>|null}}
150
+ */
151
+ export function planResumeRetry(contract, states, options = {}) {
152
+ // `--answer <node>` narrows the retry exactly like `--node <node>`: the named
153
+ // node plus every node that transitively depends on it.
154
+ const targetNode = options.node ?? options.answer;
155
+ const targets = targetNode ? retryTargets(contract.nodes, targetNode) : null;
156
+ /** @type {Map<string, "recover"|"retry"|"rejudge"|"hold">} */
157
+ const actions = new Map();
158
+ /** @type {{id: string, reason: string}[]} */
159
+ const attention = [];
160
+ /** @type {(node: string, state: import("../contract/index.mjs").NodeSnapshot) => "recover"|"retry"|"rejudge"|"hold"} */
161
+ const classify = (node, state) => {
162
+ if (isJudgeUnavailable(state)) {
163
+ // Adoption before retry: the worker result and the verification records
164
+ // are on disk, so only the arbitration is missing.
165
+ if (targets && !targets.has(node)) return hold(node, "its unresolved review is outside this retry");
166
+ return "rejudge";
167
+ }
168
+ if (isUnknownEffectStop(state)) {
169
+ // Stop boundary: only an explicit acknowledgement re-dispatches it.
170
+ return options.reconcile === node
171
+ ? "retry"
172
+ : hold(node, "an unknown workspace effect needs an explicit `resume --reconcile`");
173
+ }
174
+ if (state.status === "blocked" && state.error?.code === "context_missing") {
175
+ // A node the operator answered is dispatchable again; every other
176
+ // `context_missing` node keeps its terminal boundary.
177
+ return options.answer === node
178
+ ? "retry"
179
+ : "recover";
180
+ }
181
+ if (state.status === "blocked" && state.error?.code === "runtime_tier_exhausted") {
182
+ // A node outside an explicit `--node` retry is held before anything else,
183
+ // even past the cap: an operator asking for one node must not have the
184
+ // cap re-dispatch a different one.
185
+ if (targets && !targets.has(node)) return hold(node, "it is outside the `--node` retry");
186
+ // The cap bounds the wait: once exceeded, the named post-cap transition
187
+ // re-dispatches on the current runtime instead of holding forever.
188
+ if (tierExhaustionHeldMs(state) >= TIER_EXHAUSTION_HOLD_CAP_MS) {
189
+ attention.push({ id: node, reason: TIER_EXHAUSTION_CAP_REASON });
190
+ return state.phase === "judge" ? "rejudge" : "retry";
191
+ }
192
+ // Hold until the earliest reset the exhausted candidates announced; a
193
+ // generation with no parseable instant holds until the cap rather than
194
+ // inventing a deadline. The final candidate is already in the evidence
195
+ // list, so this reads Phase 1a's record, never `state.error.exhaustedUntil`.
196
+ const earliest = earliestTierReset(state);
197
+ if (earliest === null) return hold(node, "no recorded reset time for any exhausted candidate");
198
+ if (Date.now() < earliest) return hold(node, `earliest recorded reset is ${new Date(earliest).toISOString()}`);
199
+ // A judge-tier exhaustion keeps its order: rejudge transitions to the
200
+ // judge phase and preserves the accepted worker result, where a retry
201
+ // would discard it by re-dispatching a worker.
202
+ return state.phase === "judge" ? "rejudge" : "retry";
203
+ }
204
+ if (!isRetryableFailure(state)) return "recover";
205
+ if (targets && !targets.has(node)) return hold(node, "it is outside the `--node` retry");
206
+ return "retry";
207
+ };
208
+ /** @type {(id: string, reason: string) => "hold"} */
209
+ const hold = (id, reason) => {
210
+ attention.push({ id, reason });
211
+ return "hold";
212
+ };
213
+ for (const node of contract.nodes) {
214
+ const state = states.get(node.id);
215
+ if (!state) continue;
216
+ actions.set(node.id, classify(node.id, state));
217
+ }
218
+ // A node blocked on a failed dependency is retried only when the dependency
219
+ // it waited on is retried too; otherwise it keeps its boundary.
220
+ for (const node of contract.nodes) {
221
+ const state = states.get(node.id);
222
+ if (!state || !isDependencyFailed(state) || actions.get(node.id) !== "retry") continue;
223
+ const waitedOnRetried = (state.blockedBy ?? []).every((id) => actions.get(id) === "retry" || actions.get(id) === "rejudge" || states.get(id)?.status === "done");
224
+ if (!waitedOnRetried) {
225
+ actions.set(node.id, hold(node.id, "the dependency it waited on is not part of this retry"));
226
+ }
227
+ }
228
+ return { actions, attention, targets };
229
+ }
230
+
231
+ /**
232
+ * The most recent operator-answer override's text, or null. A second
233
+ * `--answer` appends rather than replaces, so the newest record is what
234
+ * surfaces; the older answers stay in `executionOverrides` untouched.
235
+ *
236
+ * @param {import("../contract/index.mjs").NodeSnapshot} state
237
+ * @returns {string|null}
238
+ */
239
+ function mostRecentOperatorAnswer(state) {
240
+ const overrides = state.executionOverrides ?? [];
241
+ for (let index = overrides.length - 1; index >= 0; index -= 1) {
242
+ const record = /** @type {Record<string, unknown>} */ (overrides[index]);
243
+ if (record.kind === "operator-answer") {
244
+ return typeof record.text === "string" ? record.text : null;
245
+ }
246
+ }
247
+ return null;
248
+ }
249
+
250
+ /**
251
+ * The bounded `Previous attempt` section for a node about to be re-dispatched:
252
+ * the error code and message, the most recent operator answer (when one was
253
+ * supplied), the judge findings, the scope findings, and the failing
254
+ * verification commands with a bounded output tail.
255
+ *
256
+ * @param {import("../contract/index.mjs").NodeSnapshot} state
257
+ * @returns {string|null} null when the node carries no failure evidence
258
+ */
259
+ export function renderPreviousAttemptSection(state) {
260
+ /** @type {string[]} */
261
+ const parts = [];
262
+ const attempt = state.attempt ?? 0;
263
+ // A node that never started an attempt has no failure to carry: its blocked
264
+ // dependency is already visible in the graph.
265
+ if (attempt === 0) return null;
266
+ parts.push(`Attempt ${attempt} failed; this is attempt ${attempt + 1}. The packet is unchanged.`);
267
+ if (state.error?.code || state.error?.message) {
268
+ parts.push(`Error: ${state.error?.code ?? "unknown"}${state.error?.message ? ` — ${bounded(state.error.message, 1024)}` : ""}`);
269
+ }
270
+ const answer = mostRecentOperatorAnswer(state);
271
+ if (answer !== null) {
272
+ parts.push(`${OPERATOR_ANSWER_HEADING}:`);
273
+ parts.push(bounded(answer, 1024));
274
+ }
275
+ const findings = state.gate?.findings ?? [];
276
+ if (findings.length) {
277
+ parts.push(`Judge findings (verdict ${state.gate?.verdict ?? "unknown"}, maxSeverity ${state.gate?.maxSeverity ?? "none"}):`);
278
+ for (const finding of findings) {
279
+ parts.push(`- [${finding.severity}] ${bounded(finding.description, 1024)}`);
280
+ if (finding.evidence) parts.push(` Evidence: ${bounded(finding.evidence, 1024)}`);
281
+ }
282
+ }
283
+ const unexpected = state.scopeFindings?.unexpectedPaths ?? [];
284
+ if (unexpected.length) parts.push(`Scope findings (paths outside the declared boundary): ${unexpected.map((path) => bounded(path, 256)).join(", ")}`);
285
+ const failing = (state.verification?.commands ?? []).filter((command) => !command.passed);
286
+ if (failing.length) {
287
+ parts.push("Failing verification:");
288
+ for (const command of failing) {
289
+ parts.push(`- ${command.argv.join(" ")}`);
290
+ const last = command.attempts?.at(-1);
291
+ if (last?.stdout) parts.push(` stdout tail: ${indent(last.stdout)}`);
292
+ if (last?.stderr) parts.push(` stderr tail: ${indent(last.stderr)}`);
293
+ if (typeof last?.exitCode === "number" && last.exitCode !== 0) parts.push(` exit code: ${last.exitCode}`);
294
+ }
295
+ }
296
+ if (parts.length === 1) return null;
297
+ return boundSection([`## ${PREVIOUS_ATTEMPT_HEADING}`, "", ...parts].join("\n"));
298
+ }
299
+
300
+ /**
301
+ * Append the section to a regenerated prompt. A prompt that already carries it
302
+ * (a gate revision re-rendered after a resume) is left alone.
303
+ *
304
+ * @param {string} prompt
305
+ * @param {string|undefined} section
306
+ * @returns {string}
307
+ */
308
+ export function appendPreviousAttempt(prompt, section) {
309
+ if (!section || prompt.includes(`## ${PREVIOUS_ATTEMPT_HEADING}`)) return prompt;
310
+ return `${prompt}\n\n${section}`;
311
+ }
312
+
313
+ /**
314
+ * @param {string} text
315
+ * @returns {string} the text bounded to the section ceiling, with a marker
316
+ */
317
+ function boundSection(text) {
318
+ if (Buffer.byteLength(text, "utf8") <= PREVIOUS_ATTEMPT_MAX_BYTES) return text;
319
+ const marker = "\n… (previous attempt section truncated)";
320
+ const room = PREVIOUS_ATTEMPT_MAX_BYTES - Buffer.byteLength(marker, "utf8") - 1;
321
+ const cut = Buffer.from(text, "utf8").subarray(0, room).toString("utf8");
322
+ return `${cut}${marker}`;
323
+ }
324
+
325
+ /** @param {string} text @param {number} maxBytes @returns {string} */
326
+ function bounded(text, maxBytes) {
327
+ const bytes = Buffer.from(String(text ?? ""), "utf8");
328
+ return bytes.length <= maxBytes ? String(text) : `${bytes.subarray(0, maxBytes - 1).toString("utf8")}…`;
329
+ }
330
+
331
+ /** Collapse a persisted output tail onto one indented line. @param {string} text @returns {string} */
332
+ function indent(text) {
333
+ return bounded(String(text ?? "").replace(/\s+/gu, " ").trim(), 1024);
334
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Judge round arbitration: interpret a returned verdict, decide re-ask versus
3
+ * settlement, and route what `startJudge` decided.
4
+ *
5
+ * `engine/dispatch.mjs` launches the judge invocation and hands the result
6
+ * here; this module never starts a provider invocation itself. The rejection
7
+ * paths themselves live in `engine/settle.mjs`, because the control loop
8
+ * settles too and having them here made the two import each other.
9
+ */import { parseJudge } from "./prompts.mjs";
10
+ import {
11
+ clearJudgeReask,
12
+ judgeReaskOutstanding,
13
+ markJudgeReask,
14
+ uncitedRejection,
15
+ } from "./judge-gate.mjs";
16
+ import {
17
+ JUDGE_UNAVAILABLE_CODE,
18
+ UNCITED_REJECTION_REASON,
19
+ invalidJudgeVerdict,
20
+ reviewMode,
21
+ } from "../contract/review-modes.mjs";
22
+
23
+ import { errorMessage, excerpt } from "../util.mjs";
24
+ import { appendTransitionEvent, transition } from "./state.mjs";
25
+ import { startJudge } from "./dispatch.mjs";
26
+ import { applyRejection, raiseNodeAttention, settleDone } from "./settle.mjs";
27
+
28
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
29
+ /** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
30
+ /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
31
+ /** @typedef {import("../run/lock.mjs").LockRecord} LockRecord */
32
+ /** @typedef {ReturnType<typeof import("../run/lock.mjs").acquire>} LockHandle */
33
+ /** @typedef {import("./prompts.mjs").JudgeVerdict} JudgeVerdict */
34
+ /** @typedef {import("./lifecycle.mjs").Job} Job */
35
+
36
+ /** A failed judge envelope gets one bounded re-dispatch, then judge_unavailable. */
37
+ export const JUDGE_MAX_FAILURES = 2;
38
+
39
+ /** Attention code raised when an advisory review completes without a verdict. */
40
+ const INVALID_JUDGE_OUTPUT_CODE = "invalid_judge_output";
41
+
42
+ /**
43
+ * Settle a judge round that produced no usable verdict. The first defect earns
44
+ * the one bounded re-ask on the node's own bound; once it is spent the review
45
+ * mode decides, and blocking review never degrades into a pass.
46
+ *
47
+ * @param {ValidatedContract} contract
48
+ * @param {ValidatedNode} node
49
+ * @param {NodeSnapshot} state
50
+ * @param {string} runDir
51
+ * @param {Map<string, Job>|null} running
52
+ * @param {LockHandle} lock
53
+ * @param {Map<string, NodeSnapshot>} states
54
+ * @param {string} campaignPath
55
+ * @param {string} reason
56
+ */
57
+ export async function applyJudgeProtocolFailure(contract, node, state, runDir, running, lock, states, campaignPath, reason) {
58
+ if (judgeReaskOutstanding(state)) {
59
+ if (reviewMode(node.gate) === "advisory") {
60
+ // The deterministic verification passed, so the review cannot fail the
61
+ // node; completing with the defect recorded keeps it visible instead of
62
+ // silently discarding the review.
63
+ await settleAdvisoryReview(contract, node, state, runDir, lock, states, campaignPath, invalidJudgeVerdict(reason));
64
+ await raiseNodeAttention(campaignPath, runDir, state, INVALID_JUDGE_OUTPUT_CODE);
65
+ return;
66
+ }
67
+ // Nothing about the attempt is rewritten: the accepted worker result, the
68
+ // verification records and the gate state stay exactly as a node awaiting
69
+ // its judge leaves them, so a retry in place re-judges instead of re-running.
70
+ transition(runDir, state, "blocked", {
71
+ phase: "judge",
72
+ result: state.result,
73
+ usage: state.usage,
74
+ error: { code: JUDGE_UNAVAILABLE_CODE, message: excerpt(reason) ?? "judge unavailable" },
75
+ }, lock);
76
+ await raiseNodeAttention(campaignPath, runDir, state, JUDGE_UNAVAILABLE_CODE);
77
+ return;
78
+ }
79
+ // The bound rides on the node: the next write — the recovered pending
80
+ // judge below or the re-ask dispatch's own invocation — persists it with
81
+ // the transition it belongs to, leaving no gap either way.
82
+ markJudgeReask(state, reason);
83
+ if (!running) {
84
+ // A verdict recovered after controller loss keeps its durable bound:
85
+ // the drive loop dispatches the one remaining bounded re-ask.
86
+ transition(runDir, state, "pending", { phase: "judge", gate: null, result: state.result, error: null, blockedBy: [] }, lock);
87
+ return;
88
+ }
89
+ await applyJudgeRound(await startJudge(contract, node, state, runDir, running, state.result, lock, states, campaignPath),
90
+ contract, node, state, runDir, running, lock, states, campaignPath, state.result);
91
+ }
92
+
93
+ /**
94
+ * Settle an advisory gate: the verdict is recorded with its findings and the
95
+ * node completes, because the deterministic verification already passed. A
96
+ * verdict that is not a clean pass appends the `gate.advisory` event the run
97
+ * journal keeps for review outcomes.
98
+ *
99
+ * @param {ValidatedContract} contract
100
+ * @param {ValidatedNode} node
101
+ * @param {NodeSnapshot} state
102
+ * @param {string} runDir
103
+ * @param {LockHandle} lock
104
+ * @param {Map<string, NodeSnapshot>} states
105
+ * @param {string} campaignPath
106
+ * @param {JudgeVerdict} verdict
107
+ */
108
+ async function settleAdvisoryReview(contract, node, state, runDir, lock, states, campaignPath, verdict) {
109
+ clearJudgeReask(state);
110
+ state.gate = verdict;
111
+ if (verdict.verdict !== "pass") {
112
+ appendTransitionEvent(runDir, state, state.status, state.status, {
113
+ type: "gate.advisory",
114
+ verdict: verdict.verdict,
115
+ summary: verdict.summary,
116
+ }, lock);
117
+ }
118
+ await settleDone(contract, node, state, runDir, lock, states, campaignPath, { phase: "complete", gate: verdict });
119
+ }
120
+
121
+ /** Settle a judge whose provider failed its bounded re-dispatches: blocking review blocks with the work preserved; advisory review completes with the defect recorded. @param {ValidatedContract} contract @param {ValidatedNode} node @param {NodeSnapshot} state @param {string} runDir @param {LockHandle} lock @param {Map<string, NodeSnapshot>} states @param {string} campaignPath @param {string} providerMessage */
122
+ export async function settleUnavailableJudge(contract, node, state, runDir, lock, states, campaignPath, providerMessage) {
123
+ if (reviewMode(node.gate) === "advisory") {
124
+ await settleAdvisoryReview(contract, node, state, runDir, lock, states, campaignPath, invalidJudgeVerdict(providerMessage));
125
+ await raiseNodeAttention(campaignPath, runDir, state, INVALID_JUDGE_OUTPUT_CODE);
126
+ return;
127
+ }
128
+ transition(runDir, state, "blocked", {
129
+ phase: "judge",
130
+ result: state.result,
131
+ usage: state.usage,
132
+ error: { code: JUDGE_UNAVAILABLE_CODE, message: excerpt(providerMessage) ?? "judge unavailable" },
133
+ }, lock);
134
+ await raiseNodeAttention(campaignPath, runDir, state, JUDGE_UNAVAILABLE_CODE);
135
+ }
136
+
137
+ /** Apply a judge verdict: pass settles done; a rejection citing no judgment item id is a judge protocol failure — one durable bounded re-ask, then blocked judge_protocol attention — and never consumes a revision, at any severity. Under advisory review a fail verdict is recorded and the node still completes. @param {ValidatedContract} contract @param {ValidatedNode} node @param {NodeSnapshot} state @param {unknown} result @param {string} runDir @param {LockHandle} lock @param {Map<string, Job>|null} running @param {Map<string, NodeSnapshot>} states @param {string} campaignPath */
138
+ export async function applyJudgeResult(contract, node, state, result, runDir, lock, running, states, campaignPath) {
139
+ /** @type {JudgeVerdict} */
140
+ let verdict;
141
+ try {
142
+ verdict = parseJudge(String(result ?? ""));
143
+ } catch (error) {
144
+ await applyJudgeProtocolFailure(contract, node, state, runDir, running, lock, states, campaignPath, errorMessage(error));
145
+ return;
146
+ }
147
+ state.gate = verdict;
148
+ state.judgeFailures = 0;
149
+ const advisory = reviewMode(node.gate) === "advisory";
150
+ const protocolFailure = verdict.verdict === "fail" && uncitedRejection(verdict, node);
151
+ if (protocolFailure && judgeReaskOutstanding(state)) {
152
+ if (advisory) {
153
+ await settleAdvisoryReview(contract, node, state, runDir, lock, states, campaignPath, verdict);
154
+ await raiseNodeAttention(campaignPath, runDir, state, "judge_protocol");
155
+ return;
156
+ }
157
+ transition(runDir, state, "blocked", {
158
+ phase: "judge",
159
+ gate: verdict,
160
+ result: state.result,
161
+ error: { code: "judge_protocol", message: "judge rejection cited no Definition of Done item id after the bounded re-ask" },
162
+ }, lock);
163
+ await raiseNodeAttention(campaignPath, runDir, state, "judge_protocol");
164
+ return;
165
+ }
166
+ if (protocolFailure) {
167
+ markJudgeReask(state, UNCITED_REJECTION_REASON);
168
+ if (!running) {
169
+ // A verdict recovered after controller loss keeps its durable bound:
170
+ // the drive loop dispatches the one remaining bounded re-ask.
171
+ transition(runDir, state, "pending", { phase: "judge", gate: null, result: state.result, error: null, blockedBy: [] }, lock);
172
+ return;
173
+ }
174
+ await applyJudgeRound(await startJudge(contract, node, state, runDir, running, state.result, lock, states, campaignPath),
175
+ contract, node, state, runDir, running, lock, states, campaignPath, state.result);
176
+ return;
177
+ }
178
+ clearJudgeReask(state);
179
+ if (advisory) {
180
+ await settleAdvisoryReview(contract, node, state, runDir, lock, states, campaignPath, verdict);
181
+ return;
182
+ }
183
+ const shouldFail = verdict.verdict === "fail" && verdict.maxSeverity !== "none"
184
+ && (node.gate.failOn ?? ["critical"]).includes(verdict.maxSeverity);
185
+ if (!shouldFail) {
186
+ await settleDone(contract, node, state, runDir, lock, states, campaignPath, { phase: "complete", gate: verdict });
187
+ return;
188
+ }
189
+ applyRejection(contract, node, state, runDir, running, lock, states, campaignPath, verdict, {
190
+ code: "revision_cap",
191
+ label: "gate",
192
+ phase: "judge",
193
+ });
194
+ }
195
+
196
+ /**
197
+ * Act on what a judge round decided. `startJudge` used to do this itself, which
198
+ * made dispatch depend on both review policy and settlement; the five callers
199
+ * all reach both already, so the decision comes back to them and lands here.
200
+ *
201
+ * @param {import("./dispatch.mjs").JudgeRound} round
202
+ * @param {ValidatedContract} contract
203
+ * @param {ValidatedNode} node
204
+ * @param {NodeSnapshot} state
205
+ * @param {string} runDir
206
+ * @param {Map<string, Job>} running
207
+ * @param {LockHandle} lock
208
+ * @param {Map<string, NodeSnapshot>} states
209
+ * @param {string} campaignPath
210
+ * @param {unknown} workerResult
211
+ * @returns {Promise<void>}
212
+ */
213
+ export async function applyJudgeRound(round, contract, node, state, runDir, running, lock, states, campaignPath, workerResult) {
214
+ if (round.kind === "rejected") {
215
+ applyRejection(contract, node, state, runDir, running, lock, states, campaignPath, round.verdict, {
216
+ code: "mechanical_gate_failed",
217
+ label: "mechanical-gate",
218
+ });
219
+ return;
220
+ }
221
+ if (round.kind === "settle") {
222
+ await settleDone(contract, node, state, runDir, lock, states, campaignPath, {
223
+ phase: "complete",
224
+ result: workerResult,
225
+ gate: round.gate,
226
+ });
227
+ }
228
+ }