faberun 0.19.1 → 0.19.2
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/package.json +1 -1
- package/skills/faberun/references/contract.md +17 -5
- package/src/campaign/index.mjs +56 -1
- package/src/cli/plan.mjs +164 -27
- package/src/cli/spec.mjs +17 -7
- package/src/cli.mjs +1 -0
- package/src/contract/definition-of-done.mjs +50 -0
- package/src/contract/final-verification.mjs +21 -0
- package/src/contract/index.mjs +20 -8
- package/src/contract/verification.mjs +63 -2
- package/src/engine/dispatch.mjs +2 -1
- package/src/engine/process.mjs +21 -1
- package/src/engine/scheduler.mjs +2 -2
- package/src/plan/pipeline.mjs +27 -4
- package/src/plan/proof-run.mjs +121 -0
- package/src/plan/repo-facts.mjs +5 -39
- package/src/plan/sizing.mjs +72 -4
- package/src/plan/spec.mjs +25 -1
- package/src/plan/template.mjs +24 -2
- package/src/report/final.mjs +44 -7
- package/src/report/render.mjs +98 -139
- package/src/report/role-usage.mjs +145 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.2",
|
|
4
4
|
"description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -46,8 +46,10 @@ typecheck`). Schema version is `3`.
|
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
Every `definitionOfDone` item declares `id`, `text`, and how it is proven:
|
|
49
|
-
`proof.kind` `command` (re-runs the command
|
|
50
|
-
|
|
49
|
+
`proof.kind` `command` (re-runs the command through a shell, bounded by the
|
|
50
|
+
node's own `timeoutSec`; quote a flag value containing spaces, which
|
|
51
|
+
`verification`'s argv does not need and a shell splits) or `path` (a file must
|
|
52
|
+
exist), or `judgment: true` for the judge. `proof: {
|
|
51
53
|
kind: "verification", ref: <index> }` reuses a `verification` entry's already
|
|
52
54
|
recorded result by position instead of re-running it — never by comparing argv
|
|
53
55
|
strings, since a joined argv loses shell semantics. A schema-1 string item is
|
|
@@ -95,8 +97,11 @@ discovery packet has empty `writeFiles`; with an empty `readFiles` it may
|
|
|
95
97
|
read the repository read-only to produce an execution packet — the one
|
|
96
98
|
exception to closed scope — otherwise it is closed to the listed files. Each
|
|
97
99
|
`verification` entry is `{argv, cwd?, timeoutSec? (default 120, max 600),
|
|
98
|
-
repeat? (default 1, max 8), env?}` — at most 32 commands, 64
|
|
99
|
-
KiB argv bytes per command. `
|
|
100
|
+
repeat? (default 1, max 8), env?, requirementId?}` — at most 32 commands, 64
|
|
101
|
+
argv items, 32 KiB argv bytes per command. `requirementId` names the spec
|
|
102
|
+
requirement this command proves, changing nothing about how it runs: it is
|
|
103
|
+
what lets `contract validate` report two copies of one proof that have stopped
|
|
104
|
+
agreeing. `env` declares variable *names* only; values
|
|
100
105
|
never travel in the packet. `prompt`/`promptFile` are
|
|
101
106
|
rejected; a node has `taskPacket` or `taskPacketFile`, never both. Measure a
|
|
102
107
|
candidate command's real duration before naming it in `verification` or a
|
|
@@ -289,7 +294,14 @@ only for a harness declaring `streamsOutput` (true for `codex`, `claude`,
|
|
|
289
294
|
others fall back to `timeoutSec` alone.
|
|
290
295
|
`timeoutSec` (default 2400s) caps one invocation and may be overridden per
|
|
291
296
|
node; a node is bounded by `(1 + maxRevisions) × 2 × timeoutSec`. Both clocks
|
|
292
|
-
are monotonic and pause with host suspend.
|
|
297
|
+
are monotonic and pause with host suspend.
|
|
298
|
+
`maxTurns` (default 150) bounds something else: the *provider requests* one
|
|
299
|
+
attempt may make, overridable per node. Reaching it ends the attempt with
|
|
300
|
+
`errorCode: turn_limit`, sealed then retried once, the spend already spent. It
|
|
301
|
+
bites the nodes that read much and write little — review, synthesis — so raise
|
|
302
|
+
it there; raising `timeoutSec` does not help. The controller says so once at
|
|
303
|
+
80%, and `usage.jsonl` records each invocation's `session.requests`.
|
|
304
|
+
`maxParallel` above 1 dispatches
|
|
293
305
|
every dependency-ready node concurrently, each into its own attempt
|
|
294
306
|
worktree; integration stays serialized. Nodes of one phase need no edge
|
|
295
307
|
between them: a continuation a live invocation already claims is never
|
package/src/campaign/index.mjs
CHANGED
|
@@ -132,9 +132,14 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
|
|
|
132
132
|
requireTimestamp(at, "at");
|
|
133
133
|
const campaign = readCampaign(campaignPath);
|
|
134
134
|
if (campaign.status === "closed") throw new Error(`campaign already closed: ${campaign.id}`);
|
|
135
|
-
|
|
135
|
+
const journal = readJournalForDedupe(campaignPath);
|
|
136
|
+
if (!journal.some((entry) => entry.type === "retrospective")) {
|
|
136
137
|
throw new Error(`campaign ${campaign.id} has no recorded retrospective; record one with note --kind retrospective before close`);
|
|
137
138
|
}
|
|
139
|
+
const unacknowledged = unacknowledgedAdvisories(campaignPath, campaign, journal);
|
|
140
|
+
if (unacknowledged.length) {
|
|
141
|
+
throw new Error(`campaign ${campaign.id} has judge findings no note has answered: ${unacknowledged.join("; ")}. Read them with \`faberun findings <run-dir>\`, then name the node in a note (\`campaign note ${campaign.id} --kind outcome --run-id <run-id> --text "...<node>..."\`) before close`);
|
|
142
|
+
}
|
|
138
143
|
const repoRoot = campaignRepoRoot(campaignPath);
|
|
139
144
|
const ledgerFiles = preserveCampaignLedger(campaignPath, repoRoot);
|
|
140
145
|
// The closure travels on the record itself, computed in one deterministic
|
|
@@ -147,6 +152,56 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
|
|
|
147
152
|
return { path: campaignPath, campaign: closed, ledgerFiles };
|
|
148
153
|
}
|
|
149
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Judge findings on nodes the gate accepted, which no journal note names.
|
|
157
|
+
*
|
|
158
|
+
* A gate that accepts a node whose findings sit below `failOn` is correct and
|
|
159
|
+
* documented. What was wrong is that the campaign could then be closed with
|
|
160
|
+
* the finding never read by anyone: measured 2026-09-21, a synthesis node's
|
|
161
|
+
* `gate.verdict` was `fail` with a real finding, `STATUS` said `passed`, and
|
|
162
|
+
* the campaign's own retrospective recorded that every gate passed first
|
|
163
|
+
* time. A close is the last moment the claim can still be corrected.
|
|
164
|
+
*
|
|
165
|
+
* Acknowledgement is a note that names the node id. The node id is the
|
|
166
|
+
* identifier the run, the contract and the findings output all already use,
|
|
167
|
+
* so matching on it asks the operator for nothing new; matching on a
|
|
168
|
+
* finding's prose would be matching on text the judge wrote, which is not a
|
|
169
|
+
* stable name.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} campaignPath
|
|
172
|
+
* @param {Campaign} campaign
|
|
173
|
+
* @param {{type: string, text?: unknown}[]} journal
|
|
174
|
+
* @returns {string[]} one `runId/nodeId (N findings, maxSeverity)` per unanswered node, sorted
|
|
175
|
+
*/
|
|
176
|
+
function unacknowledgedAdvisories(campaignPath, campaign, journal) {
|
|
177
|
+
const runsDir = resolve(campaignPath, "..", "..");
|
|
178
|
+
const noteText = journal
|
|
179
|
+
.filter((entry) => typeof entry.text === "string")
|
|
180
|
+
.map((entry) => /** @type {string} */ (entry.text))
|
|
181
|
+
.join("\n");
|
|
182
|
+
/** @type {string[]} */
|
|
183
|
+
const pending = [];
|
|
184
|
+
for (const runId of [...campaign.linkedRunIds].sort()) {
|
|
185
|
+
const contract = readRunJson(join(runsDir, runId, "contract.json"));
|
|
186
|
+
const nodes = contract !== null && Array.isArray(contract.nodes) ? /** @type {JsonObject[]} */ (contract.nodes) : [];
|
|
187
|
+
for (const node of nodes) {
|
|
188
|
+
const nodeId = typeof node.id === "string" ? node.id : "";
|
|
189
|
+
if (!nodeId) continue;
|
|
190
|
+
const snapshot = readRunJson(join(runsDir, runId, "nodes", `${nodeId}.json`));
|
|
191
|
+
if (snapshot === null) continue;
|
|
192
|
+
// Only an accepted node: on a rejected one the findings are the
|
|
193
|
+
// rejection itself, and the run already refuses to read as finished.
|
|
194
|
+
if (snapshot.status !== "done" && snapshot.status !== "no-op") continue;
|
|
195
|
+
const gate = /** @type {JsonObject|null|undefined} */ (snapshot.gate);
|
|
196
|
+
const findings = gate && Array.isArray(gate.findings) ? gate.findings : [];
|
|
197
|
+
if (findings.length === 0) continue;
|
|
198
|
+
if (noteText.includes(nodeId)) continue;
|
|
199
|
+
pending.push(`${runId}/${nodeId} (${findings.length} ${findings.length === 1 ? "finding" : "findings"}, ${typeof gate?.maxSeverity === "string" ? gate.maxSeverity : "unknown"})`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return pending.sort();
|
|
203
|
+
}
|
|
204
|
+
|
|
150
205
|
/**
|
|
151
206
|
* The requirement closure a close records: one entry per requirement id the
|
|
152
207
|
* linked runs' contracts declared, correlated only by the identifiers the runs
|
package/src/cli/plan.mjs
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* contested. This file only owns the wire — `src/plan/pipeline.mjs` owns the
|
|
5
5
|
* sequencing and every decision the pipeline makes.
|
|
6
6
|
*/
|
|
7
|
-
import { readFileSync } from "node:fs";
|
|
8
|
-
import { resolve } from "node:path";
|
|
7
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
9
|
import { detachArgv, detachSelf, waitForBootstrap } from "./launch.mjs";
|
|
10
10
|
import { classifyRunProgress } from "../campaign/chain.mjs";
|
|
11
11
|
import { runProgress } from "../engine/supervise.mjs";
|
|
@@ -15,11 +15,22 @@ import { validateFinalVerification, validateSharedVerification } from "../contra
|
|
|
15
15
|
import { colorLevel, statusToken } from "./brand.mjs";
|
|
16
16
|
import { delay } from "../util.mjs";
|
|
17
17
|
import { runPlanningPipeline } from "../plan/pipeline.mjs";
|
|
18
|
-
import { runDirectory } from "../run/paths.mjs";
|
|
18
|
+
import { campaignTree, runDirectory } from "../run/paths.mjs";
|
|
19
19
|
|
|
20
20
|
/** How often a foreground `plan` polls a launched stage's run directory. */
|
|
21
21
|
const DEFAULT_POLL_MS = 1_000;
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* How long a `--detach` launcher stays to see whether the planning process it
|
|
25
|
+
* started is actually up. Long enough to cover the bootstrap work that fails
|
|
26
|
+
* synchronously -- spec read, strict validation, catalogue load, the runtime
|
|
27
|
+
* ask -- and short enough that a launcher is not a supervisor: past this
|
|
28
|
+
* window, a planning run that dies is a running campaign's problem and leaves
|
|
29
|
+
* its evidence in the run directory, not here.
|
|
30
|
+
*/
|
|
31
|
+
const PLAN_BOOTSTRAP_WINDOW_MS = 5_000;
|
|
32
|
+
const PLAN_BOOTSTRAP_POLL_MS = 100;
|
|
33
|
+
|
|
23
34
|
/**
|
|
24
35
|
* `--runtime-defaults worker=<id>,judge=<id>`, either key optional, comma
|
|
25
36
|
* separated. Absent entirely, the pipeline falls through to plain
|
|
@@ -114,7 +125,7 @@ export function loadVerificationSuites(path) {
|
|
|
114
125
|
|
|
115
126
|
/**
|
|
116
127
|
* @param {string} target
|
|
117
|
-
* @param {{campaign?: string, phase?: string, "review-rounds"?: string, "approve-below"?: string, "runtime-defaults"?: string, runtimes?: string, verification?: string, detach?: boolean, json?: boolean}} values
|
|
128
|
+
* @param {{campaign?: string, phase?: string, "review-rounds"?: string, "approve-below"?: string, "runtime-defaults"?: string, runtimes?: string, verification?: string, package?: string, detach?: boolean, json?: boolean}} values
|
|
118
129
|
* @returns {Promise<void>}
|
|
119
130
|
*/
|
|
120
131
|
export async function planCli(target, values) {
|
|
@@ -131,6 +142,7 @@ export async function planCli(target, values) {
|
|
|
131
142
|
const verification = typeof values.verification === "string" && values.verification
|
|
132
143
|
? loadVerificationSuites(values.verification)
|
|
133
144
|
: {};
|
|
145
|
+
const packageMode = packageModeOf(values.package);
|
|
134
146
|
|
|
135
147
|
if (values.detach === true) {
|
|
136
148
|
const argv = ["plan", specPath, "--campaign", campaignId, "--phase", phase, "--review-rounds", String(reviewRounds)];
|
|
@@ -138,35 +150,63 @@ export async function planCli(target, values) {
|
|
|
138
150
|
if (values["runtime-defaults"] !== undefined) argv.push("--runtime-defaults", values["runtime-defaults"]);
|
|
139
151
|
if (typeof values.runtimes === "string" && values.runtimes) argv.push("--runtimes", resolve(values.runtimes));
|
|
140
152
|
if (typeof values.verification === "string" && values.verification) argv.push("--verification", resolve(values.verification));
|
|
153
|
+
if (packageMode !== "implementation") argv.push("--package", packageMode);
|
|
154
|
+
const failurePath = planBootstrapFailurePath(process.cwd(), campaignId, phase);
|
|
155
|
+
mkdirSync(dirname(failurePath), { recursive: true });
|
|
156
|
+
rmSync(failurePath, { force: true });
|
|
141
157
|
const child = detachArgv(argv);
|
|
142
158
|
if (child.pid === undefined) throw new Error("detached plan has no pid");
|
|
159
|
+
// The child's stdio is discarded (detachArgv), so a planning run that dies
|
|
160
|
+
// during bootstrap used to take its own reason with it while the launcher
|
|
161
|
+
// had already printed a pid and exited 0. Measured 2026-09-21 on macOS and
|
|
162
|
+
// Linux the same day: a run died after collecting repo facts and the stderr
|
|
163
|
+
// went with the closed connection. Waiting out a bounded window is the
|
|
164
|
+
// whole check -- a controller still alive past it is up, and one that is
|
|
165
|
+
// not has written why.
|
|
166
|
+
const failure = await watchPlanBootstrap(child, failurePath);
|
|
167
|
+
if (failure) {
|
|
168
|
+
process.stderr.write(`[plan] bootstrap failed · ${failure.error}\n[plan] recorded at ${failurePath}\n`);
|
|
169
|
+
process.exitCode = 1;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
143
172
|
process.stdout.write(`[plan] detached · pid ${child.pid} · ${specPath}\n`);
|
|
144
173
|
return;
|
|
145
174
|
}
|
|
146
175
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
if (
|
|
166
|
-
await
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
176
|
+
// A detached child arrives here with its stdio already discarded, so what
|
|
177
|
+
// it throws reaches nobody unless it is written down first. The record is
|
|
178
|
+
// written on every path, detached or not: a foreground failure that also
|
|
179
|
+
// leaves the file costs nothing and reads the same.
|
|
180
|
+
let result;
|
|
181
|
+
try {
|
|
182
|
+
result = await runPlanningPipeline({
|
|
183
|
+
specPath,
|
|
184
|
+
campaignId,
|
|
185
|
+
phase,
|
|
186
|
+
reviewRounds,
|
|
187
|
+
approveBelow,
|
|
188
|
+
runtimeDefaults,
|
|
189
|
+
runtimes,
|
|
190
|
+
verification,
|
|
191
|
+
packageMode,
|
|
192
|
+
launch: async (contractPath, contract) => {
|
|
193
|
+
const child = detachSelf("run", contractPath);
|
|
194
|
+
if (child.pid === undefined) throw new Error("detached planning run has no pid");
|
|
195
|
+
await waitForBootstrap(runDirectory(contract.cwd, contract.id), child.pid, child);
|
|
196
|
+
},
|
|
197
|
+
wait: async (runDir) => {
|
|
198
|
+
for (;;) {
|
|
199
|
+
const progress = runProgress(runDir);
|
|
200
|
+
const classification = classifyRunProgress(progress);
|
|
201
|
+
if (classification !== "unfinished" && classification !== "waiting") return progress;
|
|
202
|
+
await delay(DEFAULT_POLL_MS);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
} catch (error) {
|
|
207
|
+
writePlanBootstrapFailure(process.cwd(), campaignId, phase, error instanceof Error ? error : new Error(String(error)));
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
170
210
|
|
|
171
211
|
if (values.json === true) {
|
|
172
212
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
@@ -180,3 +220,100 @@ export async function planCli(target, values) {
|
|
|
180
220
|
for (const warning of result.warnings) process.stdout.write(`${statusToken("warn", colorLevel(process.env, process.stdout.isTTY))} ${warning}\n`);
|
|
181
221
|
process.stdout.write(`[plan] ${campaignId} phase ${phase} frozen · approved ${result.approved} · ${result.contractPath}\n`);
|
|
182
222
|
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Where a detached planning run records why it never came up. It sits beside
|
|
226
|
+
* the phase's durable plan artifacts rather than in the disposable scratch
|
|
227
|
+
* tree, and it is derived from campaign and phase alone -- the launcher and
|
|
228
|
+
* the child compute the same path without either having to parse the spec.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} cwd
|
|
231
|
+
* @param {string} campaignId
|
|
232
|
+
* @param {string} phase
|
|
233
|
+
* @returns {string}
|
|
234
|
+
*/
|
|
235
|
+
export function planBootstrapFailurePath(cwd, campaignId, phase) {
|
|
236
|
+
return join(campaignTree(cwd, campaignId), "plans", phase, "bootstrap-failure.json");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Record a detached planning run's bootstrap failure where its launcher can
|
|
241
|
+
* read it. Best effort: a failure to write this must never replace the
|
|
242
|
+
* failure it was describing.
|
|
243
|
+
*
|
|
244
|
+
* @param {string} cwd
|
|
245
|
+
* @param {string} campaignId
|
|
246
|
+
* @param {string} phase
|
|
247
|
+
* @param {Error} error
|
|
248
|
+
* @returns {void}
|
|
249
|
+
*/
|
|
250
|
+
export function writePlanBootstrapFailure(cwd, campaignId, phase, error) {
|
|
251
|
+
try {
|
|
252
|
+
const path = planBootstrapFailurePath(cwd, campaignId, phase);
|
|
253
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
254
|
+
writeFileSync(path, `${JSON.stringify({ at: new Date().toISOString(), pid: process.pid, campaignId, phase, error: error.message }, null, 2)}\n`);
|
|
255
|
+
} catch {
|
|
256
|
+
// Nothing left to do: the caller is already reporting the real failure.
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Watch a freshly detached planning child through its bootstrap window.
|
|
262
|
+
*
|
|
263
|
+
* Returns the recorded failure when the child died inside the window, and
|
|
264
|
+
* null when it is still running at the end of it. A child that exits zero
|
|
265
|
+
* inside the window also reads as no failure: a planning run can legitimately
|
|
266
|
+
* be that fast only by refusing early, and it will have written its own
|
|
267
|
+
* record if it refused.
|
|
268
|
+
*
|
|
269
|
+
* @param {import("node:child_process").ChildProcess} child
|
|
270
|
+
* @param {string} failurePath
|
|
271
|
+
* @param {{windowMs?: number, pollMs?: number}} [options]
|
|
272
|
+
* @returns {Promise<{error: string}|null>}
|
|
273
|
+
*/
|
|
274
|
+
export async function watchPlanBootstrap(child, failurePath, { windowMs = PLAN_BOOTSTRAP_WINDOW_MS, pollMs = PLAN_BOOTSTRAP_POLL_MS } = {}) {
|
|
275
|
+
let exitCode = /** @type {number|null|undefined} */ (undefined);
|
|
276
|
+
let exited = false;
|
|
277
|
+
child.once("exit", (code) => { exited = true; exitCode = code; });
|
|
278
|
+
const deadline = Date.now() + windowMs;
|
|
279
|
+
while (Date.now() < deadline) {
|
|
280
|
+
// Typed checks, not `!== null`: a child object that never carried the
|
|
281
|
+
// property at all would otherwise read as one that has already exited.
|
|
282
|
+
if (exited || typeof child.exitCode === "number" || typeof child.signalCode === "string") {
|
|
283
|
+
const recorded = readPlanBootstrapFailure(failurePath);
|
|
284
|
+
if (recorded) return recorded;
|
|
285
|
+
const code = typeof exitCode === "number" ? exitCode : child.exitCode;
|
|
286
|
+
if (typeof code === "number" && code !== 0) return { error: `the detached planning process exited ${code} without recording a reason` };
|
|
287
|
+
const signal = child.signalCode;
|
|
288
|
+
if (typeof signal === "string") return { error: `the detached planning process was killed by ${signal} without recording a reason` };
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
await delay(pollMs);
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** @param {string} failurePath @returns {{error: string}|null} */
|
|
297
|
+
function readPlanBootstrapFailure(failurePath) {
|
|
298
|
+
try {
|
|
299
|
+
const record = JSON.parse(readFileSync(failurePath, "utf8"));
|
|
300
|
+
return typeof record?.error === "string" ? { error: record.error } : null;
|
|
301
|
+
} catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* `--package implementation|exploratory`. Implementation is the default and
|
|
308
|
+
* the only mode there was: nodes sized by their write set. Exploratory sizes
|
|
309
|
+
* by what a node reads, accepts a one-file write set as the normal shape of a
|
|
310
|
+
* finding, and reports a node whose read surface dwarfs its siblings'.
|
|
311
|
+
*
|
|
312
|
+
* @param {unknown} value
|
|
313
|
+
* @returns {import("../plan/sizing.mjs").PackageMode}
|
|
314
|
+
*/
|
|
315
|
+
export function packageModeOf(value) {
|
|
316
|
+
if (value === undefined) return "implementation";
|
|
317
|
+
if (value === "implementation" || value === "exploratory") return value;
|
|
318
|
+
throw new Error(`--package must be implementation or exploratory: ${String(value)}`);
|
|
319
|
+
}
|
package/src/cli/spec.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { validateSpec } from "../plan/spec.mjs";
|
|
|
14
14
|
/** Flags are scoped to the operation that declares them; all others are rejected. */
|
|
15
15
|
/** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
|
|
16
16
|
const OPERATION_OPTIONS = {
|
|
17
|
-
validate: { "strict-traceability": { type: "boolean" }, json: { type: "boolean" } },
|
|
17
|
+
validate: { "strict-traceability": { type: "boolean" }, "run-proofs": { type: "boolean" }, json: { type: "boolean" } },
|
|
18
18
|
scaffold: { id: { type: "string" } },
|
|
19
19
|
};
|
|
20
20
|
|
|
@@ -63,9 +63,13 @@ export function specCli(args) {
|
|
|
63
63
|
}
|
|
64
64
|
const target = parsed.positionals[0];
|
|
65
65
|
if (!target || parsed.positionals.length > 1) return usage();
|
|
66
|
-
const values = /** @type {{"strict-traceability"?: boolean, json?: boolean, id?: string}} */ (parsed.values);
|
|
66
|
+
const values = /** @type {{"strict-traceability"?: boolean, "run-proofs"?: boolean, json?: boolean, id?: string}} */ (parsed.values);
|
|
67
67
|
if (operation === "validate") {
|
|
68
|
-
validateSpecFile(resolve(target), {
|
|
68
|
+
validateSpecFile(resolve(target), {
|
|
69
|
+
strict: values["strict-traceability"] === true,
|
|
70
|
+
runProofs: values["run-proofs"] === true,
|
|
71
|
+
json: values.json === true,
|
|
72
|
+
});
|
|
69
73
|
return;
|
|
70
74
|
}
|
|
71
75
|
try {
|
|
@@ -80,12 +84,18 @@ export function specCli(args) {
|
|
|
80
84
|
* Validate a spec file and print its class, its overall verdict, and one
|
|
81
85
|
* line per finding. Exits `1` when the verdict is not `ok`.
|
|
82
86
|
*
|
|
87
|
+
* `runProofs` is the only operation here that spawns anything: it runs each
|
|
88
|
+
* requirement's declared proof instead of only checking that one is written
|
|
89
|
+
* down. It is opt-in because running a repository's proofs costs real time,
|
|
90
|
+
* and default-off keeps `spec validate` the deterministic, side-effect-free
|
|
91
|
+
* read it has always been.
|
|
92
|
+
*
|
|
83
93
|
* @param {string} path
|
|
84
|
-
* @param {{strict: boolean, json: boolean}} options
|
|
94
|
+
* @param {{strict: boolean, json: boolean, runProofs?: boolean}} options
|
|
85
95
|
* @returns {SpecValidation}
|
|
86
96
|
*/
|
|
87
|
-
export function validateSpecFile(path, { strict, json }) {
|
|
88
|
-
const result = validateSpec(readFileSync(path, "utf8"), { cwd: process.cwd(), strict });
|
|
97
|
+
export function validateSpecFile(path, { strict, json, runProofs = false }) {
|
|
98
|
+
const result = validateSpec(readFileSync(path, "utf8"), { cwd: process.cwd(), strict, runProofs });
|
|
89
99
|
if (json) {
|
|
90
100
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
91
101
|
} else {
|
|
@@ -112,7 +122,7 @@ export function scaffoldSpec(path, id) {
|
|
|
112
122
|
|
|
113
123
|
/** @returns {void} */
|
|
114
124
|
function usage() {
|
|
115
|
-
process.stderr.write("usage: faberun spec <validate|scaffold> <path> [--strict-traceability] [--json] [--id <value>]\n");
|
|
125
|
+
process.stderr.write("usage: faberun spec <validate|scaffold> <path> [--strict-traceability] [--run-proofs] [--json] [--id <value>]\n");
|
|
116
126
|
process.exitCode = 2;
|
|
117
127
|
}
|
|
118
128
|
|
package/src/cli.mjs
CHANGED
|
@@ -95,3 +95,53 @@ function validateVerificationRef(value, label, options) {
|
|
|
95
95
|
return String(index);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* A `kind: "command"` proof's `ref` runs through a shell (`judge-gate.mjs`'s
|
|
100
|
+
* `proveCommand`), while a task packet's `verification` entries run as argv
|
|
101
|
+
* arrays -- the opposite quoting convention for the same author intent.
|
|
102
|
+
* Measured 2026-09-22: six DoD refs on one campaign wrote
|
|
103
|
+
* `--test-name-pattern=a b c` the way an argv array would take it, the shell
|
|
104
|
+
* split it into three words, and the gate rejected work whose identical
|
|
105
|
+
* command had just passed as verification. Nothing warned the author, because
|
|
106
|
+
* a shell that receives extra bare words after an unquoted flag value does not
|
|
107
|
+
* itself know they were meant to be one argument.
|
|
108
|
+
*
|
|
109
|
+
* This flags the same shape rather than every proof: a node:test filter flag
|
|
110
|
+
* (the flags `declaredTestFilters` in judge-gate.mjs recognizes) whose
|
|
111
|
+
* unquoted value is immediately followed by bare words is the pattern the
|
|
112
|
+
* incident measured, and it is precise enough that a legitimate `ref` rarely
|
|
113
|
+
* has trailing bare words right after such a flag by accident.
|
|
114
|
+
*
|
|
115
|
+
* @param {DefinitionOfDoneItem[]} items
|
|
116
|
+
* @param {number} index
|
|
117
|
+
* @returns {string[]}
|
|
118
|
+
*/
|
|
119
|
+
export function unquotedFilterValueWarnings(items, index) {
|
|
120
|
+
/** @type {string[]} */
|
|
121
|
+
const warnings = [];
|
|
122
|
+
items.forEach((item, itemIndex) => {
|
|
123
|
+
if (item.proof?.kind !== "command") return;
|
|
124
|
+
const tokens = item.proof.ref.split(/\s+/u).filter(Boolean);
|
|
125
|
+
for (const flag of TEST_FILTER_FLAGS) {
|
|
126
|
+
for (let position = 0; position < tokens.length; position++) {
|
|
127
|
+
const prefix = `${flag}=`;
|
|
128
|
+
if (!tokens[position].startsWith(prefix)) continue;
|
|
129
|
+
const value = tokens[position].slice(prefix.length);
|
|
130
|
+
if (/^['"]/u.test(value)) continue;
|
|
131
|
+
let end = position + 1;
|
|
132
|
+
while (end < tokens.length && !tokens[end].startsWith("-")) end++;
|
|
133
|
+
if (end === position + 1) continue;
|
|
134
|
+
const spilled = [value, ...tokens.slice(position + 1, end)].join(" ");
|
|
135
|
+
warnings.push(
|
|
136
|
+
`nodes[${index}] (definitionOfDone[${itemIndex}]): proof.ref's ${flag} value "${spilled}" is unquoted; ` +
|
|
137
|
+
`kind: "command" runs through a shell, unlike taskPacket.verification's argv, so the space splits it into extra words -- quote it as ${flag}="${spilled}"`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
return warnings;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The node:test filter flags a `kind: "command"` proof's shell can split on an unquoted value. */
|
|
146
|
+
const TEST_FILTER_FLAGS = ["--test-name-pattern", "--test-skip-pattern"];
|
|
147
|
+
|
|
@@ -60,6 +60,27 @@ export function sharedVerificationCommands(contract) {
|
|
|
60
60
|
return contract.sharedVerification ?? [];
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The timeout a Definition of Done `command` proof is spawned with. It used
|
|
65
|
+
* to be capped at a hardcoded 120s regardless of what the node's own attempt
|
|
66
|
+
* was given, so a contract that raised `timeoutSec` to run a slow command as
|
|
67
|
+
* `taskPacket.verification` still had the identical command fail its DoD
|
|
68
|
+
* proof on the same run: measured 2026-09-22, six proofs rejected work whose
|
|
69
|
+
* argv had just passed with a `timeoutSec` well past 120s. A command proof
|
|
70
|
+
* gets the same budget the node's own attempt has, because that is the
|
|
71
|
+
* budget the author already reasoned about; nothing here invents a second
|
|
72
|
+
* number for the gate to disagree with. Shared by `dispatch.mjs` (the actual
|
|
73
|
+
* spawn) and `scheduler.mjs` (the freeze-detection budget that must not judge
|
|
74
|
+
* a node frozen before its own gate's timeout has had a chance to fire).
|
|
75
|
+
*
|
|
76
|
+
* @param {{timeoutSec?: number}} node
|
|
77
|
+
* @param {{timeoutSec?: number}} contract
|
|
78
|
+
* @returns {number}
|
|
79
|
+
*/
|
|
80
|
+
export function gateProofTimeoutMs(node, contract) {
|
|
81
|
+
return Math.max(1_000, (node.timeoutSec ?? contract.timeoutSec ?? 60) * 1_000);
|
|
82
|
+
}
|
|
83
|
+
|
|
63
84
|
/**
|
|
64
85
|
* Whether no other node in the contract depends on this one. `finalVerification`
|
|
65
86
|
* is a candidate to run on a phase-terminal node only; a node with a dependant
|
package/src/contract/index.mjs
CHANGED
|
@@ -3,9 +3,9 @@ import { readFileSync, statSync } from "node:fs";
|
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
import { loadTaskPacket, renderWorkerPrompt } from "./task-packet.mjs";
|
|
5
5
|
import { RESERVED_ARTICLES } from "./articles.mjs";
|
|
6
|
-
import { validateDefinitionOfDone } from "./definition-of-done.mjs";
|
|
6
|
+
import { unquotedFilterValueWarnings, validateDefinitionOfDone } from "./definition-of-done.mjs";
|
|
7
7
|
import { validateFinalVerification, validateSharedVerification } from "./final-verification.mjs";
|
|
8
|
-
import { VERIFICATION_LIMITS } from "./verification.mjs";
|
|
8
|
+
import { VERIFICATION_LIMITS, requirementProofWarnings } from "./verification.mjs";
|
|
9
9
|
import {
|
|
10
10
|
validateCapabilityRequirements,
|
|
11
11
|
} from "../harnesses/index.mjs";
|
|
@@ -374,12 +374,24 @@ export function validateContract(raw, contractPath, options = {}) {
|
|
|
374
374
|
const sharedVerification = validateSharedVerification(raw.sharedVerification, "contract.sharedVerification");
|
|
375
375
|
const contractCommands = [...finalVerification ?? [], ...sharedVerification ?? []].map((command) => command.argv.join(" "));
|
|
376
376
|
const contractWrites = new Set(nodes.flatMap((node) => node.taskPacket.writeFiles ?? []));
|
|
377
|
-
const warnings =
|
|
378
|
-
...
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
377
|
+
const warnings = [
|
|
378
|
+
...nodes.flatMap((node, index) => [
|
|
379
|
+
...commandCoverageWarnings(node, index),
|
|
380
|
+
...unquotedFilterValueWarnings(node.definitionOfDone ?? [], index),
|
|
381
|
+
...(persisted ? [] : mirrorCoverageWarnings(node, index, cwd, contractCommands, contractWrites)),
|
|
382
|
+
...(persisted ? [] : unsnapshottedWriteWarnings(node, index, cwd)),
|
|
383
|
+
...(persisted ? [] : writeFileLineBudgetWarnings(node, index, cwd)),
|
|
384
|
+
]),
|
|
385
|
+
// Cross-node by construction: a requirement proven in two nodes is only
|
|
386
|
+
// visible when every node's commands are read together, which is the
|
|
387
|
+
// whole point -- one copy repaired and six left behind is what a per-node
|
|
388
|
+
// read cannot see.
|
|
389
|
+
...requirementProofWarnings([
|
|
390
|
+
...nodes.map((node, index) => ({ id: `nodes[${index}] (${node.id})`, requirementIds: node.requirementIds, commands: node.taskPacket.verification ?? [] })),
|
|
391
|
+
{ id: "contract.sharedVerification", commands: sharedVerification ?? [] },
|
|
392
|
+
{ id: "contract.finalVerification", commands: finalVerification ?? [] },
|
|
393
|
+
]),
|
|
394
|
+
];
|
|
383
395
|
const contract = /** @type {ValidatedContract} */ ({
|
|
384
396
|
...raw,
|
|
385
397
|
schemaVersion: /** @type {number} */ (raw.schemaVersion),
|
|
@@ -42,7 +42,14 @@ export const MUTATION_TIERS = Object.freeze({
|
|
|
42
42
|
/**
|
|
43
43
|
* One declared deterministic check: an argv command run by the controller.
|
|
44
44
|
*
|
|
45
|
-
*
|
|
45
|
+
* `requirementId` names the spec requirement this command is the proof of. It
|
|
46
|
+
* changes nothing about how the command runs; it is what makes a duplicated
|
|
47
|
+
* proof visible. Measured 2026-09-22: one broken command lived in a spec's R3,
|
|
48
|
+
* in its R4 and in seven nodes' verification, and the repair reached one of
|
|
49
|
+
* them — nothing could tell that the other copies had stopped agreeing,
|
|
50
|
+
* because nothing recorded that they were copies of one claim.
|
|
51
|
+
*
|
|
52
|
+
* @typedef {{argv: string[], cwd?: string, timeoutSec?: number, repeat?: number, env?: string[], mutation?: {tier: MutationTier}, requirementId?: string}} VerificationCommand
|
|
46
53
|
*/
|
|
47
54
|
|
|
48
55
|
/**
|
|
@@ -122,7 +129,7 @@ export function validateVerificationCommands(commands, label = "verification") {
|
|
|
122
129
|
function validateVerificationCommand(command, label = "verification command") {
|
|
123
130
|
if (!command || typeof command !== "object" || Array.isArray(command)) throw new TypeError(`${label} must be an argv command object`);
|
|
124
131
|
const record = /** @type {Record<string, unknown>} */ (command);
|
|
125
|
-
const allowed = new Set(["argv", "cwd", "timeoutSec", "repeat", "env", "mutation"]);
|
|
132
|
+
const allowed = new Set(["argv", "cwd", "timeoutSec", "repeat", "env", "mutation", "requirementId"]);
|
|
126
133
|
for (const key of Object.keys(record)) if (!allowed.has(key)) throw new TypeError(`${label} has unexpected field ${key}`);
|
|
127
134
|
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)) {
|
|
128
135
|
throw new TypeError(`${label}.argv must be a non-empty array of strings`);
|
|
@@ -157,10 +164,18 @@ function validateVerificationCommand(command, label = "verification command") {
|
|
|
157
164
|
}
|
|
158
165
|
mutation = { tier: /** @type {MutationTier} */ (mutationRecord.tier) };
|
|
159
166
|
}
|
|
167
|
+
// Bounded exactly like the node-level `requirementIds` it must match against
|
|
168
|
+
// (at most 128 bytes), so the two sides of the claim cannot accept different
|
|
169
|
+
// ids.
|
|
170
|
+
if (record.requirementId !== undefined
|
|
171
|
+
&& (typeof record.requirementId !== "string" || !record.requirementId.trim() || Buffer.byteLength(record.requirementId, "utf8") > 128)) {
|
|
172
|
+
throw new TypeError(`${label}.requirementId must be a requirement id of at most 128 bytes`);
|
|
173
|
+
}
|
|
160
174
|
/** @type {VerificationCommand} */
|
|
161
175
|
const normalized = { argv: [.../** @type {string[]} */ (record.argv)], timeoutSec, repeat, env: [.../** @type {string[]} */ (env)] };
|
|
162
176
|
if (record.cwd !== undefined) normalized.cwd = /** @type {string} */ (record.cwd);
|
|
163
177
|
if (mutation !== undefined) normalized.mutation = mutation;
|
|
178
|
+
if (record.requirementId !== undefined) normalized.requirementId = /** @type {string} */ (record.requirementId);
|
|
164
179
|
return normalized;
|
|
165
180
|
}
|
|
166
181
|
|
|
@@ -199,3 +214,49 @@ export function compactVerification(result) {
|
|
|
199
214
|
return { passed: Boolean(result?.passed), commands };
|
|
200
215
|
}
|
|
201
216
|
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* One proof, one source. A command that declares `requirementId` says it is
|
|
220
|
+
* the proof of that requirement; this reports the two ways such a claim can
|
|
221
|
+
* be false.
|
|
222
|
+
*
|
|
223
|
+
* A claim the node does not carry is a mislabel: the node's own
|
|
224
|
+
* `requirementIds` are what the phase assigned it, and a command proving
|
|
225
|
+
* something outside them is either the wrong id or the wrong node.
|
|
226
|
+
*
|
|
227
|
+
* Copies that stopped agreeing are the measured one. 2026-09-22: a broken
|
|
228
|
+
* command lived in a spec's R3, its R4, and seven nodes' verification, and
|
|
229
|
+
* the repair reached one copy. Nothing could see that the others had drifted,
|
|
230
|
+
* because nothing recorded that they were copies of a single claim. Argv is
|
|
231
|
+
* compared against argv, never a joined string against a shell command: a
|
|
232
|
+
* joined argv loses argument boundaries, which is the same reason a
|
|
233
|
+
* `verification` proof references an index instead of comparing text.
|
|
234
|
+
*
|
|
235
|
+
* @param {Array<{id: string, requirementIds?: string[], commands: VerificationCommand[]}>} owners
|
|
236
|
+
* @returns {string[]}
|
|
237
|
+
*/
|
|
238
|
+
export function requirementProofWarnings(owners) {
|
|
239
|
+
/** @type {string[]} */
|
|
240
|
+
const warnings = [];
|
|
241
|
+
/** @type {Map<string, Array<{owner: string, position: number, argv: string[]}>>} */
|
|
242
|
+
const claims = new Map();
|
|
243
|
+
for (const owner of owners) {
|
|
244
|
+
owner.commands.forEach((command, position) => {
|
|
245
|
+
const requirementId = command.requirementId;
|
|
246
|
+
if (requirementId === undefined) return;
|
|
247
|
+
if (owner.requirementIds !== undefined && !owner.requirementIds.includes(requirementId)) {
|
|
248
|
+
warnings.push(`${owner.id}: verification[${position}] declares requirementId "${requirementId}", which this node does not carry in requirementIds`);
|
|
249
|
+
}
|
|
250
|
+
const claimed = claims.get(requirementId) ?? [];
|
|
251
|
+
claimed.push({ owner: owner.id, position, argv: command.argv });
|
|
252
|
+
claims.set(requirementId, claimed);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
for (const [requirementId, claimed] of claims) {
|
|
256
|
+
const distinct = new Map(claimed.map((claim) => [JSON.stringify(claim.argv), claim]));
|
|
257
|
+
if (distinct.size < 2) continue;
|
|
258
|
+
const listed = [...distinct.values()].map((claim) => `${claim.owner}: verification[${claim.position}] runs ${JSON.stringify(claim.argv)}`).join("; ");
|
|
259
|
+
warnings.push(`requirementId "${requirementId}" is proven by commands that no longer agree: ${listed}`);
|
|
260
|
+
}
|
|
261
|
+
return warnings;
|
|
262
|
+
}
|