faberun 0.21.0 → 0.22.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.21.0",
3
+ "version": "0.22.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": {
@@ -45,22 +45,22 @@ attempt's sealed sha, the same continuation rule as any other retry.
45
45
  One controller drives a run, holding `<run-dir>/controller.lock`: `{pid,
46
46
  processStartToken, startedAt, hostname}`. Acquisition is an exclusive create
47
47
  with no TTL. A contender treats the lock as stale only once it can prove the
48
- holder dead — the pid is gone, or its process start token no longer matches
49
- (pid recycled); anything less is `controller_active` and it exits untouched.
50
- Takeover renames the lock aside, re-checks the captured record is stale, then
51
- installs its own; a capture that turns out live is handed back. Worker/judge/
48
+ holder dead — pid gone, or its start token no longer matches (pid recycled);
49
+ otherwise it exits `controller_active`, untouched. Takeover renames the lock
50
+ aside and re-checks it is stale before installing its own. Worker/judge/
52
51
  verification children run detached in their own process group, so before
53
52
  dispatching new work `resume`'s recovery pass terminates (`SIGTERM` then
54
53
  `SIGKILL`, same as `cancel`) every invocation recorded for a `running` node —
55
54
  unless it is still inside its deadline, when it is adopted and its result read.
56
- `cancel <run-dir>` signals a live controller first, so its own takeover never
57
- waits on an expiry.
55
+ `cancel` signals a live controller first, so its takeover never waits.
58
56
 
59
57
  `supervise <run-dir> [--detach] [--interval <sec>]` is the watchdog above that.
60
58
  It holds no lock and writes no state: every interval (default 30s) it launches
61
59
  `resume --detach` when a node is unfinished and no controller is live, exits 0
62
60
  once all are terminal, and stops after three failed launches. An empty run
63
- directory is never resumed it has not proved it needs to be.
61
+ directory is never resumed. A detached controller outlives its launcher,
62
+ not the session scope (cgroup) holding it: run long work under `tmux`,
63
+ `systemd-run` or the seat.
64
64
 
65
65
  ## Runtime discovery
66
66
 
@@ -42,6 +42,7 @@ import { pidAlive, processStartToken } from "../run/lock.mjs";
42
42
  import { delay, errorCode, errorMessage } from "../util.mjs";
43
43
  import { writeJsonAtomic } from "../run/store.mjs";
44
44
  import { runDirectory } from "../run/paths.mjs";
45
+ import { listNodeSnapshots, readNodeSnapshot } from "../run/node-store.mjs";
45
46
  import { gitArguments, killTarget } from "../host/platform.mjs";
46
47
 
47
48
  /** @typedef {import("../contract/index.mjs").ControllerIdentity} ControllerIdentity */
@@ -561,12 +562,14 @@ export async function driveCampaignChain(campaignPath, options = {}) {
561
562
  try {
562
563
  await launch(entry.path, { baseRef, controllerIdentity, runDir, contract });
563
564
  } catch (error) {
565
+ const stranded = strandedRunNote(runDir);
564
566
  return park({
565
567
  code: errorCode(error) ?? "launch_failed",
566
- message: errorMessage(error),
568
+ message: stranded ? `${errorMessage(error)}; ${stranded}` : errorMessage(error),
567
569
  contractPath: entry.path,
568
570
  contractId: id,
569
571
  runId: id,
572
+ ...(stranded ? { resume: `resume ${runDir}` } : {}),
570
573
  });
571
574
  }
572
575
  heartbeat.progress();
@@ -578,3 +581,29 @@ export async function driveCampaignChain(campaignPath, options = {}) {
578
581
  if (acquired) releaseCoordinatorLock(campaignPath, acquired.lock);
579
582
  }
580
583
  }
584
+
585
+ /**
586
+ * What a launch that died before readiness left on disk. RM-053, measured on
587
+ * `rec-audit-remediation`: the controller died with the launcher's cgroup,
588
+ * the park said only `detached bootstrap did not become ready`, and the run
589
+ * directory it left, every node pending, finished under a durable `resume`.
590
+ *
591
+ * @param {string} runDir
592
+ * @returns {string|null}
593
+ */
594
+ function strandedRunNote(runDir) {
595
+ if (!runDir || !existsSync(runDir)) return null;
596
+ /** @type {Map<string, number>} */
597
+ const counts = new Map();
598
+ for (const name of listNodeSnapshots(runDir)) {
599
+ let status = "unreadable";
600
+ try {
601
+ status = String(/** @type {{status?: unknown}} */ (readNodeSnapshot(runDir, name.replace(/\.json$/u, ""))).status ?? "unknown");
602
+ } catch {
603
+ // A snapshot caught mid-write is counted as unreadable, not dropped.
604
+ }
605
+ counts.set(status, (counts.get(status) ?? 0) + 1);
606
+ }
607
+ const nodes = [...counts].sort(([left], [right]) => left.localeCompare(right)).map(([status, count]) => `${count} ${status}`).join(" · ");
608
+ return `run directory ${runDir} exists (nodes: ${nodes || "none written yet"}); \`faberun resume ${runDir}\` completes it, under tmux, systemd-run or faberun seat so it outlives this shell`;
609
+ }
@@ -13,9 +13,9 @@ import { DISCOVERY_RUNTIME_DEFINITIONS } from "../engine/runtime-discovery.mjs";
13
13
  import { stableJson } from "../util.mjs";
14
14
  import { assertObject, boundedString, nonNegativeInteger, nonNegativeNumber, positiveInteger, positiveNumber, rejectUnknown, requireId, requireString } from "./assert.mjs";
15
15
  import { validateMetadata } from "./schema-version.mjs";
16
- import { assertRuntimeExecutesCommands, requireRuntime, validateRuntime } from "./runtime.mjs";
16
+ import { assertRuntimeExecutesCommands, judgeWriteWarnings, requireRuntime, validateRuntime } from "./runtime.mjs";
17
17
  import { validateSourceIdentity } from "../repo/source-identity.mjs";
18
- import { commandCoverageWarnings, mirrorCoverageWarnings, unsnapshottedWriteWarnings } from "../repo/declared-paths.mjs";
18
+ import { commandCoverageWarnings, ignoreSourceWriteWarnings, mirrorCoverageWarnings, unsnapshottedWriteWarnings } from "../repo/declared-paths.mjs";
19
19
  import { crossNodeScopeFindings, scopeClosureFindings } from "../repo/scope-closure.mjs";
20
20
 
21
21
  export { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../harnesses/index.mjs";
@@ -98,7 +98,7 @@ const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
98
98
  /** @typedef {{history: RoutingHistoryEntry[], currentOverride: RoutingOverride|null, assignments?: RuntimeAssignments, availability?: Record<string, RuntimeAvailability>, tierExhaustion?: TierExhaustion, tierExhaustionCycle?: number}} RoutingState */
99
99
  /** @typedef {{revision?: number, heartbeatCount: number, dryHeartbeatCount: number, progressSignature?: string|null, lastHeartbeatAt: string|null, lastProgressAt: string|null, nextCheckAt?: string|null}} ProgressState */
100
100
  /** @typedef {{status: "unassigned"|"provisioning"|"ready"|"failed"|"removed", path: string|null, branch: string|null, commit: string|null, baseSha?: string|null, sealedSha?: string|null, sealError?: string|null, previousAttempt?: number|null}} WorktreeState */
101
- /** @typedef {{schemaVersion: number, contractVersion: string, id: string, type: string, sourceIdentity: SourceIdentity, packetHash: string, requirementIds?: string[], status: NodeStatus, phase: NodePhase, attempt: number, revisions: number, judgeFailures?: number, review?: ("none"|"advisory"|"blocking"), runtime: RuntimeSnapshot|null, blockedBy: string[], startedAt: string|null, updatedAt: string, result: unknown, gate: GateResult|null, error: SnapshotError|null, usage?: Usage, costUsd?: number, routing?: RoutingState|null, progress?: ProgressState|null, worktree?: WorktreeState|null, integratedHead?: string|null, invocations?: Invocation[], executionOverrides?: ExecutionOverride[], verification?: VerificationState|null, scope?: BoundedScope|null, scopeFindings?: ScopeFindings|null, previousAttempt?: string, sessionPolicy?: {forceFresh?: boolean}|null, declaredReadBytes?: number|null}} NodeSnapshot */
101
+ /** @typedef {{schemaVersion: number, contractVersion: string, id: string, type: string, sourceIdentity: SourceIdentity, packetHash: string, requirementIds?: string[], status: NodeStatus, phase: NodePhase, attempt: number, revisions: number, judgeFailures?: number, review?: ("none"|"advisory"|"blocking"), runtime: RuntimeSnapshot|null, blockedBy: string[], startedAt: string|null, updatedAt: string, result: unknown, gate: GateResult|null, error: SnapshotError|null, usage?: Usage, costUsd?: number, routing?: RoutingState|null, progress?: ProgressState|null, worktree?: WorktreeState|null, integratedHead?: string|null, invocations?: Invocation[], executionOverrides?: ExecutionOverride[], verification?: VerificationState|null, scope?: BoundedScope|null, scopeFindings?: ScopeFindings|null, verificationArtifacts?: string[], previousAttempt?: string, sessionPolicy?: {forceFresh?: boolean}|null, declaredReadBytes?: number|null}} NodeSnapshot */
102
102
  /** @typedef {{path: string, sha: string}} ControllerIdentity */
103
103
  /** @typedef {{schemaVersion: number, contractVersion: string, pid: number, processStartToken: string|null, startedAt: string, sourceIdentity: SourceIdentity, controllerIdentity?: ControllerIdentity, integrationRef?: string, identityWarnings?: string[], relaunchCount?: number, lastRelaunchProgressAt?: string|null, attention?: {code: string, message: string, at: string}|null, contractDigest?: string, scopeDecision?: ScopeDecision, autoRetries?: Record<string, {code: string, at: string}>}} RunMetadata */
104
104
  /** @typedef {{at: string, base: string|null, dirtyTreeFingerprint: string|null}} ScopeDecision */
@@ -380,8 +380,10 @@ export function validateContract(raw, contractPath, options = {}) {
380
380
  ...unquotedFilterValueWarnings(node.definitionOfDone ?? [], index),
381
381
  ...(persisted ? [] : mirrorCoverageWarnings(node, index, cwd, contractCommands, contractWrites)),
382
382
  ...(persisted ? [] : unsnapshottedWriteWarnings(node, index, cwd)),
383
+ ...(persisted ? [] : ignoreSourceWriteWarnings(node, index)),
383
384
  ...(persisted ? [] : writeFileLineBudgetWarnings(node, index, cwd)),
384
385
  ]),
386
+ ...judgeWriteWarnings(runtimes, defaults, nodes),
385
387
  // Cross-node by construction: a requirement proven in two nodes is only
386
388
  // visible when every node's commands are read together, which is the
387
389
  // whole point -- one copy repaired and six left behind is what a per-node
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { assertObject, nonNegativeNumber, positiveInteger, positiveNumber, rejectUnknown, requireId, requireString, requireStringArray, requireTimestamp } from "./assert.mjs";
13
13
  import { composeAssignments } from "../engine/runtime-discovery.mjs";
14
- import { harnessCapabilities, resolvePermissionExecution, resolveVendor, validateCapabilityRequirements } from "../harnesses/index.mjs";
14
+ import { harnessCapabilities, resolvePermissionExecution, resolveVendor, validateCapabilityRequirements, writesWorkspace } from "../harnesses/index.mjs";
15
15
  import { stableJson } from "../util.mjs";
16
16
  /** @typedef {import("./index.mjs").NodeStatus} NodeStatus */
17
17
  /** @typedef {import("../engine/runtime-discovery.mjs").RuntimeAvailability} RuntimeAvailability */
@@ -212,6 +212,27 @@ export function assertRuntimeExecutesCommands(runtimes, runtimeId, index, nodeId
212
212
  `nodes[${index}] (${nodeId}) has verification but ${label} ${runtimeId} uses ${execution.field}=${execution.mode}; ${runtime.harness} executes commands only in ${execution.executingModes.join(", ")}`,
213
213
  );
214
214
  }
215
+ /**
216
+ * A judge runtime that declares a writing mode its harness offers a read-only
217
+ * alternative to. The verdict reaches the gate without a write (RM-058), and
218
+ * `judge_protocol` blocks a judge that writes anyway, so the grant buys
219
+ * nothing but the chance to write where the judge should not.
220
+ *
221
+ * @param {Record<string, ValidatedRuntime>} runtimes
222
+ * @param {{judge?: string}} defaults
223
+ * @param {{gate?: {runtime?: string}|false|null}[]} nodes
224
+ * @returns {string[]}
225
+ */
226
+ export function judgeWriteWarnings(runtimes, defaults, nodes) {
227
+ const judges = new Set([defaults.judge, ...nodes.map((node) => (node.gate ? node.gate.runtime : undefined))].filter((id) => typeof id === "string"));
228
+ return [...judges].flatMap((id) => {
229
+ const runtime = runtimes[/** @type {string} */ (id)];
230
+ const execution = resolvePermissionExecution(runtime);
231
+ if (!execution.field || runtime[execution.field] === undefined || !writesWorkspace(runtime)) return [];
232
+ if (writesWorkspace({ ...runtime, [execution.field]: "read-only" })) return [];
233
+ return [`judge runtime ${id} declares ${execution.field} ${execution.mode}; a judge's verdict reaches the gate without writing, so declare ${execution.field} read-only`];
234
+ });
235
+ }
215
236
  /**
216
237
  * @param {unknown} value
217
238
  * @param {string} label
@@ -35,6 +35,18 @@ export function scopeFindingsNote(scopeFindings) {
35
35
  return `scope: ${count} unexpected path${count === 1 ? "" : "s"}`;
36
36
  }
37
37
 
38
+ /**
39
+ * The `verification_artifact` finding: paths the controller's verification
40
+ * left untracked, which the seal kept out of the integration.
41
+ *
42
+ * @param {string[]|null|undefined} paths
43
+ * @returns {string|null}
44
+ */
45
+ export function verificationArtifactsNote(paths) {
46
+ if (!paths?.length) return null;
47
+ return `verification_artifact: ${paths.length} path${paths.length === 1 ? "" : "s"} left unsealed (${paths.slice(0, 3).join(", ")})`;
48
+ }
49
+
38
50
  /**
39
51
  * @param {{unexpectedPaths: string[]}|null|undefined} scopeFindings
40
52
  * @returns {string}
@@ -129,7 +129,7 @@ export function validateNodeSnapshot(value, expectedNode = null) {
129
129
  "schemaVersion", "contractVersion", "id", "type", "sourceIdentity", "packetHash", "status", "phase",
130
130
  "attempt", "revisions", "judgeFailures", "requirementIds", "runtime", "blockedBy", "startedAt", "updatedAt", "result", "gate", "error", "usage",
131
131
  "costUsd", "routing", "progress", "worktree", "invocations", "executionOverrides", "verification", "scope",
132
- "scopeFindings", "review", "previousAttempt", "sessionPolicy", "integratedHead", "declaredReadBytes",
132
+ "scopeFindings", "verificationArtifacts", "review", "previousAttempt", "sessionPolicy", "integratedHead", "declaredReadBytes",
133
133
  ]), "node snapshot");
134
134
  validateMetadata(value, "node snapshot");
135
135
  requireId(value.id, "node snapshot.id");
@@ -184,6 +184,7 @@ export function validateNodeSnapshot(value, expectedNode = null) {
184
184
  if (value.verification !== undefined && value.verification !== null) validateVerificationSnapshot(value.verification);
185
185
  if (value.scope !== undefined && value.scope !== null) validateScopeSnapshot(value.scope);
186
186
  if (value.scopeFindings !== undefined && value.scopeFindings !== null) validateScopeFindings(value.scopeFindings);
187
+ if (value.verificationArtifacts !== undefined) validatePathList(value.verificationArtifacts, "node snapshot.verificationArtifacts");
187
188
  // The session policy a rejection decision leaves for the dispatch that will
188
189
  // run the retry. It is persisted because the decision can hand the node back
189
190
  // to the scheduler, whose own `startWorker` call carries no argument; without
@@ -611,12 +612,22 @@ function validateScopeSnapshot(value) {
611
612
  function validateScopeFindings(value) {
612
613
  assertObject(value, "node snapshot.scopeFindings");
613
614
  rejectUnknown(value, new Set(["unexpectedPaths"]), "node snapshot.scopeFindings");
614
- const paths = /** @type {unknown[]} */ (value.unexpectedPaths);
615
- if (!Array.isArray(value.unexpectedPaths) || paths.length > MAX_SCOPE_FINDING_PATHS || paths.some((path) => typeof path !== "string")) {
616
- throw new TypeError("node snapshot.scopeFindings.unexpectedPaths is invalid");
615
+ validatePathList(value.unexpectedPaths, "node snapshot.scopeFindings.unexpectedPaths");
616
+ }
617
+ /**
618
+ * A bounded list of workspace paths: the shape of a scope finding and of the
619
+ * paths a verification left behind (`verificationArtifacts`).
620
+ *
621
+ * @param {unknown} value
622
+ * @param {string} label
623
+ */
624
+ function validatePathList(value, label) {
625
+ const paths = /** @type {unknown[]} */ (value);
626
+ if (!Array.isArray(value) || paths.length > MAX_SCOPE_FINDING_PATHS || paths.some((path) => typeof path !== "string")) {
627
+ throw new TypeError(`${label} is invalid`);
617
628
  }
618
629
  if (paths.some((path) => Buffer.byteLength(/** @type {string} */ (path), "utf8") > 1024)) {
619
- throw new TypeError("node snapshot.scopeFindings.unexpectedPaths contains an oversized path");
630
+ throw new TypeError(`${label} contains an oversized path`);
620
631
  }
621
632
  }
622
633
  /**
@@ -34,7 +34,7 @@ import { invocationCost, invocationUsage } from "../run/usage.mjs";
34
34
  import { logPaths, startProcess } from "./process.mjs";
35
35
  import { readBoundedTail } from "./transcript.mjs";
36
36
  import { mkdirSync, statSync } from "node:fs";
37
- import { READ_BYTE_LIMIT, READ_LINE_LIMIT, harnessCapabilities, normalizeProviderResult } from "../harnesses/index.mjs";
37
+ import { READ_BYTE_LIMIT, READ_LINE_LIMIT, harnessCapabilities, normalizeProviderResult, writesWorkspace } from "../harnesses/index.mjs";
38
38
  import { writeJsonAtomic } from "../run/store.mjs";
39
39
  import { judgeReaskInstruction, reviewMode } from "../contract/review-modes.mjs";
40
40
  import { routeRuntimeForState, runtimeSnapshot } from "./failover.mjs";
@@ -194,6 +194,7 @@ function sealPreviousAttempt(contract, node, state) {
194
194
  runId: contract.id,
195
195
  nodeId: node.id,
196
196
  attempt,
197
+ exclude: state.verificationArtifacts,
197
198
  });
198
199
  return sealed.empty ? null : { sha: sealed.sha, attempt };
199
200
  }
@@ -269,7 +270,7 @@ export function startWorker(contract, node, state, runDir, running, prompt, lock
269
270
  // make sure the directory exists before the provider is asked to.
270
271
  const resultPath = attemptWorkerResultPath(runDir, node.id, workspace);
271
272
  mkdirSync(dirname(resultPath), { recursive: true });
272
- const effectivePrompt = workerProtocolPrompt(phasePlan.prompt, resultPath);
273
+ const effectivePrompt = workerProtocolPrompt(phasePlan.prompt, resultPath, writesWorkspace(runtime));
273
274
  const paths = logPaths(runDir, node.id, "worker", state.attempt);
274
275
  if (Buffer.byteLength(effectivePrompt, "utf8") > 64 * 1024) {
275
276
  transition(runDir, state, "failed", { phase: "worker", error: { code: "worker_prompt_too_large", message: "worker prompt exceeds 65536 bytes" } }, lock);
@@ -390,8 +391,9 @@ export function startResultMaterialization(contract, node, state, runDir, runnin
390
391
  const resultPath = attemptWorkerResultPath(runDir, node.id, workspace);
391
392
  const prompt = appendSandboxNotice([
392
393
  `${RESULT_MATERIALIZATION_PROMPT_HEADER} Do not inspect, implement, verify, or invoke tools.`,
393
- `Your only job in this single bounded turn is to write the required worker-result JSON object to: ${resultPath}`,
394
- "Then return that same JSON object as the final message.",
394
+ ...(writesWorkspace(materializationRuntime)
395
+ ? [`Your only job in this single bounded turn is to write the required worker-result JSON object to: ${resultPath}`, "Then return that same JSON object as the final message."]
396
+ : ["Your sandbox is read-only, so write no file: your only job in this single bounded turn is to return the required worker-result JSON object as the final message."]),
395
397
  ].join("\n\n"), materializationRuntime);
396
398
  let baseline;
397
399
  try {
@@ -396,6 +396,7 @@ export async function sealBeforeTerminate(job, timeout) {
396
396
  runId: job.contract.id,
397
397
  nodeId: job.node.id,
398
398
  attempt: job.state.attempt,
399
+ exclude: job.state.verificationArtifacts,
399
400
  });
400
401
  job.state.worktree = {
401
402
  ...job.state.worktree,
@@ -143,11 +143,23 @@ export function resolveWorkerResult(runDir, node, providerResult) {
143
143
  return result;
144
144
  }
145
145
  /**
146
+ * A runtime that cannot write (`writable` false) is told its final message is
147
+ * the result, and `resolveWorkerResult` persists it: asking it to write the
148
+ * file is what stalled a read-only reviewer (RM-058).
149
+ *
146
150
  * @param {string} prompt
147
151
  * @param {string} resultPath
152
+ * @param {boolean} [writable]
148
153
  * @returns {string}
149
154
  */
150
- export function workerProtocolPrompt(prompt, resultPath) {
155
+ export function workerProtocolPrompt(prompt, resultPath, writable = true) {
156
+ if (!writable) {
157
+ return [
158
+ prompt,
159
+ "Controller worker protocol:",
160
+ "Your sandbox is read-only, so write no file: your final response is the result, exactly the required worker-result JSON object, and the controller persists it.",
161
+ ].join("\n\n");
162
+ }
151
163
  return [
152
164
  prompt,
153
165
  "Controller worker protocol:",
@@ -166,6 +166,7 @@ export async function settleDone(contract, node, state, runDir, lock, states, ca
166
166
  runId: contract.id,
167
167
  nodeId: node.id,
168
168
  attempt: state.attempt,
169
+ exclude: state.verificationArtifacts,
169
170
  });
170
171
  state.worktree = { ...state.worktree, commit: sealed.sha, status: "ready" };
171
172
  writeNode(runDir, state, lock);
@@ -8,9 +8,10 @@
8
8
  * catch. `recoverVerificationAttempts` reads back what a crashed controller had
9
9
  * already proved, so a resume does not pay for the same suite twice.
10
10
  */
11
- import { attemptWorkspace } from "../repo/worktree.mjs";
11
+ import { attemptWorkspace, untrackedPaths } from "../repo/worktree.mjs";
12
12
  import { boundedUtf8, errorMessage } from "../util.mjs";
13
13
  import { compactVerification } from "../contract/verification.mjs";
14
+ import { MAX_SCOPE_FINDING_PATHS } from "../contract/scope-findings.mjs";
14
15
  import { finalVerificationCommands, phaseTerminalNode, sharedVerificationCommands } from "../contract/final-verification.mjs";
15
16
  import { join } from "node:path";
16
17
  import { listNodeSnapshots, readNodeSnapshot } from "../run/node-store.mjs";
@@ -137,7 +138,11 @@ export async function executeControllerVerification(contract, runDir, node, stat
137
138
  attempts: [...(state.verification?.attempts ?? [])],
138
139
  };
139
140
  writeNode(runDir, state, lock);
140
- const workspace = attemptWorkspace(state) ?? contract.cwd;
141
+ const attempt = attemptWorkspace(state);
142
+ const workspace = attempt ?? contract.cwd;
143
+ // The mark between "worker finished" and "verification ran": what was
144
+ // already untracked is the worker's, what appears after is the suite's.
145
+ const untrackedBefore = attempt ? new Set(untrackedPaths(attempt)) : null;
141
146
  const commands = [...node.taskPacket.verification, ...sharedVerificationCommands(contract), ...finalVerificationCommands(contract, node, settledSiblingIds(runDir, contract, node))];
142
147
  /** @param {VerificationAttempt} attempt @returns {VerificationProgress} */
143
148
  const progressFor = (attempt) => verificationProgress(attempt.commandIndex + 1, commands.length, /** @type {VerificationCommand|undefined} */ (commands[attempt.commandIndex])?.argv);
@@ -169,9 +174,39 @@ export async function executeControllerVerification(contract, runDir, node, stat
169
174
  attempts: verificationAttemptRecords(state),
170
175
  };
171
176
  }
177
+ if (attempt && untrackedBefore) {
178
+ const artifacts = verificationArtifacts(node, state, untrackedBefore, untrackedPaths(attempt));
179
+ if (artifacts.length) state.verificationArtifacts = artifacts;
180
+ else delete state.verificationArtifacts;
181
+ }
172
182
  writeNode(runDir, state, lock);
173
183
  return state.verification;
174
184
  }
185
+
186
+ /**
187
+ * RM-052, measured on `rec-audit-remediation`: the target's suite wrote
188
+ * `rec-wav-test-<pid>.*` into its working directory, and 14 of them crossed
189
+ * the seal into the remediation branch. A path the verification leaves
190
+ * untracked, outside the packet's declared writes, is recorded here and kept
191
+ * out of the seal. An earlier pass's artifact stays recorded while it is
192
+ * still on disk, since the next pass sees it as already there. Bounded like
193
+ * scope findings; an artifact past the bound would still be sealed.
194
+ *
195
+ * @param {ValidatedNode} node
196
+ * @param {NodeSnapshot} state
197
+ * @param {Set<string>} before
198
+ * @param {string[]} after
199
+ * @returns {string[]}
200
+ */
201
+ function verificationArtifacts(node, state, before, after) {
202
+ const files = new Set(node.taskPacket.writeFiles ?? []);
203
+ const roots = (node.taskPacket.writeRoots ?? []).map((root) => root.replace(/\/+$/u, ""));
204
+ const previous = new Set(state.verificationArtifacts ?? []);
205
+ return after
206
+ .filter((path) => !before.has(path) || previous.has(path))
207
+ .filter((path) => !files.has(path) && !roots.some((root) => path === root || path.startsWith(`${root}/`)))
208
+ .slice(0, MAX_SCOPE_FINDING_PATHS);
209
+ }
175
210
  /**
176
211
  * @param {string} runDir
177
212
  * @param {NodeSnapshot} state
@@ -51,6 +51,7 @@ export const codexHarness = {
51
51
  field: "sandbox",
52
52
  executingModes: ["read-only", "workspace-write", "danger-full-access"],
53
53
  defaultMode: "workspace-write",
54
+ readOnlyModes: ["read-only"],
54
55
  },
55
56
 
56
57
  /** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string} */
@@ -55,7 +55,10 @@ const CAPABILITY_NAMES = new Set([
55
55
  * the value used when the contract omits that field. `null` means the harness
56
56
  * has no permission mode that can deny command execution.
57
57
  *
58
- * @typedef {{field: "permissionMode"|"sandbox", executingModes: string[], defaultMode: string}|null} PermissionExecutionPolicy
58
+ * `readOnlyModes` names the modes in which the provider cannot write its
59
+ * workspace, so the controller must not ask it to.
60
+ *
61
+ * @typedef {{field: "permissionMode"|"sandbox", executingModes: string[], defaultMode: string, readOnlyModes?: string[]}|null} PermissionExecutionPolicy
59
62
  */
60
63
 
61
64
  /** @typedef {{status: "done"|"no-op"|"blocked"|"failed"|"exhausted"|"stalled"|"canceled", result: string|null, continuationId: string|null, usage: {inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}, costUsd: number|null, error: {code: string, message: string, resetAt?: string|null}|null, exhaustedUntil?: string|null, judgeCandidates?: number}} ProviderEnvelope */
@@ -154,6 +157,20 @@ export function resolvePermissionExecution(runtime) {
154
157
  return { executes: policy.executingModes.includes(mode), field: policy.field, mode, executingModes: policy.executingModes };
155
158
  }
156
159
 
160
+ /**
161
+ * Whether a runtime can write its workspace. False only in a mode its adapter
162
+ * declares read-only; RM-058 measured a codex reviewer under `read-only`
163
+ * whose result-file write was rejected and which then stalled for 300s.
164
+ *
165
+ * @param {{harness: string, permissionMode?: string, sandbox?: string}} runtime
166
+ * @returns {boolean}
167
+ */
168
+ export function writesWorkspace(runtime) {
169
+ const policy = getHarness(runtime.harness).permissionExecution;
170
+ if (!policy?.readOnlyModes) return true;
171
+ return !policy.readOnlyModes.includes(/** @type {string} */ (runtime[policy.field] ?? policy.defaultMode));
172
+ }
173
+
157
174
  /**
158
175
  * The vendor a harness talks to when no provider configuration says
159
176
  * otherwise. `replay` and `exec-jsonl` stand in for whatever the recording or
@@ -19,6 +19,7 @@ import { fileURLToPath } from "node:url";
19
19
  import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION, contractDigest, validateContract } from "../contract/index.mjs";
20
20
  import { assertObject, rejectUnknown, requirePacketHash, requireString } from "../contract/assert.mjs";
21
21
  import { writeJsonAtomic, writeTextAtomic } from "../run/store.mjs";
22
+ import { VERIFICATION_LIMITS } from "../contract/verification.mjs";
22
23
  import { validatePlanPhases } from "./template.mjs";
23
24
 
24
25
  /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
@@ -31,9 +32,108 @@ import { validatePlanPhases } from "./template.mjs";
31
32
  /** @typedef {{path: string, digest: string}} PlanSpecIdentity */
32
33
  /** @typedef {{formatVersion: number, contractDigest: string, spec?: PlanSpecIdentity, phases?: PlanPhase[], provenance: PlanProvenance}} FrozenPlan */
33
34
  /** @typedef {{ok: boolean, digest: string, expectedDigest: string}} FrozenPlanVerdict */
35
+ /** @typedef {{scripts?: Record<string, string>, verificationCandidates: {argv: string[], measuredMs: number}[]}} MeasuredFacts */
34
36
 
35
37
  const PLAN_FORMAT_VERSION = 1;
36
38
 
39
+ /**
40
+ * The margin a frozen verification timeout keeps over its measured duration.
41
+ * No measurement behind the number itself: it is the spec's (RM-057), and a
42
+ * timeout only bounds a failure, so a passing command never waits for it.
43
+ */
44
+ const MEASURED_TIMEOUT_MARGIN = 1.5;
45
+
46
+ /** `node --test` options that run a subset of the files they name. */
47
+ const FILTER_OPTIONS = ["--test-name-pattern", "--test-skip-pattern", "--test-only", "--test-shard"];
48
+
49
+ /** `node` options whose value is the next argument, so it is not a path. */
50
+ const NODE_VALUE_OPTIONS = new Set(["--import", "--require", "-r", "--loader", "--experimental-loader", "--env-file", "--test-reporter", "--test-reporter-destination", "--test-name-pattern", "--test-skip-pattern", "--test-concurrency", "--test-timeout"]);
51
+
52
+ /**
53
+ * The `node --test <dir>` candidates one path argument includes: a directory
54
+ * includes itself and everything below it, and a glob includes the
55
+ * directories below its literal prefix only when it descends (`test/*` +
56
+ * `/…`); `test/*.test.mjs` names top-level files no candidate measured.
57
+ *
58
+ * @param {string} arg
59
+ * @param {string[]} directories
60
+ * @returns {string[]}
61
+ */
62
+ function includedDirectories(arg, directories) {
63
+ const path = arg.replace(/^\.\//u, "").replace(/\/+$/u, "");
64
+ const wildcard = path.search(/[*?[]/u);
65
+ if (wildcard < 0) return directories.filter((directory) => directory === path || directory.startsWith(`${path}/`));
66
+ const base = path.slice(0, path.lastIndexOf("/", wildcard));
67
+ if (!path.slice(wildcard).includes("/")) return [];
68
+ return directories.filter((directory) => base === "" || directory.startsWith(`${base}/`));
69
+ }
70
+
71
+ /**
72
+ * What repo facts measured for a verification command: its own candidate, or
73
+ * the sum of the `node --test <dir>` candidates it includes, `npm test`
74
+ * resolved through the `test` script. A lower bound when the command also
75
+ * runs files no candidate measured; null when it includes nothing measured.
76
+ *
77
+ * @param {string[]} argv
78
+ * @param {MeasuredFacts} facts
79
+ * @returns {number|null}
80
+ */
81
+ function measuredMsFor(argv, facts) {
82
+ const candidates = facts.verificationCandidates;
83
+ const exact = candidates.find((candidate) => candidate.argv.join(" ") === argv.join(" "));
84
+ if (exact) return exact.measuredMs;
85
+ const npmTest = argv[0] === "npm" && ["test", "run test"].includes(argv.slice(1).join(" "));
86
+ if (npmTest && typeof facts.scripts?.test === "string") return measuredMsFor(facts.scripts.test.trim().split(/\s+/u), facts);
87
+ if (argv[0] !== "node" || argv[1] !== "--test") return null;
88
+ // A filtered run measures nothing a directory candidate measured.
89
+ if (argv.some((arg) => FILTER_OPTIONS.some((option) => arg === option || arg.startsWith(`${option}=`)))) return null;
90
+ const measured = new Map(candidates
91
+ .filter((candidate) => candidate.argv.length === 3 && candidate.argv[0] === "node" && candidate.argv[1] === "--test")
92
+ .map((candidate) => [candidate.argv[2].replace(/\/+$/u, ""), candidate.measuredMs]));
93
+ /** @type {string[]} */
94
+ const paths = [];
95
+ for (let index = 2; index < argv.length; index += 1) {
96
+ if (NODE_VALUE_OPTIONS.has(argv[index])) index += 1;
97
+ else if (!argv[index].startsWith("-")) paths.push(argv[index]);
98
+ }
99
+ const included = new Set((paths.length ? paths : ["test"]).flatMap((path) => includedDirectories(path, [...measured.keys()])));
100
+ if (!included.size) return null;
101
+ return [...included].reduce((sum, directory) => sum + /** @type {number} */ (measured.get(directory)), 0);
102
+ }
103
+
104
+ /**
105
+ * Refuse a contract whose verification timeout sits under
106
+ * `MEASURED_TIMEOUT_MARGIN` times what repo facts measured for the command,
107
+ * and name a command no legal timeout can cover, so the plan is contested
108
+ * with the advice to split it. RM-057, measured on the Campaign Brief run:
109
+ * a gate frozen at 120s against parts measured at 178,904 ms and 246,955 ms.
110
+ *
111
+ * @param {import("../contract/index.mjs").ValidatedContract} contract
112
+ * @param {MeasuredFacts} facts
113
+ * @returns {void}
114
+ */
115
+ export function assertTimeoutsCoverMeasured(contract, facts) {
116
+ const commands = [
117
+ ...contract.nodes.flatMap((node) => node.taskPacket.verification ?? []),
118
+ ...contract.sharedVerification ?? [],
119
+ ...contract.finalVerification ?? [],
120
+ ];
121
+ const problems = new Set();
122
+ for (const command of commands) {
123
+ const measuredMs = measuredMsFor(command.argv, facts);
124
+ if (measuredMs === null) continue;
125
+ const timeoutSec = command.timeoutSec ?? 120;
126
+ const requiredSec = Math.ceil((measuredMs * MEASURED_TIMEOUT_MARGIN) / 1_000);
127
+ const shown = `${command.argv.join(" ")} measured ${(measuredMs / 1_000).toFixed(1)}s`;
128
+ if (requiredSec > VERIFICATION_LIMITS.maxTimeoutSec) {
129
+ problems.add(`${shown}, and ${MEASURED_TIMEOUT_MARGIN} times that passes the ${VERIFICATION_LIMITS.maxTimeoutSec}s maxTimeoutSec: split it into commands that each fit`);
130
+ } else if (timeoutSec < requiredSec) {
131
+ problems.add(`verification ${command.argv.join(" ")} has timeoutSec ${timeoutSec}s, under ${MEASURED_TIMEOUT_MARGIN} times its measured ${(measuredMs / 1_000).toFixed(1)}s: raise it to at least ${requiredSec}s`);
132
+ }
133
+ }
134
+ if (problems.size) throw new TypeError(`verification timeouts do not cover their measured durations: ${[...problems].join("; ")}`);
135
+ }
136
+
37
137
  /** @returns {string} the installed package's own version, read once per call so a freeze always names the toolchain that produced it */
38
138
  function packageVersion() {
39
139
  const packageJsonPath = fileURLToPath(new URL("../../package.json", import.meta.url));
@@ -175,10 +275,13 @@ export function writeFrozenPlanRecord(outDir, record) {
175
275
  * was planned from.
176
276
  *
177
277
  * @param {JsonObject} plan
178
- * @param {{outDir: string, provenance: PlanProvenanceInput, phases?: import("./template.mjs").PlanPhase[], spec?: PlanSpecIdentity}} options
278
+ * `options.facts` carries repo facts' measured durations; given, a
279
+ * verification timeout that does not cover one is refused.
280
+ *
281
+ * @param {{outDir: string, provenance: PlanProvenanceInput, phases?: import("./template.mjs").PlanPhase[], spec?: PlanSpecIdentity, facts?: MeasuredFacts}} options
179
282
  * @returns {FrozenPlan}
180
283
  */
181
- export function freezePlan(plan, { outDir, provenance, phases, spec }) {
284
+ export function freezePlan(plan, { outDir, provenance, phases, spec, facts }) {
182
285
  // Shape-checked before anything is written, so a malformed declaration
183
286
  // leaves the outDir exactly as it was — the same failure discipline as the
184
287
  // validateContract rollback below. A declaration in the nodeIds shape must
@@ -199,7 +302,8 @@ export function freezePlan(plan, { outDir, provenance, phases, spec }) {
199
302
  });
200
303
  writeJsonAtomic(contractPath, raw);
201
304
  try {
202
- validateContract(raw, contractPath);
305
+ const validated = validateContract(raw, contractPath);
306
+ if (facts) assertTimeoutsCoverMeasured(validated, facts);
203
307
  } catch (error) {
204
308
  rmSync(contractPath, { force: true });
205
309
  throw error;
@@ -30,7 +30,7 @@ import { collectRepoFacts } from "./repo-facts.mjs";
30
30
  import { RISK_TIERS, TASK_KIND_CATALOGUE_FILE, buildPlanningContract, renderTaskKindCatalogue, validateFindings, validatePlanOutput } from "./template.mjs";
31
31
  import { MIN_WRITE_FILES, applySizingRules, provenParallelism } from "./sizing.mjs";
32
32
  import { resolveRuntimes } from "./routing.mjs";
33
- import { contentDigest, freezePlan, writeFrozenPlanRecord } from "./freeze.mjs";
33
+ import { assertTimeoutsCoverMeasured, contentDigest, freezePlan, writeFrozenPlanRecord } from "./freeze.mjs";
34
34
  import { availabilityOf, fileLineCount, highestOf, modelOf, toContractNode, toSizingNode } from "./pipeline-shape.mjs";
35
35
  import { campaignTree, runDirectory } from "../run/paths.mjs";
36
36
 
@@ -364,7 +364,7 @@ export async function runPlanningPipeline(options) {
364
364
  /** @type {PlanFindingOutput|null} */
365
365
  let freezeFailure = null;
366
366
  try {
367
- validateContract(frozenContractRaw(assembleFrozenNodes(plan)), join(plansDir, "contract.json"));
367
+ assertTimeoutsCoverMeasured(validateContract(frozenContractRaw(assembleFrozenNodes(plan)), join(plansDir, "contract.json")), repoFacts);
368
368
  } catch (error) {
369
369
  freezeFailure = invalidPlanFinding(`freeze-r${round}`, error);
370
370
  findings = [...findings, freezeFailure];
@@ -457,6 +457,7 @@ export async function runPlanningPipeline(options) {
457
457
  // The pipeline's own pinned spec bytes: a wrong or missing digest is the
458
458
  // first thing the Campaign Brief refuses on, never a summary.
459
459
  spec: { path: relativeSpecPath, digest: specDigest },
460
+ facts: repoFacts,
460
461
  provenance: {
461
462
  targetGitHead: repoFacts.gitHead,
462
463
  planner: { runtimeId: runtimeDefaults.worker ?? "", model: modelOf(runtimes, runtimeDefaults.worker) },
@@ -17,6 +17,7 @@ import { join, resolve } from "node:path";
17
17
  import { lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
18
18
  import { tmpdir } from "node:os";
19
19
  import { RUNS_DIR_NAME } from "../run/paths.mjs";
20
+ import { isIgnoreSource } from "./workspace.mjs";
20
21
 
21
22
  /** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
22
23
 
@@ -49,6 +50,21 @@ export function unsnapshottedWriteWarnings(node, index, cwd) {
49
50
  `nodes[${index}] (${node.id}): ${path.includes("/") ? `${kind} under ${root}/` : `${kind} ${path}`} are outside the workspace snapshot, so the closed-scope gate cannot observe them`,
50
51
  );
51
52
  }
53
+ /**
54
+ * `writes_ignore_source`: a declared write the workspace snapshot fingerprints
55
+ * as an ignore source. The node fails with `snapshot_ignore_changed` the
56
+ * moment the worker changes it, and two campaigns each lost a node learning
57
+ * that (RM-051), so the author hears it before dispatch.
58
+ *
59
+ * @param {ValidatedNode} node
60
+ * @param {number} index
61
+ * @returns {string[]}
62
+ */
63
+ export function ignoreSourceWriteWarnings(node, index) {
64
+ const sources = (node.taskPacket.writeFiles ?? []).filter(isIgnoreSource);
65
+ if (!sources.length) return [];
66
+ return [`nodes[${index}] (${node.id}): writes_ignore_source: writeFiles ${sources.join(", ")} ${sources.length === 1 ? "is an ignore source" : "are ignore sources"} the workspace snapshot fingerprints; a worker that changes one fails the node with snapshot_ignore_changed, so make that edit outside the run`];
67
+ }
52
68
  /**
53
69
  * @param {string|undefined} cwd
54
70
  * @param {string} declaredPath
@@ -53,7 +53,7 @@ export function captureWorkspaceSnapshot(cwd, expectedIgnoreSources) {
53
53
  const root = realpathSync(cwd);
54
54
  const ignoreSources = captureIgnoreSources(root);
55
55
  if (expectedIgnoreSources !== undefined && !sameSnapshotEntries(expectedIgnoreSources, ignoreSources)) {
56
- throw fail("snapshot_ignore_changed", "workspace ignore sources changed during worker execution");
56
+ throw ignoreSourcesChanged(expectedIgnoreSources, ignoreSources);
57
57
  }
58
58
  /** @type {SnapshotEntry[]} */
59
59
  const entries = [];
@@ -123,7 +123,7 @@ export function compareWorkspaceSnapshot(before, cwd, scope = {}) {
123
123
  });
124
124
  const after = captureWorkspaceSnapshot(cwd, before.ignoreSources);
125
125
  if (!sameSnapshotEntries(before.ignoreSources, after.ignoreSources)) {
126
- throw fail("snapshot_ignore_changed", "workspace ignore sources changed during worker execution");
126
+ throw ignoreSourcesChanged(before.ignoreSources, after.ignoreSources);
127
127
  }
128
128
  const prior = new Map(before.entries.map((/** @type {SnapshotEntry} */ entry) => [entry.path, JSON.stringify(entry)]));
129
129
  const current = new Map(after.entries.map((/** @type {SnapshotEntry} */ entry) => [entry.path, JSON.stringify(entry)]));
@@ -262,6 +262,45 @@ export function validateWorkspaceScopeBoundary(cwd, boundary, declared = {}) {
262
262
  rootOrigins,
263
263
  };
264
264
  }
265
+ /** Directories `captureIgnoreSources` skips at any depth. */
266
+ const SKIPPED_SOURCE_DIRS = new Set([".git", RUNS_DIR_NAME, "node_modules", ".venv", "venv"]);
267
+
268
+ /** The ignore sources `captureIgnoreSources` always fingerprints at the root. */
269
+ const ROOT_IGNORE_SOURCES = [".faberunignore", ".gitignore", ".git/config"];
270
+
271
+ /**
272
+ * Whether a workspace-relative path is one `captureIgnoreSources` fingerprints,
273
+ * so a worker that changes it fails its node with `snapshot_ignore_changed`.
274
+ * A `.gitignore` counts at any depth outside the directories the walk skips.
275
+ *
276
+ * @param {string} path
277
+ * @returns {boolean}
278
+ */
279
+ export function isIgnoreSource(path) {
280
+ const normalized = path.replaceAll("\\", "/").replace(/^\.\//u, "");
281
+ if (ROOT_IGNORE_SOURCES.includes(normalized) || normalized === ".git/info/exclude" || normalized === ".git") return true;
282
+ const segments = normalized.split("/");
283
+ // The same directories `captureIgnoreSources` never walks.
284
+ if (segments.some((segment) => SKIPPED_SOURCE_DIRS.has(segment)) || [".claude", ".codex"].includes(segments[0])) return false;
285
+ return basename(normalized) === ".gitignore";
286
+ }
287
+
288
+ /**
289
+ * The `snapshot_ignore_changed` failure, naming every source that moved.
290
+ * Measured on `rec-audit-remediation`: the message named none, and the
291
+ * operator had to diff the worktree to learn it was one `.gitignore` line.
292
+ *
293
+ * @param {SnapshotEntry[]} before
294
+ * @param {SnapshotEntry[]} after
295
+ * @returns {Error}
296
+ */
297
+ function ignoreSourcesChanged(before, after) {
298
+ const prior = new Map(before.map((entry) => [entry.path, JSON.stringify(entry)]));
299
+ const current = new Map(after.map((entry) => [entry.path, JSON.stringify(entry)]));
300
+ const changed = [...new Set([...prior.keys(), ...current.keys()])].filter((path) => prior.get(path) !== current.get(path)).sort();
301
+ return fail("snapshot_ignore_changed", `workspace ignore sources changed during worker execution: ${changed.join(", ")}`);
302
+ }
303
+
265
304
  /**
266
305
  * Snapshot the files Git can use to hide workspace changes. These entries are
267
306
  * kept separate from the relevant-file entry cap. A worker cannot replace
@@ -273,7 +312,7 @@ export function validateWorkspaceScopeBoundary(cwd, boundary, declared = {}) {
273
312
  */
274
313
  function captureIgnoreSources(root) {
275
314
  /** @type {Set<string>} */
276
- const paths = new Set([".faberunignore", ".gitignore", ".git/config"]);
315
+ const paths = new Set(ROOT_IGNORE_SOURCES);
277
316
  /** @type {Map<string, string>} */
278
317
  const gitPaths = new Map();
279
318
  /** @param {string} name @param {string} logical @returns {string|null} */
@@ -322,10 +322,27 @@ function gitTracks(repo, read) {
322
322
  }
323
323
 
324
324
  /**
325
- * @param {{repo: string, path: string, baseSha: string|null, runId: string, nodeId: string, attempt: number}} args
325
+ * Every untracked, unignored path in a worktree, outside the runner's own
326
+ * `.runs` tree and the linked `node_modules`: the same exclusions the seal
327
+ * applies, so the two agree on what "left in the worktree" means.
328
+ *
329
+ * @param {string} path
330
+ * @returns {string[]}
331
+ */
332
+ export function untrackedPaths(path) {
333
+ const listed = git(path, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ".", `:(exclude)${RUNS_DIR_NAME}`, ":(exclude)node_modules"]);
334
+ return listed.split("\0").filter((entry) => entry.startsWith("?? ")).map((entry) => entry.slice(3)).sort();
335
+ }
336
+
337
+ /**
338
+ * `exclude` names paths the attempt holds but must not seal: what the
339
+ * controller's own verification left behind (`verificationArtifacts`). They
340
+ * stay on disk and out of the commit.
341
+ *
342
+ * @param {{repo: string, path: string, baseSha: string|null, runId: string, nodeId: string, attempt: number, exclude?: string[]}} args
326
343
  * @returns {SealedAttempt}
327
344
  */
328
- export function sealAttempt({ repo, path, baseSha, runId, nodeId, attempt }) {
345
+ export function sealAttempt({ repo, path, baseSha, runId, nodeId, attempt, exclude = [] }) {
329
346
  // The attempt-local `.runs` result sidecar must never enter the attempt
330
347
  // commit. Naming it through an exclude pathspec makes `git add` exit 1 with
331
348
  // advice.addIgnoredFile as soon as the sidecar exists in a repository that
@@ -345,6 +362,11 @@ export function sealAttempt({ repo, path, baseSha, runId, nodeId, attempt }) {
345
362
  // node_modules is linked into the worktree as a symlink, which `node_modules/`
346
363
  // in .gitignore does not match; never let the link into the attempt commit.
347
364
  runGit(["-C", path, "rm", "-r", "-q", "--cached", "--ignore-unmatch", "--", RUNS_DIR_NAME, "node_modules"]);
365
+ if (exclude.length) runGit(["-C", path, "rm", "-q", "--cached", "--ignore-unmatch", "--", ...exclude.map((item) => `:(literal)${item}`)]);
366
+ }
367
+ // A worktree whose only change was an excluded artifact stages nothing, and
368
+ // an empty commit exits 1; the attempt's head is then its seal.
369
+ if (dirty && !stagedNothing(path)) {
348
370
  runGit([
349
371
  "-C", path,
350
372
  "-c", "user.email=runner@example.test",
@@ -371,6 +393,16 @@ export function sealAttempt({ repo, path, baseSha, runId, nodeId, attempt }) {
371
393
  return { sha, empty };
372
394
  }
373
395
 
396
+ /** @param {string} path @returns {boolean} */
397
+ function stagedNothing(path) {
398
+ try {
399
+ runGit(["-C", path, "diff", "--cached", "--quiet"]);
400
+ return true;
401
+ } catch {
402
+ return false;
403
+ }
404
+ }
405
+
374
406
  /** @param {string} repo @param {string} base @param {string} head @returns {boolean} */
375
407
  export function gitDiffEmpty(repo, base, head) {
376
408
  try {
@@ -9,7 +9,7 @@
9
9
  import { compactCost, compactTokens, errorCode } from "../util.mjs";
10
10
  import { basename, join } from "node:path";
11
11
  import { readJson, writeJsonAtomic, writeTextAtomic } from "../run/store.mjs";
12
- import { scopeFindingsNote } from "../contract/scope-findings.mjs";
12
+ import { scopeFindingsNote, verificationArtifactsNote } from "../contract/scope-findings.mjs";
13
13
  import { MARK, advisoryFindingCount, fit, judgeRuntimeLabel, roleCosts, roleUsage, statusNote, workerRuntimeLabel, writeStatusArtifacts } from "./render.mjs";
14
14
  import { packetRepetitionByNode, packetRepetitionNote } from "./packet-repetition.mjs";
15
15
  import { unlinkSync } from "node:fs";
@@ -131,9 +131,10 @@ export function renderFinalReport(runDir, contract, states) {
131
131
  const judge = judgeRuntimeLabel(node) ?? "-";
132
132
  const planNode = contract.nodes.find((candidate) => candidate.id === node.id);
133
133
  const detail = node.gate?.summary ?? node.error?.message ?? (node.blockedBy?.length ? node.blockedBy.join(", ") : null) ?? (typeof node.result === "string" && node.result.trim() ? node.result.trim() : node.phase ?? "-");
134
- // The advisory scope finding leads the note, as it does in STATUS.md.
135
- const note = scopeFindingsNote(node.scopeFindings)
136
- ? `${scopeFindingsNote(node.scopeFindings)} · ${detail}`
134
+ // The advisory findings lead the note, as they do in STATUS.md.
135
+ const findings = [scopeFindingsNote(node.scopeFindings), verificationArtifactsNote(node.verificationArtifacts)].filter(Boolean).join(" · ");
136
+ const note = findings
137
+ ? `${findings} · ${detail}`
137
138
  : `${detail} · phase ${planNode?.phase ?? "-"} · ${node.invocations?.at(-1)?.continuationMode ?? "fresh"}`;
138
139
  lines.push(row([
139
140
  MARK[node.status] ?? "[?]",
@@ -3,7 +3,7 @@ import { basename, join } from "node:path";
3
3
  import { validateContract } from "../contract/index.mjs";
4
4
  import { readJson, writeJsonAtomic } from "../run/store.mjs";
5
5
  import { lockStale, pidAlive, readLock } from "../run/lock.mjs";
6
- import { scopeFindingsNote } from "../contract/scope-findings.mjs";
6
+ import { scopeFindingsNote, verificationArtifactsNote } from "../contract/scope-findings.mjs";
7
7
  import { reviewNote } from "../contract/review-modes.mjs";
8
8
  import { validateNodeSnapshot, validateRunMetadata } from "../contract/snapshot.mjs";
9
9
  import { compactCost, compactTokens, truncateChars } from "../util.mjs";
@@ -32,7 +32,7 @@ const POINTER_ATTENTION_CHARS = 80;
32
32
  /** @typedef {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}} StatusPayloadUsage */
33
33
  /** @typedef {{index: number, total: number, argv: string}} VerificationProgress */
34
34
  /** @typedef {{verdict: string, maxSeverity: string, findingCount: number, summary: string|null}} StatusPayloadGate */
35
- /** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, judgeRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, roleCostUsd: {worker: number|null, judge: number|null}, costProvenance: {worker: string, judge: string}, verdict: string|null, gate: StatusPayloadGate|null, gateOutcome: "passed"|"rejected"|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, errorCode: string|null, blockedBy: string[], verificationProgress: VerificationProgress|null, declaredReadBytes: number|null}} StatusPayloadNode */
35
+ /** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, judgeRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, roleCostUsd: {worker: number|null, judge: number|null}, costProvenance: {worker: string, judge: string}, verdict: string|null, gate: StatusPayloadGate|null, gateOutcome: "passed"|"rejected"|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, verificationArtifacts: string[]|null, errorCode: string|null, blockedBy: string[], verificationProgress: VerificationProgress|null, declaredReadBytes: number|null}} StatusPayloadNode */
36
36
  /** @typedef {{schemaVersion: 1, run: string, contractId: string, campaignId: string, goal: string, usage: {inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}, roles: {worker: RoleUsage, judge: RoleUsage}, controller: JsonObject, identityWarnings: string[], summary: string, nodes: StatusPayloadNode[]}} StatusPayload */
37
37
 
38
38
  /** The glyph each terminal state prints in a status table. */
@@ -296,6 +296,7 @@ function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
296
296
  pendingHandoff: pendingHandoff(node),
297
297
  note: statusNote(node),
298
298
  scopeFindings: node.scopeFindings?.unexpectedPaths ?? null,
299
+ verificationArtifacts: node.verificationArtifacts ?? null,
299
300
  errorCode: node.error?.code ?? null,
300
301
  blockedBy: node.blockedBy ?? [],
301
302
  verificationProgress: progress,
@@ -723,7 +724,7 @@ function boundedNote(segments, maxLength = MAX_NOTE_LENGTH) {
723
724
  * @returns {string|null}
724
725
  */
725
726
  export function statusNote(node) {
726
- const scope = scopeFindingsNote(node.scopeFindings);
727
+ const scope = boundedNote([scopeFindingsNote(node.scopeFindings), verificationArtifactsNote(node.verificationArtifacts)]);
727
728
  const review = reviewNote(node);
728
729
  const detail = node.gate?.summary ?? node.error?.message ?? node.blockedBy?.join(", ") ?? (candidateVerificationActive(node) ? "candidate" : node.phase);
729
730
  const note = boundedNote([review, detail]);