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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conditional judge gate for schema-2 Definition of Done items.
|
|
3
|
+
*
|
|
4
|
+
* Deterministic items carry a mechanical `proof` (a verification command, a
|
|
5
|
+
* workspace path, or a `verification` entry reused by reference) and gate
|
|
6
|
+
* first: the controller settles them and no judge invocation is spent until
|
|
7
|
+
* they pass. The judge arbitrates only `judgment` items, and a gate-failing
|
|
8
|
+
* rejection whose findings cite no judgment item id is a judge protocol
|
|
9
|
+
* failure — one bounded re-ask, then blocked attention — that never consumes a
|
|
10
|
+
* worker revision.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { stat } from "node:fs/promises";
|
|
14
|
+
import { isAbsolute, resolve } from "node:path";
|
|
15
|
+
import { reviewMode, UNCITED_REJECTION_REASON } from "../contract/review-modes.mjs";
|
|
16
|
+
import { JUDGE_LIMITS } from "../contract/judge-envelope.mjs";
|
|
17
|
+
|
|
18
|
+
/** @typedef {import("../contract/definition-of-done.mjs").DefinitionOfDoneItem} DefinitionOfDoneItem */
|
|
19
|
+
/** @typedef {import("../contract/definition-of-done.mjs").DefinitionOfDoneProof} DefinitionOfDoneProof */
|
|
20
|
+
/** @typedef {import("../contract/index.mjs").ExecutionOverride} ExecutionOverride */
|
|
21
|
+
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
22
|
+
/** @typedef {import("../contract/index.mjs").VerificationState} VerificationState */
|
|
23
|
+
|
|
24
|
+
const MAX_PROOF_OUTPUT_BYTES = 4 * 1024;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {unknown} value
|
|
28
|
+
* @param {number} maxBytes
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
function boundedText(value, maxBytes = MAX_PROOF_OUTPUT_BYTES) {
|
|
32
|
+
const text = String(value ?? "");
|
|
33
|
+
const bytes = Buffer.from(text, "utf8");
|
|
34
|
+
if (bytes.length <= maxBytes) return text;
|
|
35
|
+
// The marker costs 3 bytes in UTF-8 and a byte-aligned cut can land inside a
|
|
36
|
+
// multibyte character, whose replacement costs 3 more. Reserve the marker and
|
|
37
|
+
// then shrink until the encoded result actually fits: a finding that exceeds
|
|
38
|
+
// the validator's evidence ceiling is not truncated downstream, it throws, and
|
|
39
|
+
// the throw kills the controller mid-gate.
|
|
40
|
+
const marker = "…";
|
|
41
|
+
const markerBytes = Buffer.byteLength(marker, "utf8");
|
|
42
|
+
if (maxBytes <= markerBytes) return "";
|
|
43
|
+
let room = maxBytes - markerBytes;
|
|
44
|
+
let out = `${bytes.subarray(0, room).toString("utf8")}${marker}`;
|
|
45
|
+
while (room > 0 && Buffer.byteLength(out, "utf8") > maxBytes) {
|
|
46
|
+
room -= 1;
|
|
47
|
+
out = `${bytes.subarray(0, room).toString("utf8")}${marker}`;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @param {{definitionOfDone?: DefinitionOfDoneItem[]}} node @returns {DefinitionOfDoneItem[]} */
|
|
53
|
+
function mechanicalItems(node) {
|
|
54
|
+
return (node.definitionOfDone ?? []).filter((item) => item.proof !== undefined);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @param {{definitionOfDone?: DefinitionOfDoneItem[]}} node @returns {DefinitionOfDoneItem[]} */
|
|
58
|
+
export function judgmentItems(node) {
|
|
59
|
+
return (node.definitionOfDone ?? []).filter((item) => item.judgment === true);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A gated node runs the judge only when its review mode is not `none` and a Definition of Done item carries judgment:true; an empty, purely deterministic, or review-free checklist settles mechanically without spending a judge invocation. @param {{definitionOfDone?: DefinitionOfDoneItem[], gate?: {enabled?: boolean, review?: unknown}}} node @returns {boolean} */
|
|
63
|
+
export function judgeRequired(node) {
|
|
64
|
+
return reviewMode(node.gate) !== "none"
|
|
65
|
+
&& (node.definitionOfDone ?? []).some((item) => item.judgment === true);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The green-and-small escape hatch: the contract declared `gate.skipWhen`, the
|
|
70
|
+
* controller verification passed, and the persisted workspace scope recorded no
|
|
71
|
+
* more changed paths than the declared ceiling. Both conditions must hold, and
|
|
72
|
+
* this predicate overrides `judgment: true` — that is its whole purpose. It
|
|
73
|
+
* composes with `reviewMode` rather than replacing it: `judgeRequired` still
|
|
74
|
+
* owns the `none` case, and a gate with no `skipWhen` behaves exactly as before.
|
|
75
|
+
*
|
|
76
|
+
* @param {{gate?: {skipWhen?: {verificationGreen: true, maxChangedPaths: number}}}} node
|
|
77
|
+
* @param {{verification?: {passed?: boolean}|null, scope?: {changedPathCount?: number, changedPaths?: string[]}|null}} state
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
export function judgeSkippedByScope(node, state) {
|
|
81
|
+
const skipWhen = node.gate?.skipWhen;
|
|
82
|
+
if (!skipWhen) return false;
|
|
83
|
+
if (state.verification?.passed !== true) return false;
|
|
84
|
+
const changed = typeof state.scope?.changedPathCount === "number"
|
|
85
|
+
? state.scope.changedPathCount
|
|
86
|
+
: Array.isArray(state.scope?.changedPaths) ? state.scope.changedPaths.length : null;
|
|
87
|
+
return typeof changed === "number" && changed <= skipWhen.maxChangedPaths;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {DefinitionOfDoneItem[]} items
|
|
92
|
+
* @param {string} cwd
|
|
93
|
+
* @param {{timeoutMs?: number, verification?: VerificationState|null}} [options]
|
|
94
|
+
* @returns {Promise<Array<{id: string, kind: "command"|"path"|"verification", ref: string, pass: boolean, detail: string}>>}
|
|
95
|
+
*/
|
|
96
|
+
async function runMechanicalProofs(items, cwd, options = {}) {
|
|
97
|
+
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
98
|
+
const recorded = options.verification?.commands ?? [];
|
|
99
|
+
const results = [];
|
|
100
|
+
for (const item of items) {
|
|
101
|
+
const proof = item.proof;
|
|
102
|
+
if (proof === undefined) continue;
|
|
103
|
+
results.push(proof.kind === "path"
|
|
104
|
+
? await provePath(item.id, proof, cwd)
|
|
105
|
+
: proof.kind === "verification"
|
|
106
|
+
? proveVerification(item.id, proof, recorded)
|
|
107
|
+
: await proveCommand(item.id, proof, cwd, timeoutMs));
|
|
108
|
+
}
|
|
109
|
+
return results;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Reuse the recorded result of one controller verification entry: pass or fail
|
|
114
|
+
* and its bounded output, with nothing executed. The reference is positional,
|
|
115
|
+
* so the entry this attempt recorded is exactly the entry the packet named.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} id
|
|
118
|
+
* @param {DefinitionOfDoneProof} proof
|
|
119
|
+
* @param {Array<{argv: string[], passed: boolean, attempts?: Array<{exitCode?: number|null, stdout?: string, stderr?: string}>}>} recorded
|
|
120
|
+
* @returns {{id: string, kind: "verification", ref: string, pass: boolean, detail: string}}
|
|
121
|
+
*/
|
|
122
|
+
function proveVerification(id, proof, recorded) {
|
|
123
|
+
const index = Number.parseInt(proof.ref, 10);
|
|
124
|
+
const entry = Number.isInteger(index) ? recorded[index] : undefined;
|
|
125
|
+
if (!entry) {
|
|
126
|
+
return { id, kind: "verification", ref: proof.ref, pass: false, detail: `verification command ${proof.ref} has no recorded result for this attempt` };
|
|
127
|
+
}
|
|
128
|
+
const attempt = entry.attempts?.at(-1);
|
|
129
|
+
const output = [attempt?.stderr, attempt?.stdout].find((text) => typeof text === "string" && text.trim()) ?? "";
|
|
130
|
+
const detail = entry.passed
|
|
131
|
+
? `reused recorded verification result: ${entry.argv.join(" ")} passed`
|
|
132
|
+
: boundedText(`reused recorded verification result: ${entry.argv.join(" ")} failed: ${output.trim()}`);
|
|
133
|
+
return { id, kind: "verification", ref: proof.ref, pass: entry.passed === true, detail };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @param {string} id
|
|
138
|
+
* @param {DefinitionOfDoneProof} proof
|
|
139
|
+
* @param {string} cwd
|
|
140
|
+
* @param {number} timeoutMs
|
|
141
|
+
* @returns {Promise<{id: string, kind: "command"|"path", ref: string, pass: boolean, detail: string}>}
|
|
142
|
+
*/
|
|
143
|
+
async function proveCommand(id, proof, cwd, timeoutMs) {
|
|
144
|
+
const ref = proof.ref;
|
|
145
|
+
return new Promise((settle) => {
|
|
146
|
+
// Detached on POSIX so the shell leads its own process group: `shell: true`
|
|
147
|
+
// means the timeout must kill the group, not the shell, or the command the
|
|
148
|
+
// shell started keeps running and keeps the result pending forever.
|
|
149
|
+
const child = spawn(ref, { cwd, shell: true, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] });
|
|
150
|
+
let stdout = "";
|
|
151
|
+
let stderr = "";
|
|
152
|
+
let settled = false;
|
|
153
|
+
const finish = (/** @type {{id: string, kind: "command", ref: string, pass: boolean, detail: string}} */ result) => {
|
|
154
|
+
if (settled) return;
|
|
155
|
+
settled = true;
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
try { child.stdout?.destroy(); } catch {
|
|
158
|
+
// The stream already closed; destroying it again is a no-op.
|
|
159
|
+
}
|
|
160
|
+
try { child.stderr?.destroy(); } catch {
|
|
161
|
+
// The stream already closed; destroying it again is a no-op.
|
|
162
|
+
}
|
|
163
|
+
settle(result);
|
|
164
|
+
};
|
|
165
|
+
const timer = setTimeout(() => {
|
|
166
|
+
terminateProofGroup(child);
|
|
167
|
+
finish({ id, kind: "command", ref, pass: false, detail: boundedText(`timed out after ${timeoutMs}ms`) });
|
|
168
|
+
}, timeoutMs);
|
|
169
|
+
child.stdout.on("data", (chunk) => {
|
|
170
|
+
if (stdout.length < MAX_PROOF_OUTPUT_BYTES) stdout += chunk;
|
|
171
|
+
});
|
|
172
|
+
child.stderr.on("data", (chunk) => {
|
|
173
|
+
if (stderr.length < MAX_PROOF_OUTPUT_BYTES) stderr += chunk;
|
|
174
|
+
});
|
|
175
|
+
child.on("error", (error) => {
|
|
176
|
+
finish({ id, kind: "command", ref, pass: false, detail: boundedText(error.message) });
|
|
177
|
+
});
|
|
178
|
+
child.on("close", (code, signal) => {
|
|
179
|
+
const detail = signal !== null ? `killed by ${signal}` : `exit ${code ?? "?"}`;
|
|
180
|
+
const pass = code === 0 && signal === null;
|
|
181
|
+
finish({ id, kind: "command", ref, pass, detail: pass ? detail : boundedText(`${detail}: ${(stderr || stdout).trim()}`) });
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Kill the proof command's process group, then make sure it is gone. The group
|
|
188
|
+
* is the shell and everything it started; killing only the shell was the bug
|
|
189
|
+
* that let a timed-out proof hold the pipe and never settle.
|
|
190
|
+
*
|
|
191
|
+
* @param {import("node:child_process").ChildProcess} child
|
|
192
|
+
*/
|
|
193
|
+
function terminateProofGroup(child) {
|
|
194
|
+
const pid = child.pid;
|
|
195
|
+
if (!pid) return;
|
|
196
|
+
const signal = (/** @type {NodeJS.Signals} */ name) => {
|
|
197
|
+
try {
|
|
198
|
+
if (process.platform !== "win32") process.kill(-pid, name);
|
|
199
|
+
else child.kill(name);
|
|
200
|
+
} catch {
|
|
201
|
+
try { child.kill(name); } catch {
|
|
202
|
+
// ESRCH: the group and the leader are already gone.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
signal("SIGTERM");
|
|
207
|
+
setTimeout(() => signal("SIGKILL"), 100).unref();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @param {string} id
|
|
212
|
+
* @param {DefinitionOfDoneProof} proof
|
|
213
|
+
* @param {string} cwd
|
|
214
|
+
* @returns {Promise<{id: string, kind: "command"|"path", ref: string, pass: boolean, detail: string}>}
|
|
215
|
+
*/
|
|
216
|
+
async function provePath(id, proof, cwd) {
|
|
217
|
+
const ref = proof.ref;
|
|
218
|
+
try {
|
|
219
|
+
const target = isAbsolute(ref) ? ref : resolve(cwd, ref);
|
|
220
|
+
const info = await stat(target);
|
|
221
|
+
return { id, kind: "path", ref, pass: true, detail: `${info.isDirectory() ? "directory" : "file"} exists` };
|
|
222
|
+
} catch (error) {
|
|
223
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
224
|
+
return { id, kind: "path", ref, pass: false, detail: boundedText(message) };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Verdict a deterministic gate from its per-item mechanical results: pass only
|
|
230
|
+
* when every proof passed.
|
|
231
|
+
*
|
|
232
|
+
* @param {Array<{id: string, kind: "command"|"path"|"verification", ref: string, pass: boolean, detail: string}>} results
|
|
233
|
+
* @returns {import("./prompts.mjs").JudgeVerdict}
|
|
234
|
+
*/
|
|
235
|
+
export function mechanicalVerdict(results) {
|
|
236
|
+
const failed = results.filter((result) => !result.pass);
|
|
237
|
+
if (!failed.length) {
|
|
238
|
+
return {
|
|
239
|
+
verdict: "pass",
|
|
240
|
+
maxSeverity: "none",
|
|
241
|
+
summary: "every deterministic Definition of Done item passed",
|
|
242
|
+
findings: [],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
verdict: "fail",
|
|
247
|
+
maxSeverity: "critical",
|
|
248
|
+
summary: "deterministic Definition of Done item failed",
|
|
249
|
+
findings: failed.map((result) => ({
|
|
250
|
+
severity: "critical",
|
|
251
|
+
description: `Definition of Done item [${result.id}] failed its ${result.kind} proof`,
|
|
252
|
+
evidence: boundedText(`${result.ref}: ${result.detail}`),
|
|
253
|
+
})),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The deterministic evidence a judge protocol re-ask must reuse: the round's
|
|
259
|
+
* mechanical proofs already passed before the first ask, so the re-ask prompt
|
|
260
|
+
* reports every deterministic item as proven without re-running any proof.
|
|
261
|
+
*
|
|
262
|
+
* @param {{definitionOfDone?: DefinitionOfDoneItem[]}} node
|
|
263
|
+
* @returns {Array<{id: string, kind: "command"|"path"|"verification", ref: string, pass: boolean, detail: string}>}
|
|
264
|
+
*/
|
|
265
|
+
function provenDeterministicResults(node) {
|
|
266
|
+
return mechanicalItems(node).map((item) => {
|
|
267
|
+
const proof = /** @type {DefinitionOfDoneProof} */ (item.proof);
|
|
268
|
+
return { id: item.id, kind: proof.kind, ref: proof.ref, pass: true, detail: "" };
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Deterministic evidence of one gate round plus its mechanical verdict. The
|
|
274
|
+
* first ask settles every mechanical proof (a command proof executes; a
|
|
275
|
+
* verification proof reuses its recorded result); a judge protocol re-ask
|
|
276
|
+
* reuses the round's proven items and never re-runs a proof, so a flaky second
|
|
277
|
+
* execution cannot consume a worker revision on the protocol failure path.
|
|
278
|
+
*
|
|
279
|
+
* @param {{definitionOfDone?: DefinitionOfDoneItem[]}} node
|
|
280
|
+
* @param {string} cwd
|
|
281
|
+
* @param {boolean} reask
|
|
282
|
+
* @param {number} timeoutMs
|
|
283
|
+
* @param {VerificationState|null} [verification] this attempt's recorded verification results
|
|
284
|
+
* @returns {Promise<{verdict: import("./prompts.mjs").JudgeVerdict, results: Array<{id: string, pass: boolean, detail: string}>}>}
|
|
285
|
+
*/
|
|
286
|
+
export async function deterministicGate(node, cwd, reask, timeoutMs, verification = null) {
|
|
287
|
+
const results = reask
|
|
288
|
+
? provenDeterministicResults(node)
|
|
289
|
+
: await runMechanicalProofs(mechanicalItems(node), cwd, { timeoutMs, verification });
|
|
290
|
+
return { verdict: mechanicalVerdict(results), results };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Node's test runner prints `test at <path>:<line>:<column>` immediately before
|
|
295
|
+
* each failing test. That is the only place the file a suite accused is written
|
|
296
|
+
* down, so it is read exactly as the runner wrote it -- inventing a format of
|
|
297
|
+
* our own would lie the first time the runner changed.
|
|
298
|
+
*/
|
|
299
|
+
const NODE_TEST_LOCATION = /^test at (.+?):\d+(?::\d+)?\s*$/gmu;
|
|
300
|
+
|
|
301
|
+
/** @param {unknown} value @returns {string} */
|
|
302
|
+
function stripAnsi(value) {
|
|
303
|
+
return String(value ?? "").replace(/\u001b\[[0-9;]*m/gu, "");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Test files named by the captured output of failing verification commands.
|
|
308
|
+
*
|
|
309
|
+
* @param {Array<{attempts?: Array<{stdout?: string, stderr?: string}>}>} commands
|
|
310
|
+
* @returns {string[]}
|
|
311
|
+
*/
|
|
312
|
+
function namedTestFiles(commands) {
|
|
313
|
+
/** @type {string[]} */
|
|
314
|
+
const paths = [];
|
|
315
|
+
const seen = new Set();
|
|
316
|
+
for (const command of commands) {
|
|
317
|
+
for (const attempt of command.attempts ?? []) {
|
|
318
|
+
for (const stream of [attempt.stdout, attempt.stderr]) {
|
|
319
|
+
for (const match of stripAnsi(stream).matchAll(NODE_TEST_LOCATION)) {
|
|
320
|
+
const path = match[1].trim();
|
|
321
|
+
if (!path || seen.has(path)) continue;
|
|
322
|
+
seen.add(path);
|
|
323
|
+
paths.push(path);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return paths;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Whether a path named by verification output sits inside the write scope the
|
|
333
|
+
* node persisted. `state.scope.boundary` is the captured form of the packet's
|
|
334
|
+
* `writeFiles` plus `writeRoots`, so it is the only copy of the declared scope
|
|
335
|
+
* available at settlement time; `fileRoots` are exact paths, the rest of
|
|
336
|
+
* `roots` cover their subtree.
|
|
337
|
+
*
|
|
338
|
+
* @param {{scope?: {boundary?: {files?: string[], roots?: string[], fileRoots?: string[]}}|null}|null|undefined} state
|
|
339
|
+
* @returns {(path: string) => boolean}
|
|
340
|
+
*/
|
|
341
|
+
function declaredWriteCoverage(state) {
|
|
342
|
+
const boundary = state?.scope?.boundary;
|
|
343
|
+
const files = new Set(boundary?.files ?? []);
|
|
344
|
+
const fileRoots = new Set(boundary?.fileRoots ?? []);
|
|
345
|
+
const directoryRoots = (boundary?.roots ?? []).filter((root) => !fileRoots.has(root));
|
|
346
|
+
return (path) =>
|
|
347
|
+
files.has(path) ||
|
|
348
|
+
fileRoots.has(path) ||
|
|
349
|
+
directoryRoots.some((root) => path === root || path.startsWith(`${root}/`));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The operator-facing description for a failure that named a test outside the
|
|
354
|
+
* declared write scope. The defect is not in the worker's code: the worker is
|
|
355
|
+
* forbidden from touching the test, so the contract that withheld it is what
|
|
356
|
+
* has to change. The message names the file and says to fix the contract.
|
|
357
|
+
*
|
|
358
|
+
* @param {string[]} paths
|
|
359
|
+
* @returns {string}
|
|
360
|
+
*/
|
|
361
|
+
function undeclaredTestDescription(paths) {
|
|
362
|
+
const files = paths.join(", ");
|
|
363
|
+
const noun = paths.length === 1 ? "file" : "files";
|
|
364
|
+
const pronoun = paths.length === 1 ? "it" : "them";
|
|
365
|
+
return boundedText(
|
|
366
|
+
`deterministic verification failed in undeclared test ${noun} ${files}; the node's writeFiles does not include ${pronoun}, so the worker cannot fix the failing test. This is a contract defect, not a worker defect: add ${files} to writeFiles or scopeAcknowledged and re-dispatch`,
|
|
367
|
+
JUDGE_LIMITS.descriptionBytes,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The deterministic controller-verification failure verdict, kept next to the
|
|
373
|
+
* Definition of Done gate so every deterministic failure settles identically.
|
|
374
|
+
*
|
|
375
|
+
* @param {{verification?: {commands?: Array<{argv: string[], passed?: boolean, attempts?: Array<{stdout?: string, stderr?: string, exitCode?: number|null, timedOut?: boolean}>}>, error?: unknown}|null, scope?: {boundary?: {files?: string[], roots?: string[], fileRoots?: string[]}}|null}} state
|
|
376
|
+
* @returns {import("./prompts.mjs").JudgeVerdict}
|
|
377
|
+
*/
|
|
378
|
+
export function verificationFailureVerdict(state) {
|
|
379
|
+
const failedCommands = (state.verification?.commands ?? []).filter((command) => !command.passed);
|
|
380
|
+
const evidence = failedCommands.length
|
|
381
|
+
? failedCommands.map((command) => `${command.argv.join(" ")}: ${(command.attempts ?? []).map((attempt) => `exit=${attempt.exitCode ?? "-"}${attempt.timedOut ? " timeout" : ""}`).join(", ")}`).join("; ")
|
|
382
|
+
: state.verification?.error ?? "verification controller failed to execute a command";
|
|
383
|
+
const undeclared = namedTestFiles(failedCommands).filter((path) => !declaredWriteCoverage(state)(path));
|
|
384
|
+
return {
|
|
385
|
+
verdict: "fail",
|
|
386
|
+
maxSeverity: "critical",
|
|
387
|
+
summary: "deterministic verification failed",
|
|
388
|
+
findings: [{
|
|
389
|
+
severity: "critical",
|
|
390
|
+
description: undeclared.length ? undeclaredTestDescription(undeclared) : "deterministic verification failed",
|
|
391
|
+
evidence: boundedText(evidence),
|
|
392
|
+
}],
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The commands the integration candidate failed that the attempt had passed.
|
|
398
|
+
*
|
|
399
|
+
* A non-empty list means the two worktrees disagree about the environment
|
|
400
|
+
* rather than about the work: the same commit ran the same command twice with
|
|
401
|
+
* different outcomes. Without naming that, the failure reads as a defect in
|
|
402
|
+
* the node's own changes — which is how a missing `node_modules` link in the
|
|
403
|
+
* candidate once cost a campaign four attempts on already-correct work.
|
|
404
|
+
* Commands are matched by position, never by comparing joined argv, since a
|
|
405
|
+
* joined argv loses argument boundaries.
|
|
406
|
+
*
|
|
407
|
+
* @param {unknown} attempt the attempt's recorded verification
|
|
408
|
+
* @param {unknown} candidate the candidate's recorded verification
|
|
409
|
+
* @returns {string[]}
|
|
410
|
+
*/
|
|
411
|
+
export function candidateOnlyFailures(attempt, candidate) {
|
|
412
|
+
const attemptCommands = verificationCommands(attempt);
|
|
413
|
+
return verificationCommands(candidate)
|
|
414
|
+
.map((command, index) => ({ command, counterpart: attemptCommands[index] }))
|
|
415
|
+
.filter((pair) => pair.command.passed === false && pair.counterpart?.passed === true)
|
|
416
|
+
.map((pair) => (pair.command.argv ?? []).join(" "));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** @param {unknown} evidence @returns {Array<{argv?: string[], passed?: boolean}>} */
|
|
420
|
+
function verificationCommands(evidence) {
|
|
421
|
+
const commands = /** @type {{commands?: unknown}} */ (evidence ?? {}).commands;
|
|
422
|
+
return Array.isArray(commands) ? commands : [];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* A gate-failing judge verdict on a node with judgment items is a protocol
|
|
427
|
+
* failure when none of its findings cites any judgment item id.
|
|
428
|
+
*
|
|
429
|
+
* @param {{verdict: string, findings: Array<{description: string, evidence: string}>}} verdict
|
|
430
|
+
* @param {{definitionOfDone?: DefinitionOfDoneItem[]}} node
|
|
431
|
+
* @returns {boolean}
|
|
432
|
+
*/
|
|
433
|
+
export function uncitedRejection(verdict, node) {
|
|
434
|
+
const items = judgmentItems(node);
|
|
435
|
+
if (verdict.verdict !== "fail" || items.length === 0) return false;
|
|
436
|
+
const ids = new Set(items.map((item) => item.id));
|
|
437
|
+
return !verdict.findings.some((finding) => citesItem(finding.description, ids) || citesItem(finding.evidence, ids));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** @param {string} text @param {Set<string>} ids @returns {boolean} */
|
|
441
|
+
function citesItem(text, ids) {
|
|
442
|
+
return text.split(/[^A-Za-z0-9._-]+/u).some((token) => ids.has(token));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The execution-override kind that records a spent judge protocol re-ask. The
|
|
447
|
+
* snapshot validator takes any override kind; only the kinds the recovery path
|
|
448
|
+
* interprets are named in the contract's typedef, so this one is read and
|
|
449
|
+
* written through the same record cast the recovery scan uses.
|
|
450
|
+
*/
|
|
451
|
+
const JUDGE_REASK_KIND = "judge-reask";
|
|
452
|
+
|
|
453
|
+
/** @param {ExecutionOverride} override @returns {boolean} */
|
|
454
|
+
function isJudgeReask(override) {
|
|
455
|
+
return /** @type {Record<string, unknown>} */ (override).kind === JUDGE_REASK_KIND;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** @param {NodeSnapshot} state @returns {Record<string, unknown>|null} */
|
|
459
|
+
function judgeReaskRecord(state) {
|
|
460
|
+
const record = [...(state.executionOverrides ?? [])].reverse().find(isJudgeReask);
|
|
461
|
+
return record ? /** @type {Record<string, unknown>} */ (record) : null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Spend the one bounded re-ask of the current judge round on the node state
|
|
466
|
+
* itself. The record is only mutated in memory: the caller's transition — the
|
|
467
|
+
* same atomic node write that persists the re-ask dispatch, the recovered
|
|
468
|
+
* pending judge, or the blocked attention — carries it to disk. Bound and node
|
|
469
|
+
* therefore move together, so no crash window can skip the permitted re-ask or
|
|
470
|
+
* grant a second one.
|
|
471
|
+
*
|
|
472
|
+
* @param {NodeSnapshot} state
|
|
473
|
+
* @param {string} [reason] what the re-ask answers, which selects its instruction
|
|
474
|
+
*/
|
|
475
|
+
export function markJudgeReask(state, reason = UNCITED_REJECTION_REASON) {
|
|
476
|
+
if (judgeReaskOutstanding(state)) return;
|
|
477
|
+
const record = /** @type {ExecutionOverride} */ (/** @type {unknown} */ ({
|
|
478
|
+
kind: JUDGE_REASK_KIND,
|
|
479
|
+
at: new Date().toISOString(),
|
|
480
|
+
phase: "judge",
|
|
481
|
+
reason,
|
|
482
|
+
}));
|
|
483
|
+
state.executionOverrides = [...(state.executionOverrides ?? []), record];
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Whether the current judge round already spent its one bounded re-ask. @param {NodeSnapshot} state @returns {boolean} */
|
|
487
|
+
export function judgeReaskOutstanding(state) {
|
|
488
|
+
return judgeReaskRecord(state) !== null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* What the outstanding re-ask answers, so the re-dispatched prompt carries the
|
|
493
|
+
* instruction the defect calls for.
|
|
494
|
+
*
|
|
495
|
+
* @param {NodeSnapshot} state
|
|
496
|
+
* @returns {string|undefined}
|
|
497
|
+
*/
|
|
498
|
+
export function judgeReaskReason(state) {
|
|
499
|
+
const reason = judgeReaskRecord(state)?.reason;
|
|
500
|
+
return typeof reason === "string" ? reason : undefined;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Release the bound when a judge round settles on a verdict that is not a protocol failure, so the next round is asked afresh. @param {NodeSnapshot} state */
|
|
504
|
+
export function clearJudgeReask(state) {
|
|
505
|
+
if (!judgeReaskOutstanding(state)) return;
|
|
506
|
+
state.executionOverrides = (state.executionOverrides ?? []).filter((item) => !isJudgeReask(item));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Drop the routing override and progress snapshot so a fresh worker attempt
|
|
511
|
+
* routes and meters from scratch.
|
|
512
|
+
* @param {{routing?: {currentOverride?: unknown}|null, progress?: unknown}} state
|
|
513
|
+
*/
|
|
514
|
+
export function resetPhaseRouting(state) {
|
|
515
|
+
if (state.routing) state.routing.currentOverride = null;
|
|
516
|
+
state.progress = null;
|
|
517
|
+
}
|