faberun 0.28.0 → 0.29.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
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": {
@@ -16,7 +16,7 @@ import { bootstrapAckPath, bootstrapAttemptPath, bootstrapPath, cleanupBootstrap
16
16
  import { bootstrapFailureMatchesChild, bootstrapMatchesChild, processStartToken, readLock, sameProcessStartToken, validBootstrapNonce } from "../run/lock.mjs";
17
17
  import { cleanupBootstrapNonce, discardNodeLessRunDirectory } from "../engine/detach.mjs";
18
18
  import { delay, errorCode } from "../util.mjs";
19
- import { existsSync, readFileSync } from "node:fs";
19
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { join, resolve } from "node:path";
22
22
  import { randomUUID } from "node:crypto";
@@ -232,16 +232,33 @@ export function bootstrapRunDir(command, target) {
232
232
  */
233
233
  export function writeBootstrapFailure(command, target, error) {
234
234
  const runDir = bootstrapRunDir(command, target);
235
- if (!runDir || !existsSync(runDir)) return;
235
+ if (!runDir) return;
236
236
  const nonce = validBootstrapNonce(process.env.FABERUN_BOOTSTRAP_NONCE) ? process.env.FABERUN_BOOTSTRAP_NONCE : null;
237
237
  const failure = { status: "failed", pid: process.pid, processStartToken: processStartToken(process.pid), runDir, nonce, at: new Date().toISOString(), error: error.message };
238
+ const detachedChild = nonce !== null && !process.argv.includes("--detach");
239
+ if (!existsSync(runDir)) {
240
+ // A refusal that happens before the run directory exists — a dirty tree, a
241
+ // base ref that does not resolve — is the one the launcher cannot
242
+ // otherwise learn: the child's stdio is discarded, so all it could say was
243
+ // "failed before readiness for pid N". Measured 2026-09-27 (AP10): a whole
244
+ // `plan` stage died that way over two uncommitted paths. Only a detached
245
+ // child creates the directory, and only to carry this record;
246
+ // `waitForBootstrap` removes a node-less one as it reads it.
247
+ if (!detachedChild) return;
248
+ try {
249
+ mkdirSync(runDir, { recursive: true });
250
+ } catch {
251
+ // The refusal being reported matters more than the directory that would
252
+ // have carried it; without one the launcher falls back to its own message.
253
+ return;
254
+ }
255
+ }
238
256
  // A `run` that names an existing run directory is refused before it writes
239
257
  // anything, and its failure must not be stamped into that run's bootstrap
240
258
  // record. The detached child is the exception: it wrote contract.json and
241
259
  // then died before readiness, and the launcher has no other channel to
242
260
  // learn the controller's error, so it must record that error before it
243
261
  // exits.
244
- const detachedChild = nonce !== null && !process.argv.includes("--detach");
245
262
  if (command === "run" && !detachedChild && existsSync(join(runDir, "contract.json"))) return;
246
263
  /** @type {BootstrapRecord|null} */
247
264
  let current = null;
package/src/cli/plan.mjs CHANGED
@@ -5,8 +5,8 @@
5
5
  * sequencing and every decision the pipeline makes.
6
6
  */
7
7
  import { validateJudgeIndependence } from "../contract/judge-independence.mjs";
8
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
- import { dirname, join, resolve } from "node:path";
8
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { dirname, join, relative, resolve } from "node:path";
10
10
  import { detachArgv, detachSelf, waitForBootstrap } from "./launch.mjs";
11
11
  import { classifyRunProgress } from "../campaign/chain.mjs";
12
12
  import { runProgress } from "../engine/supervise.mjs";
@@ -17,6 +17,7 @@ import { colorLevel, statusToken } from "./brand.mjs";
17
17
  import { delay } from "../util.mjs";
18
18
  import { runPlanningPipeline } from "../plan/pipeline.mjs";
19
19
  import { parseAnswerFlags, resolvePlanningPipeline } from "../plan/resolve.mjs";
20
+ import { abandonedPlanProgress } from "../plan/progress.mjs";
20
21
  import { resolveReviewerList } from "../plan/reviewer.mjs";
21
22
  import { campaignTree, runDirectory } from "../run/paths.mjs";
22
23
  import { readCampaign } from "../campaign/record.mjs";
@@ -112,6 +113,46 @@ export function loadRuntimesCatalogue(path) {
112
113
  return runtimes;
113
114
  }
114
115
 
116
+ /**
117
+ * The campaign's own checked-in planning inputs live beside the spec it
118
+ * already keeps in the repository: `docs/campaigns/<id>/plan-inputs/`. A file
119
+ * that exists there is used as if it had been passed on the command line,
120
+ * because that is what it is for; an absent one changes nothing.
121
+ *
122
+ * Measured 2026-09-27 (AP19): a campaign carried
123
+ * `plan-inputs/runtimes.json` with a raised output ceiling, `plan` fell back
124
+ * to the 8192 default without a word, and the operator found out only after
125
+ * burning rounds on a truncated draft.
126
+ *
127
+ * @param {string} cwd @param {string} campaignId @param {string} file
128
+ * @returns {string}
129
+ */
130
+ function campaignInputPath(cwd, campaignId, file) {
131
+ return join(cwd, "docs", "campaigns", campaignId, "plan-inputs", file);
132
+ }
133
+
134
+ /**
135
+ * The catalogue a `plan` reads runtimes from, and the file it came from when
136
+ * that file is the campaign's own. `--runtimes` wins over the checked-in one:
137
+ * an operator passing a path is overriding, not asking.
138
+ *
139
+ * `source` is returned rather than printed here so a caller decides the
140
+ * wording; `planCli` says it out loud because silence is the whole defect. The
141
+ * path is resolved from the launch cwd, never from the plan's own target, so
142
+ * the same command from a subdirectory reads the same file the campaign does.
143
+ *
144
+ * @param {{cwd: string, campaignId: string, runtimes?: unknown}} options
145
+ * @returns {{runtimes: Record<string, import("../contract/index.mjs").ValidatedRuntime>, source: string|null}}
146
+ */
147
+ export function resolvePlanRuntimes({ cwd, campaignId, runtimes }) {
148
+ if (typeof runtimes === "string" && runtimes) return { runtimes: loadRuntimesCatalogue(runtimes), source: null };
149
+ const campaignRuntimes = campaignInputPath(cwd, campaignId, "runtimes.json");
150
+ if (!existsSync(campaignRuntimes)) {
151
+ return { runtimes: /** @type {Record<string, import("../contract/index.mjs").ValidatedRuntime>} */ (DISCOVERY_RUNTIME_DEFINITIONS), source: null };
152
+ }
153
+ return { runtimes: loadRuntimesCatalogue(campaignRuntimes), source: campaignRuntimes };
154
+ }
155
+
115
156
  /**
116
157
  * A `--verification <path>` catalogue: a JSON object carrying either or both
117
158
  * of the contract's own suite keys, `sharedVerification` and
@@ -160,9 +201,10 @@ export async function planCli(target, values) {
160
201
  // R19: the planner's own reviewer list, never `runtimeDefaults.judge` --
161
202
  // `--reviewers` wins over the machine's own `config.reviewers` default.
162
203
  const reviewers = resolveReviewerList({ reviewers: parseReviewerList(values.reviewers) }, readUserConfig(process.env));
163
- const runtimes = typeof values.runtimes === "string" && values.runtimes
164
- ? loadRuntimesCatalogue(values.runtimes)
165
- : DISCOVERY_RUNTIME_DEFINITIONS;
204
+ const { runtimes, source: runtimesSource } = resolvePlanRuntimes({ cwd: process.cwd(), campaignId, runtimes: values.runtimes });
205
+ // Said out loud because silence is the whole defect: the default and the
206
+ // campaign's own catalogue look identical from the outside.
207
+ if (runtimesSource) process.stdout.write(`[plan] runtimes · ${relative(process.cwd(), runtimesSource)}\n`);
166
208
  const verification = typeof values.verification === "string" && values.verification
167
209
  ? loadVerificationSuites(values.verification)
168
210
  : {};
@@ -171,6 +213,15 @@ export async function planCli(target, values) {
171
213
  ? undefined
172
214
  : validateJudgeIndependence(values["judge-independence"], "--judge-independence");
173
215
 
216
+ // A record with no live process behind it is the only trace a planning run
217
+ // that died without a word leaves. Measured 2026-09-26 (AP4): `--detach` pid
218
+ // 64270 vanished during repo-facts and the phase directory stayed empty, so
219
+ // the next launch is the one place the operator can still be told.
220
+ const abandoned = abandonedPlanProgress(process.cwd(), campaignId, phase);
221
+ if (abandoned) {
222
+ process.stderr.write(`[plan] a previous planning process (pid ${String(abandoned.pid ?? "?")}) stopped during ${String(abandoned.stage ?? "?")} at ${String(abandoned.at ?? "?")} and never reached the end\n`);
223
+ }
224
+
174
225
  if (values.detach === true) {
175
226
  const argv = ["plan", specPath, "--campaign", campaignId, "--phase", phase, "--review-rounds", String(reviewRounds)];
176
227
  if (approveBelow !== undefined) argv.push("--approve-below", approveBelow);
package/src/cli.mjs CHANGED
@@ -579,7 +579,11 @@ function advisoryToken() {
579
579
  function prunedLine(pruned) {
580
580
  if (!pruned.length) return "[prune] no finished, integrated run holds a worktree";
581
581
  const removed = pruned.reduce((sum, run) => sum + run.removed, 0);
582
- return `[prune] released ${removed} worktree${removed === 1 ? "" : "s"} of ${pruned.length} integrated run${pruned.length === 1 ? "" : "s"} · each kept under refs/faberun-archive/`;
582
+ const branches = pruned.reduce((sum, run) => sum + run.branches.length, 0);
583
+ const line = removed === 0
584
+ ? "[prune] an earlier prune already released every worktree of an integrated run"
585
+ : `[prune] released ${removed} worktree${removed === 1 ? "" : "s"} of ${pruned.length} integrated run${pruned.length === 1 ? "" : "s"} · each kept under refs/faberun-archive/`;
586
+ return branches === 0 ? line : `${line} · deleted ${branches} attempt branch${branches === 1 ? "" : "es"} already held by another ref`;
583
587
  }
584
588
 
585
589
  /**
@@ -279,6 +279,61 @@ export function assertInlineScriptProofsParse(nodes) {
279
279
  }
280
280
  }
281
281
 
282
+ /**
283
+ * The quote a command proof's text leaves open, or null when every quote it
284
+ * opens is closed. `kind: "command"` goes through `/bin/sh -c`, so a text with
285
+ * an odd number of `'` or `"` is refused by the shell before the command runs,
286
+ * with `unexpected EOF while looking for matching`. Measured 2026-09-27 (AP13
287
+ * of safe-to-hand-to-friend): the freeze accepted
288
+ * `node --test --test-name-pattern=a stranger's first campaign completes
289
+ * offline test/…` — an apostrophe inside an unquoted test title — the node did
290
+ * the work and passed its own test in 15 s, and both attempts were spent on a
291
+ * command `/bin/sh` never ran.
292
+ *
293
+ * This is a quote scanner, not a shell parser: it reports the two failures a
294
+ * proof can carry without expanding anything (an open quote, or nothing at
295
+ * all), and leaves every other shell construct to the shell. A `$'…'` string
296
+ * and a quote inside a here-document are the constructs it does not model.
297
+ *
298
+ * @param {string} commandText
299
+ * @returns {string|null}
300
+ */
301
+ export function unclosedQuote(commandText) {
302
+ /** @type {string|null} */
303
+ let quote = null;
304
+ let escaped = false;
305
+ for (const character of commandText) {
306
+ if (escaped) { escaped = false; continue; }
307
+ // Outside double quotes a backslash escapes the next character; a
308
+ // backslash inside single quotes is literal, which POSIX also gives it.
309
+ if (quote !== "'" && character === "\\") { escaped = true; continue; }
310
+ if (quote === null && (character === "'" || character === '"')) { quote = character; continue; }
311
+ if (quote === character) quote = null;
312
+ }
313
+ return quote;
314
+ }
315
+
316
+ /**
317
+ * Refuse a contract whose command proof the shell cannot parse, naming the
318
+ * node, the item and the open quote. The freeze and the launch both call it:
319
+ * a command no attempt can run is the same waste as an inline script that
320
+ * cannot parse, and cheaper to refuse than to discover.
321
+ *
322
+ * @param {{id?: unknown, definitionOfDone?: {id?: unknown, proof?: {kind?: unknown, ref?: unknown}}[]}[]} nodes
323
+ * @returns {void}
324
+ */
325
+ export function assertProofCommandsParse(nodes) {
326
+ for (const node of nodes) {
327
+ for (const item of node.definitionOfDone ?? []) {
328
+ if (item.proof?.kind !== "command" || typeof item.proof.ref !== "string") continue;
329
+ const quote = unclosedQuote(item.proof.ref);
330
+ if (quote === null) continue;
331
+ const nodeName = node.id === undefined ? "a node" : `node ${node.id}`;
332
+ throw new TypeError(`${nodeName}, Definition of Done item "${item.id}": the proof command leaves a ${quote} open, so /bin/sh refuses it before it runs ("unexpected EOF while looking for matching \`${quote}'"). Quote the value, as in --test-name-pattern="<title>".`);
333
+ }
334
+ }
335
+ }
336
+
282
337
  /** @param {string} token @returns {boolean} */
283
338
  function isNodeExecutable(token) {
284
339
  const base = (token.split(/[\\/]/u).pop() ?? token).toLowerCase();
@@ -153,7 +153,7 @@ export function validateContract(raw, contractPath, options = {}) {
153
153
  throw new TypeError("contract.runtimes must be an object");
154
154
  }
155
155
  const runtimes = /** @type {Record<string, ValidatedRuntime>} */ ({});
156
- for (const [id, runtime] of Object.entries(rawRuntimes)) runtimes[id] = validateRuntime(id, runtime);
156
+ for (const [id, runtime] of Object.entries(rawRuntimes)) runtimes[id] = validateRuntime(id, runtime, { persisted });
157
157
  // A runtime's fallback is validated against sibling runtimes once every
158
158
  // runtime is known, so declaration order never matters.
159
159
  for (const [id, runtime] of Object.entries(runtimes)) {
@@ -93,9 +93,11 @@ export function routeRuntime(contract, node, role = "worker", event = {}) {
93
93
  /**
94
94
  * @param {string} id
95
95
  * @param {unknown} runtime
96
+ * @param {{persisted?: boolean}} [options] `persisted` is a replay of a contract
97
+ * already accepted at launch; see the vendor rule below.
96
98
  * @returns {ValidatedRuntime}
97
99
  */
98
- export function validateRuntime(id, runtime) {
100
+ export function validateRuntime(id, runtime, options = {}) {
99
101
  requireId(id, `runtime ${id}`);
100
102
  assertObject(runtime, `runtime ${id}`);
101
103
  rejectUnknown(runtime, RUNTIME_FIELDS, `runtime ${id}`);
@@ -103,16 +105,24 @@ export function validateRuntime(id, runtime) {
103
105
  const typedRuntime = /** @type {{harness: string, model: string, vendor?: string, config?: Record<string, unknown>}} */ (runtime);
104
106
  // The canonical provider derived from the route, the model's family, or the
105
107
  // harness default outranks a declared `vendor`: a label that contradicts it
106
- // is refused (R18), and the runtime's vendor becomes the derived provider
107
- // rather than whatever text the contract declared. `replay` and `exec-jsonl`
108
- // derive none — for those the declared vendor stands, as it always has.
108
+ // is refused (R18) at authoring, and the runtime's vendor becomes the derived
109
+ // provider rather than whatever text the contract declared. `replay` and
110
+ // `exec-jsonl` derive none — for those the declared vendor stands, as it
111
+ // always has. A persisted load takes the derived provider without refusing,
112
+ // so a run launched before R18 stays readable and cancelable.
109
113
  const derived = canonicalProvider(typedRuntime);
110
114
  const declared = typeof typedRuntime.vendor === "string" && typedRuntime.vendor.length ? typedRuntime.vendor : undefined;
111
115
  let vendor;
112
116
  if (derived) {
113
- if (declared !== undefined && declared !== derived) {
117
+ if (declared !== undefined && declared !== derived && options.persisted !== true) {
114
118
  throw new TypeError(`runtime ${id} declares vendor ${declared} but ${typedRuntime.harness} ${typedRuntime.model} is provider ${derived}`);
115
119
  }
120
+ // A replay canonicalizes a contradicting label instead of refusing it.
121
+ // Measured 2026-09-27 (AP6): three runs launched before R18 carried
122
+ // `vendor: anthropic-sonnet` against the model claude-sonnet-5, and every
123
+ // reader of their persisted contract — `cancel`, `status`, `report` — threw
124
+ // on a rule those contracts predate, leaving the runs unsuspended forever.
125
+ // The rule still refuses the label where it can be acted on: at authoring.
116
126
  vendor = derived;
117
127
  } else {
118
128
  vendor = resolveVendor(typedRuntime);
@@ -43,12 +43,24 @@ export const SANDBOX_BLOCKED_WRITE = "sandbox_blocked_write";
43
43
  * fences it never wrote, and repeated the copy.
44
44
  */
45
45
  export class WorkerResultSizeError extends TypeError {
46
- /** @param {string} field @param {number} limit */
47
- constructor(field, limit) {
48
- super(`${field} exceeds ${limit} bytes`);
46
+ /**
47
+ * `actual` is the measured byte count, never optional: measured 2026-09-28
48
+ * (AP18 of safe-to-hand-to-friend), a GLM worker returned an `artifacts[0]`
49
+ * of 19,810 bytes against the 16,384-byte ceiling and the node failed with
50
+ * "worker result.artifacts[0] exceeds 16384 bytes" -- the operator could not
51
+ * tell a 20 KiB artifact from a 1 MiB one, and the ceiling reads the same
52
+ * whether the worker overshot by 3 KiB or by ten times.
53
+ *
54
+ * @param {string} field
55
+ * @param {number} limit
56
+ * @param {number} actual
57
+ */
58
+ constructor(field, limit, actual) {
59
+ super(`${field} is ${actual} bytes, over the ${limit}-byte ceiling`);
49
60
  this.name = "WorkerResultSizeError";
50
61
  this.field = field;
51
62
  this.limit = limit;
63
+ this.actual = actual;
52
64
  }
53
65
  }
54
66
 
@@ -73,7 +85,7 @@ export class WorkerResultSizeError extends TypeError {
73
85
  */
74
86
  export function invalidResultRepair(cause) {
75
87
  if (cause instanceof WorkerResultSizeError) {
76
- return `the result breaks a size ceiling: ${cause.field} exceeds ${cause.limit} bytes. Keep every field within its ceiling; a node that delivers through \`output\` sends \`artifacts\` as [] and never copies \`output\` into \`artifacts\`.`;
88
+ return `the result breaks a size ceiling: ${cause.field} is ${cause.actual} bytes, over the ${cause.limit}-byte ceiling. Keep every field within its ceiling; a node that delivers through \`output\` sends \`artifacts\` as [] and never copies \`output\` into \`artifacts\`.`;
77
89
  }
78
90
  return "the entire final message must be exactly the required JSON object: no markdown fences, no prose before or after it. Return it as the only content of the final message.";
79
91
  }
@@ -85,8 +97,9 @@ export function invalidResultRepair(cause) {
85
97
  export function parseWorkerResult(value) {
86
98
  if (typeof value !== "string") throw new TypeError("worker result must be JSON text");
87
99
  const maxRawBytes = RESULT_LIMITS.bytes + RESULT_LIMITS.outputBytes;
88
- if (Buffer.byteLength(value, "utf8") > maxRawBytes) {
89
- throw new WorkerResultSizeError("worker result", maxRawBytes);
100
+ const rawBytes = Buffer.byteLength(value, "utf8");
101
+ if (rawBytes > maxRawBytes) {
102
+ throw new WorkerResultSizeError("worker result", maxRawBytes, rawBytes);
90
103
  }
91
104
  let parsed;
92
105
  try {
@@ -140,8 +153,9 @@ export function validateWorkerResult(value) {
140
153
  let output;
141
154
  if (Object.hasOwn(record, "output") && record.output !== undefined) {
142
155
  assertObject(record.output, "worker result.output");
143
- if (Buffer.byteLength(JSON.stringify(record.output), "utf8") > RESULT_LIMITS.outputBytes) {
144
- throw new WorkerResultSizeError("worker result.output", RESULT_LIMITS.outputBytes);
156
+ const outputBytes = Buffer.byteLength(JSON.stringify(record.output), "utf8");
157
+ if (outputBytes > RESULT_LIMITS.outputBytes) {
158
+ throw new WorkerResultSizeError("worker result.output", RESULT_LIMITS.outputBytes, outputBytes);
145
159
  }
146
160
  output = /** @type {Record<string, unknown>} */ (record.output);
147
161
  }
@@ -155,8 +169,9 @@ export function validateWorkerResult(value) {
155
169
  // `output` is bounded on its own above and kept outside this envelope cap:
156
170
  // it is a discovery node's deliverable, not incidental prose competing with
157
171
  // summary/verification for the same 32 KiB budget.
158
- if (Buffer.byteLength(JSON.stringify(envelope), "utf8") > RESULT_LIMITS.bytes) {
159
- throw new WorkerResultSizeError("worker result", RESULT_LIMITS.bytes);
172
+ const envelopeBytes = Buffer.byteLength(JSON.stringify(envelope), "utf8");
173
+ if (envelopeBytes > RESULT_LIMITS.bytes) {
174
+ throw new WorkerResultSizeError("worker result", RESULT_LIMITS.bytes, envelopeBytes);
160
175
  }
161
176
  return output === undefined ? envelope : { ...envelope, output };
162
177
  }
@@ -206,6 +221,7 @@ function requireList(value, label, maxItems, itemBytes) {
206
221
  if (!Array.isArray(value) || value.length > maxItems) throw new TypeError(`${label} must be an array with at most ${maxItems} items`);
207
222
  for (const [index, item] of value.entries()) {
208
223
  if (typeof item !== "string") throw new TypeError(`${label}[${index}] must be a string`);
209
- if (Buffer.byteLength(item, "utf8") > itemBytes) throw new WorkerResultSizeError(`${label}[${index}]`, itemBytes);
224
+ const bytes = Buffer.byteLength(item, "utf8");
225
+ if (bytes > itemBytes) throw new WorkerResultSizeError(`${label}[${index}]`, itemBytes, bytes);
210
226
  }
211
227
  }
@@ -23,7 +23,7 @@ import { spawn } from "node:child_process";
23
23
  import { boundedGitSync } from "../repo/worktree.mjs";
24
24
  import { tmpdir } from "node:os";
25
25
  import { validateContract } from "../contract/index.mjs";
26
- import { assertInlineScriptProofsParse } from "../contract/definition-of-done.mjs";
26
+ import { assertInlineScriptProofsParse, assertProofCommandsParse } from "../contract/definition-of-done.mjs";
27
27
  import { validateNodeSnapshot } from "../contract/snapshot.mjs";
28
28
  import { compactCost as formatCost } from "../util.mjs";
29
29
  import { runsRoot } from "../run/paths.mjs";
@@ -97,6 +97,9 @@ export async function preflightContract(contractPath, options = {}) {
97
97
  // first dispatch, so a body no attempt could ever make exit 0 refuses the
98
98
  // launch here, naming its node and item, instead of after three paid tries.
99
99
  assertInlineScriptProofsParse(contract.nodes);
100
+ // AP13: a proof command /bin/sh cannot parse never runs, so it refuses here
101
+ // for the same reason an inline script that does not parse does.
102
+ assertProofCommandsParse(contract.nodes);
100
103
  return preflightRuntimes([...reachableRuntimes(contract).values()], { ...options, cwd: contract.cwd });
101
104
  }
102
105
 
@@ -267,6 +267,22 @@ export function judgePrompt(node, workerResult, context = {}) {
267
267
  "Assess every Definition of Done item by its id and cite the id you are addressing in each finding.");
268
268
  }
269
269
 
270
+ /**
271
+ * The one rule the judge has never been given, added after N5 of the
272
+ * `open-source-readiness` campaign (AP20). A requirement that reads "the
273
+ * guard refuses these paths, except the declared ones" enumerates its cases,
274
+ * and a delivery that holds the general shape without covering one of them
275
+ * passes a judge that only checks whether the shape holds. Measured
276
+ * 2026-09-27: the `guards-core` node shipped the guard without the default
277
+ * exclusions its requirement declared, the judge approved, and the operator
278
+ * had to intervene twice on the integration ref for the phase to close.
279
+ *
280
+ * The rule sits with the list it governs, so it is stated exactly when there
281
+ * are judgment items to arbitrate and never when there are none.
282
+ */
283
+ const JUDGE_ENUMERATED_ITEM_RULE =
284
+ "When an item enumerates cases, classes or exclusions (\"A, B and C\", \"every X except Y\"), it is satisfied only when the delivery covers every one of them, checked one by one against the diff: the general shape holding is not coverage. A finding that names such an item must say which of them is missing, and an approval that checked none of them is a protocol failure.";
285
+
270
286
  /**
271
287
  * The Definition of Done checklist for the judge prompt: judgment items the
272
288
  * judge must arbitrate, plus each deterministic item with the controller-run
@@ -283,7 +299,7 @@ function judgeDoDChecklist(items, deterministic) {
283
299
  const parts = [];
284
300
  const judgmentItems = items.filter((item) => item.judgment === true);
285
301
  parts.push(judgmentItems.length
286
- ? `Judgment items — arbitrate only these:\n${judgmentItems.map((item) => `- [${item.id}] ${item.text} (judgment)`).join("\n")}`
302
+ ? `Judgment items — arbitrate only these:\n${judgmentItems.map((item) => `- [${item.id}] ${item.text} (judgment)`).join("\n")}\n${JUDGE_ENUMERATED_ITEM_RULE}`
287
303
  : "No judgment items require arbitration.");
288
304
  const proofItems = items.filter((item) => item.proof !== undefined);
289
305
  if (proofItems.length) {
@@ -759,7 +759,14 @@ export async function driveRun(contract, runDir, states, campaign, lock, sourceI
759
759
  heartbeat.stop();
760
760
  lock.release();
761
761
  }
762
- renderStatusIfChanged(false, null);
762
+ // Forced, and after the lock is released: the change-detected render here
763
+ // was a no-op whenever the last tick had already written the same node
764
+ // states, which is the usual case, so the stored status.json kept the
765
+ // `controller: active` the last tick wrote while the lock was still held.
766
+ // Measured 2026-09-27 (AP12): `safe-to-hand-to-a-friend-phase-2` finished
767
+ // with `state: "active"` and pid 43002, and a watcher waiting for the
768
+ // controller to leave spun for two hours on a pid that no longer existed.
769
+ renderStatusIfChanged(true, null);
763
770
  renderCampaignHandoffSafely(campaign, runsDir, runDir);
764
771
  writeFindingsArtifact(runDir, contract, states);
765
772
  const failed = [...states.values()].filter((state) => state.status !== "done");
@@ -21,6 +21,7 @@ import { assertObject, rejectUnknown, requirePacketHash, requireString } from ".
21
21
  import { writeJsonAtomic, writeTextAtomic } from "../run/store.mjs";
22
22
  import { VERIFICATION_LIMITS } from "../contract/verification.mjs";
23
23
  import { validatePlanPhases } from "./template.mjs";
24
+ import { assertProofCommandsParse } from "../contract/definition-of-done.mjs";
24
25
  import { NODE_VALUE_OPTIONS, assertFilteredProofsNameTheirTest } from "./proof-scope.mjs";
25
26
 
26
27
  /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
@@ -413,6 +414,7 @@ export function freezePlan(plan, { outDir, provenance, phases, spec, humanSteps,
413
414
  try {
414
415
  const validated = validateContract(raw, contractPath);
415
416
  assertFilteredProofsNameTheirTest(validated);
417
+ assertProofCommandsParse(validated.nodes);
416
418
  if (facts) assertTimeoutsCoverMeasured(validated, facts);
417
419
  } catch (error) {
418
420
  rmSync(contractPath, { force: true });
@@ -30,6 +30,7 @@ import { validateJudgeIndependence } from "../contract/judge-independence.mjs";
30
30
  import { firstEligibleReviewer, reviewerProvenanceOf } from "./reviewer.mjs";
31
31
  import { parseSpec, validateSpec } from "./spec.mjs";
32
32
  import { collectRepoFacts } from "./repo-facts.mjs";
33
+ import { clearPlanProgress, writePlanProgress } from "./progress.mjs";
33
34
  import { checkPlanProofs } from "./proof-check.mjs";
34
35
  import { collectHumanSteps } from "./human-step.mjs";
35
36
  import { RISK_TIERS, TASK_KIND_CATALOGUE_FILE, buildPlanningContract, renderTaskKindCatalogue, validatePlanOutput } from "./template.mjs";
@@ -38,6 +39,7 @@ import { resolveRuntimes } from "./routing.mjs";
38
39
  import { contentDigest, freezePlan, writeFrozenPlanRecord } from "./freeze.mjs";
39
40
  import { availabilityOf, fileLineCount, highestOf, modelOf, toContractNode, toSizingNode } from "./pipeline-shape.mjs";
40
41
  import { campaignTree, runDirectory } from "../run/paths.mjs";
42
+ import { assertLaunchBaseClean } from "../repo/source-identity.mjs";
41
43
  import { contestPlan } from "./contest.mjs";
42
44
  import { runReviewRounds } from "./rounds.mjs";
43
45
 
@@ -105,6 +107,27 @@ const APPROVE_BELOW_VALUES = new Set(["standard", "high", "none"]);
105
107
  * @returns {Promise<FrozenPipelineResult|ContestedPipelineResult>}
106
108
  */
107
109
  export async function runPlanningPipeline(options) {
110
+ try {
111
+ return await runPlanningStages(options);
112
+ } finally {
113
+ // The record of a process that is working must not outlive the work. It is
114
+ // cleared here, in the one place every return and every throw passes
115
+ // through, rather than by a caller: `plan` already removed it, but a
116
+ // caller that forgot left a record that makes the next launch report a
117
+ // death that never happened, and the record is worth nothing if it can be
118
+ // wrong in that direction.
119
+ if (typeof options.campaignId === "string" && typeof options.phase === "string") clearPlanProgress(resolve(options.cwd ?? "."), options.campaignId, options.phase);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * The stages themselves, separate from the exported entry point only so that
125
+ * the liveness record is removed on every path out of them.
126
+ *
127
+ * @param {Parameters<typeof runPlanningPipeline>[0]} options
128
+ * @returns {Promise<FrozenPipelineResult|ContestedPipelineResult>}
129
+ */
130
+ async function runPlanningStages(options) {
108
131
  const {
109
132
  specPath, campaignId, phase, runtimes, launch, wait,
110
133
  reviewRounds = 2, runtimeDefaults = {}, reviewers = [], verification = {}, targetedFix = false, judgeIndependence,
@@ -125,6 +148,17 @@ export async function runPlanningPipeline(options) {
125
148
  const campaignPath = campaignTree(cwd, campaignId);
126
149
  const campaign = readCampaign(campaignPath);
127
150
  if (campaign.status !== "active") throw new Error(`campaign is closed: ${campaignId}`);
151
+ // The run this pipeline launches is the last thing it does, and the run
152
+ // refuses a tree whose HEAD is dirty. Asking the same question here, before
153
+ // the first stage, is the difference between a refusal and sixteen minutes
154
+ // spent on repo facts first. Measured 2026-09-27 (AP10): a `plan` stage that
155
+ // died exactly that way reported "refusing to launch against HEAD: the
156
+ // working tree has 2 uncommitted paths" only when the operator re-ran it in
157
+ // the foreground. Nothing this pipeline writes touches the tree
158
+ // `dirtyTreePaths` reads: the plan artifacts live under the campaign tree in
159
+ // the home, and the scratch relay inside the repository is excluded by the
160
+ // same pathspec the launch uses.
161
+ assertLaunchBaseClean(cwd, undefined);
128
162
  refuseUnplannableRuntimes(runtimes, runtimeDefaults, packageMode, judgeIndependence);
129
163
  refusePlanningSilence(await ask(runtimes, runtimeDefaults, cwd), cwd);
130
164
 
@@ -142,10 +176,18 @@ export async function runPlanningPipeline(options) {
142
176
  const plansDir = join(campaignTree(cwd, campaignId), "plans", phase);
143
177
  mkdirSync(plansDir, { recursive: true });
144
178
  const pipelineLog = join(plansDir, "pipeline.jsonl");
179
+ // Every stage line is also the liveness record: the file is the answer to
180
+ // "is this plan still working", and a plan that dies mid-stage leaves the
181
+ // stage it died in as the last thing it ever said. Nothing removes it here;
182
+ // the caller clears it when the pipeline returns, so a record that survives
183
+ // a returned pipeline cannot happen and a record that survives anything
184
+ // else means exactly one thing.
145
185
  /** @param {string} stage @param {Record<string, unknown>} [extra] */
146
- const logStage = (stage, extra = {}) => appendJsonl(pipelineLog, {
147
- type: "plan.stage", at: new Date().toISOString(), campaignId, phase, stage, ...extra,
148
- });
186
+ const logStage = (stage, extra = {}) => {
187
+ const at = new Date().toISOString();
188
+ appendJsonl(pipelineLog, { type: "plan.stage", at, campaignId, phase, stage, ...extra });
189
+ writePlanProgress(cwd, campaignId, phase, { campaignId, phase, at, stage, ...extra });
190
+ };
149
191
 
150
192
  // A discovery node's `readFiles` must resolve inside `cwd` (the task packet's
151
193
  // own containment rule), but `plansDir` lives under the home since R2 and no
@@ -160,7 +202,17 @@ export async function runPlanningPipeline(options) {
160
202
  // the draft stage reads what the planner measured instead of inferring it
161
203
  // from the spec's prose.
162
204
  const parsedSpec = parseSpec(specText);
163
- const repoFacts = collectRepoFacts(cwd, { requirements: parsedSpec.requirements });
205
+ // This stage holds the terminal for minutes, which is why it says what it is
206
+ // about to do before it does it and one line per command as each finishes.
207
+ // Measured 2026-09-25/26 (AP3): five to eight minutes with nothing written
208
+ // anywhere, on six relaunches.
209
+ const repoFacts = collectRepoFacts(cwd, {
210
+ requirements: parsedSpec.requirements,
211
+ onProgress: (event) => {
212
+ if (event.kind === "commands") logStage("repo-facts:start", { commands: event.commands.map((argv) => argv.join(" ")) });
213
+ else logStage("repo-facts:measured", { argv: event.argv, measuredMs: event.measuredMs, index: event.index, total: event.total });
214
+ },
215
+ });
164
216
  // R16: an operator step a requirement's constraints declare, detected once
165
217
  // here so both the freeze below and a caller inspecting the pipeline agree
166
218
  // on the same list.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The liveness record of a planning process: where a `faberun plan` says which
3
+ * stage it is in, and how a later reader tells a process that is still working
4
+ * from one that died without a word.
5
+ *
6
+ * It is separate from `pipeline.mjs` because two layers need it and neither
7
+ * owns it: the pipeline writes it as it advances through stages, and
8
+ * `cli/plan.mjs` reads it before starting and clears it when the pipeline
9
+ * returns. `cli/plan.mjs` already owns the sibling record for a plan that dies
10
+ * before the pipeline is reached (`writePlanBootstrapFailure`); this one
11
+ * covers the death that record cannot, the one that happens minutes later.
12
+ *
13
+ * Measured 2026-09-26 (AP4): a detached `plan` (pid 64270) vanished during the
14
+ * repo-facts stage, leaving `plans/phase-1` empty, with no `pipeline.jsonl` and
15
+ * no bootstrap failure. `watchPlanBootstrap` covers a five-second window, so a
16
+ * death after it recorded nothing at all.
17
+ *
18
+ * A record exists because a detached process's stdio is discarded, which is
19
+ * also why `plan --resolve` writes none: it is never detached, so a death
20
+ * there is visible where it happens and a record would only add a second
21
+ * account of something the operator already watched.
22
+ */
23
+ import { rmSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { pidAlive, processStartToken, sameProcessStartToken } from "../run/lock.mjs";
26
+ import { campaignTree } from "../run/paths.mjs";
27
+ import { readJson, writeJsonAtomic } from "../run/store.mjs";
28
+
29
+ /** @typedef {{pid?: number, processStartToken?: string|null, at?: string, stage?: string, [key: string]: unknown}} PlanProgressRecord */
30
+
31
+ /**
32
+ * Beside the phase's durable plan artifacts rather than in the disposable
33
+ * scratch tree, for the same reason `bootstrap-failure.json` is: a record a
34
+ * later launch has to find cannot live where the next `plan` may wipe.
35
+ *
36
+ * @param {string} cwd @param {string} campaignId @param {string} phase
37
+ * @returns {string}
38
+ */
39
+ export function planProgressPath(cwd, campaignId, phase) {
40
+ return join(campaignTree(cwd, campaignId), "plans", phase, "progress.json");
41
+ }
42
+
43
+ /**
44
+ * Write the record of the process that is doing the work right now. It carries
45
+ * the pid together with the process start token, so a reader an hour later is
46
+ * not fooled by a pid the operating system handed to somebody else.
47
+ *
48
+ * @param {string} cwd @param {string} campaignId @param {string} phase
49
+ * @param {PlanProgressRecord} record
50
+ * @returns {void}
51
+ */
52
+ export function writePlanProgress(cwd, campaignId, phase, record) {
53
+ writeJsonAtomic(planProgressPath(cwd, campaignId, phase), {
54
+ pid: process.pid,
55
+ processStartToken: processStartToken(process.pid),
56
+ at: new Date().toISOString(),
57
+ ...record,
58
+ });
59
+ }
60
+
61
+ /**
62
+ * @param {string} cwd @param {string} campaignId @param {string} phase
63
+ * @returns {PlanProgressRecord|null}
64
+ */
65
+ export function readPlanProgress(cwd, campaignId, phase) {
66
+ try {
67
+ const record = /** @type {PlanProgressRecord} */ (readJson(planProgressPath(cwd, campaignId, phase)));
68
+ return record && typeof record === "object" && !Array.isArray(record) ? record : null;
69
+ } catch {
70
+ // An absent record is the ordinary case: a plan that finished clears it.
71
+ // A record that will not parse is one a dying process left half-written,
72
+ // and there is nothing in it a reader could act on either way.
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The record of a process that stopped where it stood, or null when no plan is
79
+ * running and nothing died. A live pid with the recorded start token is work
80
+ * in progress, however long it has been in the same stage; anything else means
81
+ * the pipeline never reached its own end.
82
+ *
83
+ * @param {string} cwd @param {string} campaignId @param {string} phase
84
+ * @returns {PlanProgressRecord|null}
85
+ */
86
+ export function abandonedPlanProgress(cwd, campaignId, phase) {
87
+ const record = readPlanProgress(cwd, campaignId, phase);
88
+ if (!record) return null;
89
+ const pid = typeof record.pid === "number" ? record.pid : null;
90
+ const token = typeof record.processStartToken === "string" ? record.processStartToken : null;
91
+ if (pid !== null && pidAlive(pid) && sameProcessStartToken(processStartToken(pid), token)) return null;
92
+ return record;
93
+ }
94
+
95
+ /**
96
+ * @param {string} cwd @param {string} campaignId @param {string} phase
97
+ * @returns {void}
98
+ */
99
+ export function clearPlanProgress(cwd, campaignId, phase) {
100
+ rmSync(planProgressPath(cwd, campaignId, phase), { force: true });
101
+ }
@@ -13,7 +13,7 @@
13
13
  import { readFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { shellWords } from "../util.mjs";
16
- import { inlineScriptParseError } from "../contract/definition-of-done.mjs";
16
+ import { inlineScriptParseError, unclosedQuote } from "../contract/definition-of-done.mjs";
17
17
  import { NAME_PATTERN_FLAG } from "./proof-run.mjs";
18
18
 
19
19
  /** @typedef {import("./template.mjs").PlanOutput} PlanOutput */
@@ -150,6 +150,18 @@ export function checkPlanProofs(plan, repoFacts, cwd) {
150
150
  });
151
151
  continue;
152
152
  }
153
+ // AP13: a command the shell cannot parse never runs, so no delivery
154
+ // makes the item pass; the proof text has to change before the freeze.
155
+ const openQuote = unclosedQuote(commandText);
156
+ if (openQuote !== null) {
157
+ findings.push({
158
+ id: `proof-unparseable-${node.id}-${item.id}`,
159
+ severity: "critical",
160
+ nodeId: node.id,
161
+ text: `Node ${node.id}'s Definition of Done item "${item.id}" proves itself with a command that leaves a ${openQuote} open ("${commandText}"), which /bin/sh refuses before it runs, so no delivery can make this proof exit 0. Quote the value, as in --test-name-pattern="<title>".`,
162
+ });
163
+ continue;
164
+ }
153
165
  const nameFlag = NAME_PATTERN_FLAG.exec(commandText);
154
166
  if (nameFlag) {
155
167
  const pattern = nameFlag[1] ?? nameFlag[2] ?? nameFlag[3];
@@ -30,7 +30,7 @@ const TEST_TITLE = /\b(?:test|it|describe)(?:\.\w+)?\(\s*(["'`])((?:\\.|(?!\1).)
30
30
  * @param {string[]} argv
31
31
  * @returns {{nameFilters: Array<{flag: string, value: string}>, paths: string[]}|null}
32
32
  */
33
- function nodeTestShape(argv) {
33
+ export function nodeTestShape(argv) {
34
34
  if (argv[0] !== "node" || !argv.includes("--test")) return null;
35
35
  /** @type {Array<{flag: string, value: string}>} */
36
36
  const nameFilters = [];
@@ -176,6 +176,19 @@ function manifestCandidateCommands(cwd) {
176
176
  return commands;
177
177
  }
178
178
 
179
+ /**
180
+ * How a caller watches this stage work. `commands` arrives once, naming every
181
+ * command about to be timed; `measured` arrives once per command as it
182
+ * finishes. Neither reaches the returned facts: two calls at the same HEAD
183
+ * stay byte-identical because no event is written into them.
184
+ *
185
+ * Measured 2026-09-25/26 (AP3): this stage took five to eight minutes and said
186
+ * nothing while it did, so `faberun plan` was indistinguishable from a hang
187
+ * and the operator checked the process by hand on six relaunches.
188
+ *
189
+ * @typedef {(event: {kind: "commands", commands: string[][]} | {kind: "measured", argv: string[], measuredMs: number, index: number, total: number}) => void} RepoFactsProgress
190
+ */
191
+
179
192
  /**
180
193
  * Time every candidate through `timeVerificationCommands`'s own probe —
181
194
  * real `spawnSync` and `Date.now` by default, or the caller's fake — instead
@@ -187,9 +200,10 @@ function manifestCandidateCommands(cwd) {
187
200
  * @param {string} cwd
188
201
  * @param {{argv: string[]}[]} commands
189
202
  * @param {MeasureProbes} probes
203
+ * @param {RepoFactsProgress} [onProgress]
190
204
  * @returns {{argv: string[], measuredMs: number, eligible: boolean}[]}
191
205
  */
192
- function measureCandidates(cwd, commands, probes) {
206
+ function measureCandidates(cwd, commands, probes, onProgress) {
193
207
  if (commands.length === 0) return [];
194
208
  const now = probes.now ?? (() => Date.now());
195
209
  /** @type {number[]} */
@@ -201,7 +215,22 @@ function measureCandidates(cwd, commands, probes) {
201
215
  taskPacket: { verification: [{ argv: command.argv, timeoutSec: CANDIDATE_TIMEOUT_SEC }] },
202
216
  })),
203
217
  }));
204
- timeVerificationCommands(contract, { ...probes, now: () => { const mark = now(); marks.push(mark); return mark; } });
218
+ timeVerificationCommands(contract, {
219
+ ...probes,
220
+ now: () => {
221
+ const mark = now();
222
+ marks.push(mark);
223
+ // Every command contributes exactly one start mark and one stop mark, in
224
+ // order, so an even mark count means the command at half that count just
225
+ // finished. That is the only progress signal this stage has while it
226
+ // holds the terminal for minutes.
227
+ const stopped = marks.length;
228
+ if (stopped % 2 === 0 && onProgress) {
229
+ onProgress({ kind: "measured", argv: commands[stopped / 2 - 1].argv, measuredMs: mark - marks[stopped - 2], index: stopped / 2, total: commands.length });
230
+ }
231
+ return mark;
232
+ },
233
+ });
205
234
  return commands.map((command, index) => {
206
235
  const measuredMs = marks[index * 2 + 1] - marks[index * 2];
207
236
  return { argv: command.argv, measuredMs, eligible: measuredMs <= ELIGIBLE_MS_CEILING };
@@ -247,7 +276,7 @@ export function measureRequirements(cwd, requirements, probes = {}) {
247
276
 
248
277
  /**
249
278
  * @param {string} cwd
250
- * @param {{measure?: MeasureProbes, maxPaths?: number, requirements?: SpecRequirement[]}} [options]
279
+ * @param {{measure?: MeasureProbes, maxPaths?: number, requirements?: SpecRequirement[], onProgress?: RepoFactsProgress}} [options]
251
280
  * @returns {RepoFacts}
252
281
  */
253
282
  export function collectRepoFacts(cwd, options = {}) {
@@ -255,7 +284,9 @@ export function collectRepoFacts(cwd, options = {}) {
255
284
  const allPaths = listTrackedPaths(cwd);
256
285
  const pathSet = new Set(allPaths);
257
286
  const scripts = readScripts(cwd);
258
- const measured = measureCandidates(cwd, nodeCandidateCommands(scripts, allPaths), options.measure ?? {});
287
+ const candidates = nodeCandidateCommands(scripts, allPaths);
288
+ options.onProgress?.({ kind: "commands", commands: candidates.map((command) => command.argv) });
289
+ const measured = measureCandidates(cwd, candidates, options.measure ?? {}, options.onProgress);
259
290
  const detected = manifestCandidateCommands(cwd);
260
291
  const truncated = allPaths.length > maxPaths;
261
292
  return {
package/src/plan/spec.mjs CHANGED
@@ -6,11 +6,14 @@
6
6
  * schedules, or reaches a provider — this module invokes no model.
7
7
  */
8
8
  import { git } from "../repo/worktree.mjs";
9
+ import { shellWords } from "../util.mjs";
10
+ import { nodeTestShape } from "./proof-scope.mjs";
9
11
  import { proveRequirements } from "./proof-run.mjs";
10
12
 
11
13
  /** @typedef {"command"|"path"|"judgment"} ProofKind */
12
14
  /** @typedef {{kind: ProofKind, ref?: string}} SpecProof */
13
- /** @typedef {{id: string|null, title: string, statement: string|null, proof: SpecProof|null, measure: SpecProof|null, constraints: string|null, line: number}} SpecRequirement */
15
+ /** @typedef {{key: string, line: number}} UnknownBulletKey */
16
+ /** @typedef {{id: string|null, title: string, statement: string|null, proof: SpecProof|null, measure: SpecProof|null, constraints: string|null, unknownBulletKeys: UnknownBulletKey[], line: number}} SpecRequirement */
14
17
  /** @typedef {Record<string, string>} SpecFrontMatter */
15
18
  /** @typedef {{heading: string, body: string, line: number}} SpecSection */
16
19
  /** @typedef {{frontMatter: SpecFrontMatter|null, sections: Map<string, SpecSection>, requirements: SpecRequirement[]}} ParsedSpec */
@@ -98,25 +101,41 @@ function extractSections(lines, startIndex) {
98
101
  * A `- **key:** value` bullet, and any following non-blank, non-bullet line as
99
102
  * its wrapped continuation.
100
103
  *
104
+ * A bullet whose key does not match is *not* a continuation of the one above
105
+ * it: measured 2026-09-27 (AP14 of safe-to-hand-to-a-friend), a
106
+ * `- **esclarecimento (27/09):**` line right after `- **proof:**` was dropped
107
+ * and the lines under it were appended to the proof above, so R8 read as
108
+ * having no proof while the text said otherwise. The dropped key is returned
109
+ * so `validateSpec` can name it instead of letting it vanish.
110
+ *
101
111
  * @param {string[]} lines
102
- * @returns {Map<string, string>}
112
+ * @returns {{bullets: Map<string, string>, unknownKeys: Array<{key: string, offset: number}>}}
103
113
  */
104
114
  function parseBullets(lines) {
105
115
  /** @type {Map<string, string>} */
106
116
  const bullets = new Map();
117
+ /** @type {Array<{key: string, offset: number}>} */
118
+ const unknownKeys = [];
107
119
  let currentKey = null;
108
- for (const line of lines) {
120
+ for (let offset = 0; offset < lines.length; offset += 1) {
121
+ const line = lines[offset];
109
122
  const match = /^-\s+\*\*([a-zA-Z-]+):\*\*\s?(.*)$/u.exec(line);
110
123
  if (match) {
111
124
  currentKey = match[1].toLowerCase();
112
125
  bullets.set(currentKey, match[2].trim());
113
126
  continue;
114
127
  }
128
+ const unrecognized = /^-\s+\*\*(.+?):\*\*\s?/u.exec(line);
129
+ if (unrecognized) {
130
+ unknownKeys.push({ key: unrecognized[1], offset });
131
+ currentKey = null;
132
+ continue;
133
+ }
115
134
  const trimmed = line.trim();
116
135
  if (!trimmed) { currentKey = null; continue; }
117
136
  if (currentKey && !trimmed.startsWith("-")) bullets.set(currentKey, `${bullets.get(currentKey)} ${trimmed}`.trim());
118
137
  }
119
- return bullets;
138
+ return { bullets, unknownKeys };
120
139
  }
121
140
 
122
141
  /**
@@ -137,6 +156,44 @@ function parseProof(raw) {
137
156
  return kind === "judgment" ? { kind } : { kind, ref: match[2].trim() };
138
157
  }
139
158
 
159
+ /**
160
+ * A requirement proof that filters `node:test` by name without naming the file
161
+ * the test lives in, or null. The same shape the frozen contract refuses
162
+ * (`plan/proof-scope.mjs`, AP1 of safe-to-hand-to-a-friend); raised here so a
163
+ * spec author sees it when the spec is written, rather than a plan later.
164
+ * Measured 2026-09-27 (AP8): R1 to R7 of the `safe-to-hand-to-a-friend` spec
165
+ * each wrote `--test-name-pattern="<title>"` with no file, and only the
166
+ * planner's own nodes were refused for it.
167
+ *
168
+ * @param {SpecProof|null} proof
169
+ * @returns {string|null}
170
+ */
171
+ function proofWithoutNamedTestFile(proof) {
172
+ if (proof?.kind !== "command" || typeof proof.ref !== "string") return null;
173
+ const shape = nodeTestShape(shellWords(proof.ref));
174
+ if (!shape || shape.nameFilters.length === 0 || shape.paths.length > 0) return null;
175
+ return proof.ref;
176
+ }
177
+
178
+ /**
179
+ * The one finding `strict` does not promote. Measured 2026-09-28: 105 of the
180
+ * 237 `--test-name-pattern` proofs in the 109 documents under
181
+ * `docs/campaigns/` name no test file, across 20 documents. Every one of them
182
+ * is the record of a run that already happened, and the repository forbids
183
+ * editing that record to satisfy a rule written later, so promoting this to
184
+ * blocking would retroactively refuse twenty specs that were accepted when
185
+ * they were written. The shape is refused where it costs something anyway:
186
+ * `assertFilteredProofsNameTheirTest` (plan/proof-scope.mjs) fails the freeze
187
+ * of any plan that carries it, so a plan cannot be built from a fresh spec
188
+ * with this proof whatever the spec validator says about it.
189
+ *
190
+ * @param {SpecFinding} finding
191
+ * @returns {boolean}
192
+ */
193
+ function promotableUnderStrict(finding) {
194
+ return finding.rule !== "proof-filter-names-no-file";
195
+ }
196
+
140
197
  /**
141
198
  * @param {SpecSection|undefined} section
142
199
  * @returns {SpecRequirement[]}
@@ -154,7 +211,8 @@ function extractRequirements(section) {
154
211
  const blockLine = section.line + i + 1;
155
212
  let end = i + 1;
156
213
  while (end < lines.length && !/^###\s+/u.test(lines[end])) end += 1;
157
- const bullets = parseBullets(lines.slice(i + 1, end));
214
+ const body = lines.slice(i + 1, end);
215
+ const { bullets, unknownKeys } = parseBullets(body);
158
216
  const idMatch = /^(R\d+)\.\s*(.*)$/u.exec(heading);
159
217
  requirements.push({
160
218
  id: idMatch ? idMatch[1] : null,
@@ -163,6 +221,7 @@ function extractRequirements(section) {
163
221
  proof: parseProof(bullets.get("proof")),
164
222
  measure: parseProof(bullets.get("measure")),
165
223
  constraints: bullets.get("constraints") ?? null,
224
+ unknownBulletKeys: unknownKeys.map(({ key, offset }) => ({ key, line: blockLine + offset })),
166
225
  line: blockLine,
167
226
  });
168
227
  i = end;
@@ -318,6 +377,23 @@ export function validateSpec(text, options = {}) {
318
377
  if (!requirement.proof) {
319
378
  findings.push({ rule: "requirement-missing-proof", severity: "advisory", message: `requirement ${requirement.id ?? requirement.title} has no proof`, line: requirement.line });
320
379
  }
380
+ for (const { key, line } of requirement.unknownBulletKeys) {
381
+ findings.push({
382
+ rule: "requirement-unknown-bullet-key",
383
+ severity: "advisory",
384
+ message: `requirement ${requirement.id ?? requirement.title} has a bullet keyed "${key}", which the format does not read: a key is letters and hyphens (statement, proof, measure, constraints), so this bullet and the lines under it are dropped instead of reaching the requirement. Reword the bullet or use one of the four keys.`,
385
+ line,
386
+ });
387
+ }
388
+ const unbacked = proofWithoutNamedTestFile(requirement.proof);
389
+ if (unbacked !== null) {
390
+ findings.push({
391
+ rule: "proof-filter-names-no-file",
392
+ severity: "advisory",
393
+ message: `requirement ${requirement.id ?? requirement.title}'s proof "${unbacked}" filters node:test by name but names no test file: with no path node --test runs every test file in the tree, each file the filter selects nothing in prints a zero plan, and the plan's own freeze refuses the shape. Name the file that holds the test, as in node --test --test-name-pattern="<title>" test/<area>/<file>.test.mjs.`,
394
+ line: requirement.line,
395
+ });
396
+ }
321
397
  }
322
398
  const successCriteria = parsed.sections.get("success criteria");
323
399
  if (successCriteria) findings.push(...baselineColumnFindings(successCriteria));
@@ -340,6 +416,6 @@ export function validateSpec(text, options = {}) {
340
416
  });
341
417
  }
342
418
  }
343
- const graded = findings.map((finding) => (strict ? { ...finding, severity: /** @type {const} */ ("blocking") } : finding));
419
+ const graded = findings.map((finding) => (strict && promotableUnderStrict(finding) ? { ...finding, severity: /** @type {const} */ ("blocking") } : finding));
344
420
  return { class: "structured", ok: !graded.some((finding) => finding.severity === "blocking"), findings: graded };
345
421
  }
@@ -154,6 +154,14 @@ function instructionsFor(kind, inputs) {
154
154
  // tree and read as having measured nothing; two nodes exhausted on it. The
155
155
  // freeze now refuses that shape (proof-scope.mjs); this is the same rule told
156
156
  // to the author, so the draft never writes it.
157
+ // AP11 of safe-to-hand-to-friend, measured 2026-09-27: the plan proved R6 with
158
+ // `! grep -q "\.runs" README.md docs/*.md` while the requirement allowed the
159
+ // mention inside a section labelled as legacy layout, and the node exhausted
160
+ // both attempts on a proof stricter than the statement it proved. The proof's
161
+ // wording is a claim the reviewer can compare with the requirement's own; no
162
+ // deterministic check does, because "stricter" is a reading of two sentences.
163
+ const PROOF_NOT_STRICTER_THAN_REQUIREMENT_RULE = "For every node, compare each command proof with the requirement statement it proves, sentence by sentence: a proof that asserts more than the statement is a finding, because the node is refused for work the requirement allows. A bare `grep` for an absence the statement does not claim, a pattern narrower than the statement's words, or a whole-file scope where the statement names a section are the three shapes this takes.";
164
+
157
165
  const NAMED_TEST_FILE_RULE = 'A command that filters node:test by name (--test-name-pattern) names the test file it selects from, as in node --test --test-name-pattern="<exact test title>" test/<area>/<file>.test.mjs: with no path node --test runs every test file in the tree and the freeze refuses it. A requirement proof the spec writes without a file gains the file of the test that carries that title.';
158
166
 
159
167
  // The rule every planned packet is held to at freeze time, worded from
@@ -198,6 +206,7 @@ const INSTRUCTIONS = Object.freeze({
198
206
  ],
199
207
  review: [
200
208
  "You are given only the spec, the repository facts, and the plan under review; you have not seen how the plan was produced or any reasoning behind it. Review the artefact alone.",
209
+ PROOF_NOT_STRICTER_THAN_REQUIREMENT_RULE,
201
210
  `Return exactly one worker-result JSON object. Put your findings in output.findings as ${FINDINGS_SHAPE} and nothing else in output.`,
202
211
  "severity must be one of critical, major, minor. Every finding's nodeId must name a node id that actually appears in the plan under review.",
203
212
  ],
@@ -13,9 +13,9 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
13
13
  import { SETTLED } from "../engine/prompts.mjs";
14
14
  import { describeRuns } from "../run/disk-gc.mjs";
15
15
  import { worktreeRoot } from "../run/paths.mjs";
16
- import { git, gitHead, releaseRunWorktrees, runRefName } from "./worktree.mjs";
16
+ import { git, gitHead, releaseAttemptBranches, releaseRunWorktrees, runRefName } from "./worktree.mjs";
17
17
 
18
- /** @typedef {{runId: string, removed: number, archived: string[]}} PrunedRun */
18
+ /** @typedef {{runId: string, removed: number, archived: string[], branches: string[]}} PrunedRun */
19
19
 
20
20
  /**
21
21
  * Is the run's integrated head already on a branch of the operator's? An
@@ -48,7 +48,9 @@ function allNodesSettled(runDir) {
48
48
  * nodes are all terminal, and whose run ref a local branch already contains.
49
49
  * `releaseRunWorktrees` keeps each attempt's HEAD and uncommitted delta under
50
50
  * `refs/faberun-archive/`, so a released attempt can still be recovered.
51
- * `parked` also takes a run with a blocked or exhausted node: only on the
51
+ * The attempt branches of the same run go with them (AP21): a released
52
+ * worktree deletes only the branch it had checked out, so a run that tried
53
+ * three times kept three. `parked` also takes a run with a blocked or exhausted node: only on the
52
54
  * operator's word, because a resume that re-judges an attempt reads its
53
55
  * worktree, and only the operator knows the run was superseded.
54
56
  *
@@ -64,9 +66,15 @@ export function pruneIntegratedRunWorktrees(repo, runsDir, options = {}) {
64
66
  if (run.hasActiveController) continue;
65
67
  if (!run.allNodesTerminal && !(options.parked === true && allNodesSettled(run.path))) continue;
66
68
  const runId = basename(run.path);
67
- if (!existsSync(worktreeRoot(join(runsDir, runId), runId))) continue;
68
69
  if (!runRefLanded(repo, runId)) continue;
69
- pruned.push({ runId, ...releaseRunWorktrees(repo, join(runsDir, runId), runId) });
70
+ // The branch sweep is not gated on the worktree root: the run whose
71
+ // worktrees an earlier prune already released is exactly the run whose
72
+ // attempt branches are still there (AP21).
73
+ const runDir = join(runsDir, runId);
74
+ const released = existsSync(worktreeRoot(runDir, runId)) ? releaseRunWorktrees(repo, runDir, runId) : { removed: 0, archived: [] };
75
+ const branches = releaseAttemptBranches(repo, runId);
76
+ if (released.removed === 0 && branches.length === 0) continue;
77
+ pruned.push({ runId, ...released, branches });
70
78
  }
71
79
  return pruned;
72
80
  }
@@ -482,6 +482,60 @@ export function attemptWorkspace(state) {
482
482
  return path && state?.worktree?.status !== "removed" && existsSync(path) ? path : null;
483
483
  }
484
484
 
485
+ /**
486
+ * Delete the attempt branches of `runId` whose commit another ref already
487
+ * holds: the run's own `refs/faberun/<run>/…`, an archive ref written when a
488
+ * worktree was released, or any branch of the operator's. Measured 2026-09-28
489
+ * (AP21 of safe-to-hand-to-friend): `prune --parked` released 20 worktrees and
490
+ * left 133 branches `faberun/<run>/<node>/<n>` behind, every one of them
491
+ * already reachable from one of those refs; releasing a worktree deletes only
492
+ * the branch it had checked out.
493
+ *
494
+ * A branch nothing else holds is never deleted: an attempt that exists only as
495
+ * its own branch is the only copy of that work. Attempt branches are not
496
+ * keepers for each other, so the rule is about the run, not about whichever
497
+ * attempt happens to be newest.
498
+ *
499
+ * @param {string} repo
500
+ * @param {string} runId
501
+ * @returns {string[]} the branch names deleted, without `refs/heads/`
502
+ */
503
+ export function releaseAttemptBranches(repo, runId) {
504
+ const prefix = `refs/heads/faberun/${runId}/`;
505
+ /** @type {Array<{ref: string, commit: string}>} */
506
+ const attempts = git(repo, ["for-each-ref", "--format=%(refname) %(objectname)", "refs/heads/faberun/"])
507
+ .split("\n")
508
+ .map((line) => line.trim())
509
+ .filter(Boolean)
510
+ .map((line) => {
511
+ const [ref, commit] = line.split(" ");
512
+ return { ref: ref ?? "", commit: commit ?? "" };
513
+ })
514
+ .filter(({ ref }) => ref.startsWith(prefix));
515
+ if (attempts.length === 0) return [];
516
+ const keepers = git(repo, ["for-each-ref", "--format=%(refname)", "refs/heads", "refs/faberun", "refs/faberun-archive"])
517
+ .split("\n")
518
+ .map((line) => line.trim())
519
+ .filter((line) => line && !line.startsWith("refs/heads/faberun/"));
520
+ /** @type {string[]} */
521
+ const deleted = [];
522
+ for (const { ref, commit } of attempts) {
523
+ const held = keepers.some((keeper) => {
524
+ try {
525
+ git(repo, ["merge-base", "--is-ancestor", commit, keeper]);
526
+ return true;
527
+ } catch {
528
+ // Not an ancestor of this keeper; the next one may still hold it.
529
+ return false;
530
+ }
531
+ });
532
+ if (!held) continue;
533
+ deleteRef(repo, ref);
534
+ deleted.push(ref.slice("refs/heads/".length));
535
+ }
536
+ return deleted;
537
+ }
538
+
485
539
  /**
486
540
  * Release every attempt worktree a finished run left behind, keeping what it
487
541
  * held. Measured 2026-09-24: attempts that were never accepted (a failed or