faberun 0.20.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.
@@ -30,7 +30,8 @@ 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 { freezePlan } from "./freeze.mjs";
33
+ import { assertTimeoutsCoverMeasured, contentDigest, freezePlan, writeFrozenPlanRecord } from "./freeze.mjs";
34
+ import { availabilityOf, fileLineCount, highestOf, modelOf, toContractNode, toSizingNode } from "./pipeline-shape.mjs";
34
35
  import { campaignTree, runDirectory } from "../run/paths.mjs";
35
36
 
36
37
  /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
@@ -39,8 +40,9 @@ import { campaignTree, runDirectory } from "../run/paths.mjs";
39
40
  /** @typedef {{sharedVerification?: VerificationCommand[], finalVerification?: VerificationCommand[]}} VerificationSuites */
40
41
  /** @typedef {import("./template.mjs").PlanOutput} PlanOutput */
41
42
  /** @typedef {import("./template.mjs").PlanFindingOutput} PlanFindingOutput */
43
+ /** @typedef {import("./template.mjs").PlanPhase} PlanPhase */
42
44
  /** @typedef {import("./sizing.mjs").PlanNode & {objective: string}} SizedPlanNode */
43
- /** @typedef {{sizing: import("./sizing.mjs").SizingResult, routing: import("./routing.mjs").RoutingResult, nodes: JsonObject[]}} AssembledPlan */
45
+ /** @typedef {{sizing: import("./sizing.mjs").SizingResult, routing: import("./routing.mjs").RoutingResult, nodes: JsonObject[], phases?: PlanPhase[]}} AssembledPlan */
44
46
  /** @typedef {"standard"|"high"|"none"} ApproveBelow */
45
47
  /** @typedef {(contractPath: string, contract: ValidatedContract) => Promise<void>|void} LaunchFn */
46
48
  /** @typedef {(runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, cwd: string) => Promise<import("../harnesses/index.mjs").ProbeResult[]>} AskFn */
@@ -113,6 +115,9 @@ export async function runPlanningPipeline(options) {
113
115
 
114
116
  const relativeSpecPath = repoRelativePath(cwd, specPath, "specPath");
115
117
  const specText = readFileSync(resolve(cwd, relativeSpecPath), "utf8");
118
+ // Pinned now, from the same bytes every planning stage reads, so the frozen
119
+ // record names the exact structured spec it was planned from.
120
+ const specDigest = contentDigest(specText);
116
121
  const specValidation = validateSpec(specText, { cwd, strict: true });
117
122
  if (specValidation.class === "structured" && !specValidation.ok) {
118
123
  const detail = specValidation.findings.map((finding) => `${finding.rule}: ${finding.message}`).join("; ");
@@ -276,6 +281,11 @@ export async function runPlanningPipeline(options) {
276
281
  sizing,
277
282
  routing,
278
283
  nodes: sizing.plan.nodes.map((node) => toContractNode(/** @type {SizedPlanNode} */ (node), phase, routing.assignments[node.id])),
284
+ // The declarations a reviewer saw, remapped onto the nodes sizing
285
+ // actually produced: a merge removes a node id, and a declaration that
286
+ // named it must now name the node that absorbed it or freeze would see
287
+ // an unknown assignment.
288
+ phases: carryPhaseDeclarations(currentPlan.phases, sizing.transformations),
279
289
  };
280
290
  };
281
291
 
@@ -354,7 +364,7 @@ export async function runPlanningPipeline(options) {
354
364
  /** @type {PlanFindingOutput|null} */
355
365
  let freezeFailure = null;
356
366
  try {
357
- validateContract(frozenContractRaw(assembleFrozenNodes(plan)), join(plansDir, "contract.json"));
367
+ assertTimeoutsCoverMeasured(validateContract(frozenContractRaw(assembleFrozenNodes(plan)), join(plansDir, "contract.json")), repoFacts);
358
368
  } catch (error) {
359
369
  freezeFailure = invalidPlanFinding(`freeze-r${round}`, error);
360
370
  findings = [...findings, freezeFailure];
@@ -443,6 +453,11 @@ export async function runPlanningPipeline(options) {
443
453
  highestRiskTier = highestOf(assembled.sizing.plan.nodes.map((node) => node.riskTier ?? RISK_TIERS[0]));
444
454
  frozen = freezePlan(frozenContractRaw(assembled), {
445
455
  outDir: plansDir,
456
+ phases: assembled.phases,
457
+ // The pipeline's own pinned spec bytes: a wrong or missing digest is the
458
+ // first thing the Campaign Brief refuses on, never a summary.
459
+ spec: { path: relativeSpecPath, digest: specDigest },
460
+ facts: repoFacts,
446
461
  provenance: {
447
462
  targetGitHead: repoFacts.gitHead,
448
463
  planner: { runtimeId: runtimeDefaults.worker ?? "", model: modelOf(runtimes, runtimeDefaults.worker) },
@@ -490,7 +505,10 @@ export async function runPlanningPipeline(options) {
490
505
 
491
506
  const approved = approveBelow === "high" ? true : approveBelow === "none" ? false : highestRiskTier !== "high";
492
507
  const planPath = join(plansDir, "plan.json");
493
- writeJsonAtomic(planPath, { ...frozen, status: "frozen", approved });
508
+ // The final bytes — status and approval included — are written first; the
509
+ // sidecar then covers exactly those bytes, and nothing rewrites the plan
510
+ // afterward. The written record is the plan.json the brief will read.
511
+ writeFrozenPlan(plansDir, /** @type {import("./freeze.mjs").FrozenPlan} */ (frozen), { status: "frozen", approved });
494
512
  if (!approved) {
495
513
  await campaignCli([
496
514
  "note", campaignId, "--cwd", cwd, "--session-id", PLANNER_SESSION_ID,
@@ -666,124 +684,58 @@ export function droppedWriteFindings(previousPlan, revisedPlan) {
666
684
  }
667
685
 
668
686
  /**
669
- * Every declared runtime treated as available. Live discovery (probing a
670
- * harness for real exhaustion) is a separate concern this pipeline does not
671
- * take on; a campaign that needs it can inject a table row and prune its
672
- * `runtimes` catalogue instead.
687
+ * The phase declarations a reviewer saw, remapped onto the nodes sizing
688
+ * actually produced. `applySizingRules` is the only stage that changes the
689
+ * node set, and it only ever folds one node into another — a declaration that
690
+ * named the folded-away node must name the node that absorbed it, or freeze
691
+ * would refuse the record as an unknown assignment. A two-entry transformation
692
+ * is a merge (`[child, parent]`); every one-entry transformation edits a node
693
+ * in place. Resolution follows a chain, so a node folded into another that was
694
+ * itself folded lands on the final owner, and duplicates collapse so a
695
+ * declaration never lists the same node twice. A declaration in the legacy
696
+ * shape (no `nodeIds`) is returned untouched.
673
697
  *
674
- * @param {Record<string, JsonObject>} runtimes
675
- * @returns {Record<string, {available: true, exhaustedUntil: null}>}
698
+ * @param {PlanPhase[]|undefined} phases
699
+ * @param {import("./sizing.mjs").SizingTransformation[]|undefined} transformations
700
+ * @returns {PlanPhase[]|undefined}
676
701
  */
677
- function availabilityOf(runtimes) {
678
- return Object.fromEntries(Object.keys(runtimes).map((id) => [id, { available: true, exhaustedUntil: null }]));
679
- }
680
-
681
- /**
682
- * @param {Record<string, JsonObject>} runtimes
683
- * @param {string|undefined} id
684
- * @returns {string}
685
- */
686
- function modelOf(runtimes, id) {
687
- const model = id ? runtimes[id]?.model : undefined;
688
- return typeof model === "string" ? model : "";
689
- }
690
-
691
- /**
692
- * @param {string[]} riskTiers
693
- * @returns {string}
694
- */
695
- function highestOf(riskTiers) {
696
- return riskTiers.reduce((highest, tier) => (RISK_TIERS.indexOf(tier) > RISK_TIERS.indexOf(highest) ? tier : highest), RISK_TIERS[0]);
697
- }
698
-
699
- /**
700
- * A draft or revise output node (flat `readFiles`/`writeFiles`/`verification`)
701
- * turned into the shape `applySizingRules` merges and splits: those fields move
702
- * under `taskPacket`, alongside `sizing.mjs`'s own `writeFiles`/`verification`
703
- * expectations, while `objective` rides along as a passthrough field a merge
704
- * never touches.
705
- *
706
- * @param {import("./template.mjs").PlanOutputNode} node
707
- * @returns {SizedPlanNode}
708
- */
709
- function toSizingNode(node) {
710
- return /** @type {SizedPlanNode} */ ({
711
- id: node.id,
712
- dependsOn: node.dependsOn,
713
- taskKind: node.taskKind,
714
- riskTier: node.riskTier,
715
- objective: node.objective,
716
- expectedTurns: node.expectedTurns,
717
- definitionOfDone: node.definitionOfDone,
718
- taskPacket: {
719
- readFiles: node.readFiles,
720
- writeFiles: node.writeFiles,
721
- scopeAcknowledged: node.scopeAcknowledged,
722
- verification: node.verification,
723
- },
724
- });
725
- }
726
-
727
- /**
728
- * A sized plan node's classification and shape, turned into the contract node
729
- * `freezePlan` validates. `riskTier: "low"` gets no gate; `standard` an
730
- * advisory one; `high` a blocking one, which `validateGate` requires `major`
731
- * in `failOn` for.
732
- *
733
- * @param {SizedPlanNode} node
734
- * @param {string} phase
735
- * @param {{worker: string|null, judge: string|null}|undefined} assignment
736
- * @returns {JsonObject}
737
- */
738
- function toContractNode(node, phase, assignment) {
739
- const riskTier = /** @type {string} */ (node.riskTier);
740
- const gate = riskTier === "low"
741
- ? false
742
- : {
743
- review: riskTier === "high" ? "blocking" : "advisory",
744
- failOn: riskTier === "high" ? ["major", "critical"] : ["critical"],
745
- ...(assignment?.judge ? { runtime: assignment.judge } : {}),
746
- };
747
- return {
748
- id: node.id,
749
- type: node.taskKind,
750
- phase,
751
- dependsOn: node.dependsOn ?? [],
752
- ...(assignment?.worker ? { runtime: assignment.worker } : {}),
753
- taskPacket: {
754
- mode: "execution",
755
- objective: node.objective,
756
- instructions: [node.objective],
757
- readFiles: node.taskPacket.readFiles ?? [],
758
- writeFiles: node.taskPacket.writeFiles ?? [],
759
- // Carried, never computed here: the drafter decided which importers it
760
- // will not change, and scope closure exists to force that decision on a
761
- // person rather than answer it for them (src/repo/scope-closure.mjs).
762
- scopeAcknowledged: node.taskPacket.scopeAcknowledged ?? [],
763
- symbols: [],
764
- decisions: [],
765
- nonGoals: [],
766
- verification: node.taskPacket.verification,
767
- },
768
- definitionOfDone: node.definitionOfDone ?? [],
769
- gate,
702
+ export function carryPhaseDeclarations(phases, transformations) {
703
+ if (!phases || phases.length === 0) return phases;
704
+ /** @type {Map<string, string>} */
705
+ const parentOf = new Map();
706
+ for (const transformation of transformations ?? []) {
707
+ const nodes = transformation?.nodes;
708
+ if (Array.isArray(nodes) && nodes.length === 2 && typeof nodes[0] === "string" && typeof nodes[1] === "string") {
709
+ parentOf.set(nodes[0], nodes[1]);
710
+ }
711
+ }
712
+ if (parentOf.size === 0) return phases;
713
+ /** @param {string} id @returns {string} */
714
+ const resolve = (id) => {
715
+ let current = id;
716
+ const seen = new Set();
717
+ while (parentOf.has(current) && !seen.has(current)) {
718
+ seen.add(current);
719
+ current = /** @type {string} */ (parentOf.get(current));
720
+ }
721
+ return current;
770
722
  };
723
+ return phases.map((phase) => {
724
+ if (phase.nodeIds === undefined) return phase;
725
+ return { ...phase, nodeIds: [...new Set(phase.nodeIds.map(resolve))] };
726
+ });
771
727
  }
772
728
 
773
729
  /**
774
- * Lines in a file the plan declares as a read, or null when it cannot be
775
- * counted (absent, a directory, unreadable). Exploratory sizing is measured
776
- * against this: what a node must read is what it is paid for.
730
+ * Write the pipeline's final frozen plan record and the `plan.json.sha256`
731
+ * sidecar over those exact bytes. This is the last write to plan.json: after
732
+ * it returns, the sidecar and the plan agree, and neither may be rewritten.
777
733
  *
778
- * @param {string} path
779
- * @returns {number|null}
734
+ * @param {string} outDir
735
+ * @param {import("./freeze.mjs").FrozenPlan} frozen
736
+ * @param {{status: "frozen", approved: boolean}} outcome
737
+ * @returns {import("./freeze.mjs").FrozenPlan}
780
738
  */
781
- function fileLineCount(path) {
782
- try {
783
- const text = readFileSync(path, "utf8");
784
- if (text === "") return 0;
785
- return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
786
- } catch {
787
- return null;
788
- }
739
+ export function writeFrozenPlan(outDir, frozen, outcome) {
740
+ return writeFrozenPlanRecord(outDir, { ...frozen, ...outcome });
789
741
  }
@@ -10,10 +10,12 @@
10
10
  * artefact under review, never the author's packet, transcript or summary.
11
11
  *
12
12
  * The plan output validator also owns requirement traceability: a plan
13
- * declares, per phase, the requirement ids it satisfies and its one-sentence
14
- * deliverable, and a phase associated with no requirement is reported as a
15
- * finding — never a silent pass, never a refusal. freeze.mjs imports the same
16
- * phase check for the frozen plan record.
13
+ * declares, per phase, the requirement ids it satisfies, the node ids it
14
+ * assigns, and its one-sentence deliverable. A planned node belongs to exactly
15
+ * one declaration, so a missing, duplicate, or unknown node assignment is a
16
+ * refusal. A legacy declaration that names no requirements and no nodes is
17
+ * still reported as a finding, never a silent pass. freeze.mjs imports the
18
+ * same phase check for the frozen plan record.
17
19
  */
18
20
  import { assertObject, positiveInteger, rejectUnknown, requireId, requireString, requireStringArray } from "../contract/assert.mjs";
19
21
  import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../contract/index.mjs";
@@ -26,7 +28,7 @@ import { validateVerificationCommands } from "../contract/verification.mjs";
26
28
  /** @typedef {{campaignId: string, phase: string, n: number, goal?: string, cwd?: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, specPath?: string, repoFactsPath?: string, cataloguePath?: string, packageMode?: import("./sizing.mjs").PackageMode, planPath?: string, findingsPath?: string, notesPath?: string}} PlanningContractInputs */
27
29
  /** @typedef {{id: string, objective: string, taskKind: string, riskTier: RiskTier, dependsOn: string[], readFiles: string[], writeFiles: string[], scopeAcknowledged: string[], definitionOfDone: import("../contract/definition-of-done.mjs").DefinitionOfDoneItem[], verification: import("../contract/verification.mjs").VerificationCommand[], expectedTurns?: number}} PlanOutputNode */
28
30
  /** @typedef {{nodes: PlanOutputNode[], phases?: PlanPhase[], findings?: PlanFindingOutput[], justification?: string}} PlanOutput */
29
- /** @typedef {{id: string, requirementIds: string[], deliverable: string}} PlanPhase */
31
+ /** @typedef {{id: string, requirementIds: string[], nodeIds?: string[], deliverable: string}} PlanPhase */
30
32
  /** @typedef {{id: string, severity: "critical"|"major"|"minor", nodeId: string, text: string}} PlanFindingOutput */
31
33
 
32
34
  /** The taskKind catalogue a draft or revise classifies against. */
@@ -109,7 +111,7 @@ const REQUIRED_INPUTS = Object.freeze({
109
111
  // run had already succeeded. The id charset is requireId's
110
112
  // (contract/assert.mjs) verbatim, because an id that is present but invalid
111
113
  // fails that same validator just as late.
112
- const PLAN_OUTPUT_SHAPE = '{nodes: [{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, scopeAcknowledged, definitionOfDone: [{id, text, proof?: {kind: "command"|"path"|"verification", ref}, judgment?: true}], verification: [{argv: [string], cwd?, timeoutSec?, repeat?, env?, mutation?: {threshold}}], expectedTurns?}], phases?: [{id, requirementIds?: [string], deliverable}], justification?}; every id in it (node, phase, and definitionOfDone item) must match [A-Za-z0-9._-]+ and never be exactly "." or ".."';
114
+ const PLAN_OUTPUT_SHAPE = '{nodes: [{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, scopeAcknowledged, definitionOfDone: [{id, text, proof?: {kind: "command"|"path"|"verification", ref}, judgment?: true}], verification: [{argv: [string], cwd?, timeoutSec?, repeat?, env?, mutation?: {threshold}}], expectedTurns?}], phases?: [{id, requirementIds: [string], nodeIds: [string], deliverable}], justification?}; every id in it (node, phase, node assignment, and definitionOfDone item) must match [A-Za-z0-9._-]+ and never be exactly "." or ".."';
113
115
  /**
114
116
  * The size guidance every draft and revise carries. measured 2026-09-20 over
115
117
  * stored runs: median 49 provider requests per worker turn; cost per turn
@@ -165,7 +167,7 @@ const OBJECTIVES = Object.freeze({
165
167
  const INSTRUCTIONS = Object.freeze({
166
168
  draft: [
167
169
  `Consult the ${TASK_KIND_CATALOGUE_FILE} in readFiles before classifying any node; taskKind must be one of that catalogue and riskTier must be one of ${RISK_TIERS.join(", ")}.`,
168
- "Declare every phase the plan serves in output.plan.phases: the requirement ids (R<n> from the spec) the phase satisfies and the deliverable it produces in one sentence. A phase associated with no requirement is reported as a finding, not refused.",
170
+ "Declare every phase the plan serves in output.plan.phases: the requirement ids (R<n> from the spec) the phase satisfies, the planned node ids it assigns, and the deliverable it produces in one sentence. Every planned node must appear in exactly one phase's nodeIds; a missing, duplicate, or unknown node assignment is refused.",
169
171
  ...SCOPE_CLOSURE_RULE,
170
172
  `Return exactly one worker-result JSON object. Put the plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
171
173
  "Never name a runtime, harness, model, or vendor anywhere in output.plan. taskKind and riskTier are the only classification a draft makes; a routing table assigns a runtime afterward, from those two fields alone.",
@@ -173,7 +175,7 @@ const INSTRUCTIONS = Object.freeze({
173
175
  ],
174
176
  revise: [
175
177
  "Read the findings and resolve every one; do not leave a critical or major finding unaddressed.",
176
- "Declare every phase the plan serves in output.plan.phases: the requirement ids (R<n> from the spec) the phase satisfies and the deliverable it produces in one sentence. A phase associated with no requirement is reported as a finding, not refused.",
178
+ "Declare every phase the plan serves in output.plan.phases: the requirement ids (R<n> from the spec) the phase satisfies, the planned node ids it assigns, and the deliverable it produces in one sentence. Every planned node must appear in exactly one phase's nodeIds; a missing, duplicate, or unknown node assignment is refused.",
177
179
  ...SCOPE_CLOSURE_RULE,
178
180
  `Return exactly one worker-result JSON object. Put the revised plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
179
181
  "Never name a runtime, harness, model, or vendor anywhere in output.plan.",
@@ -344,7 +346,7 @@ export function validatePlanOutput(plan) {
344
346
  });
345
347
  });
346
348
  if (record.justification !== undefined) requireString(record.justification, "plan.justification");
347
- const phases = validatePlanPhases(record.phases);
349
+ const phases = validatePlanPhases(record.phases, nodes.map((node) => node.id));
348
350
  const findings = (phases ?? [])
349
351
  .filter((phase) => phase.requirementIds.length === 0)
350
352
  .map((phase) => /** @type {PlanFindingOutput} */ ({
@@ -364,41 +366,102 @@ export function validatePlanOutput(plan) {
364
366
  };
365
367
  }
366
368
 
367
- // The fields a phase declaration carries beyond its id: requirementIds|deliverable —
368
- // which spec requirements the phase satisfies, and the one sentence naming its result.
369
- const PLAN_PHASE_FIELDS = new Set(["id", "requirementIds", "deliverable"]);
369
+ // The fields a phase declaration carries beyond its id: requirementIds|nodeIds|deliverable —
370
+ // which spec requirements the phase satisfies, which planned nodes it assigns, and the one
371
+ // sentence naming its result.
372
+ const PLAN_PHASE_FIELDS = new Set(["id", "requirementIds", "nodeIds", "deliverable"]);
370
373
 
371
374
  /**
372
- * Validate a plan's `phases` — the per-phase requirement declarations: the
373
- * requirement ids (R<n> from the spec) each phase satisfies and the
374
- * deliverable it produces in one sentence. Absent, empty, and requirement-less
375
- * are legal here: a support phase with no requirement is the caller's finding
376
- * to report (validatePlanOutput does), so the gap stays visible without a
377
- * malformed-but-honest plan being refused. Shared with freeze.mjs, which holds
378
- * the same declarations on the frozen plan record.
375
+ * Validate a plan's `phases` — the per-phase requirement declarations. The
376
+ * current shape `{id, requirementIds, nodeIds, deliverable}` assigns every
377
+ * planned node to exactly one declaration: `plannedNodeIds` is the plan's own
378
+ * node-id list, and a node no declaration names, a node two declarations both
379
+ * name, or a declared node absent from the plan is refused. A declaration in
380
+ * the older `{id, requirementIds, deliverable}` shape (no `nodeIds` at all)
381
+ * stays legal so a plan frozen before node assignment existed still reads; in
382
+ * that shape an empty `requirementIds` is the caller's finding to report
383
+ * (validatePlanOutput does), not a refusal. Mixing the two shapes is refused
384
+ * because it would leave some nodes silently unattributed. Shared with
385
+ * freeze.mjs, which holds the same declarations on the frozen plan record.
379
386
  *
380
387
  * @param {unknown} phases
388
+ * @param {string[]|undefined} [plannedNodeIds] the plan's node ids, when the caller has them
381
389
  * @returns {PlanPhase[]|undefined} the normalized declarations, or undefined when none were given
382
390
  */
383
- export function validatePlanPhases(phases) {
391
+ export function validatePlanPhases(phases, plannedNodeIds) {
384
392
  if (phases === undefined) return undefined;
385
393
  if (!Array.isArray(phases)) throw new TypeError("plan.phases must be an array of phase declarations");
386
394
  if (phases.length === 0) return undefined;
387
- return phases.map((phase, index) => {
395
+
396
+ /** @type {Set<string>} */
397
+ const ids = new Set();
398
+ const declarations = phases.map((phase, index) => {
388
399
  const label = `plan.phases[${index}]`;
389
400
  assertObject(phase, label);
390
401
  const record = /** @type {Record<string, unknown>} */ (phase);
391
402
  rejectUnknown(record, PLAN_PHASE_FIELDS, label);
392
403
  requireId(record.id, `${label}.id`);
404
+ const id = /** @type {string} */ (record.id);
405
+ if (ids.has(id)) throw new TypeError(`plan.phases has duplicate phase id ${id}`);
406
+ ids.add(id);
393
407
  requireString(record.deliverable, `${label}.deliverable`);
394
- const requirementIds = record.requirementIds ?? [];
408
+ const requirementIds = /** @type {string[]} */ (record.requirementIds ?? []);
395
409
  requireStringArray(requirementIds, `${label}.requirementIds`);
396
- return {
397
- id: /** @type {string} */ (record.id),
410
+ /** @type {string[]|undefined} */
411
+ let nodeIds;
412
+ if (record.nodeIds !== undefined) {
413
+ requireStringArray(record.nodeIds, `${label}.nodeIds`);
414
+ nodeIds = /** @type {string[]} */ (record.nodeIds);
415
+ if (nodeIds.length === 0) throw new TypeError(`${label}.nodeIds must name at least one planned node`);
416
+ if (requirementIds.length === 0) throw new TypeError(`${label}.requirementIds must name at least one requirement when the declaration assigns nodes`);
417
+ }
418
+ return /** @type {PlanPhase} */ ({
419
+ id,
398
420
  requirementIds: /** @type {string[]} */ (requirementIds),
421
+ ...(nodeIds === undefined ? {} : { nodeIds }),
399
422
  deliverable: /** @type {string} */ (record.deliverable),
400
- };
423
+ });
401
424
  });
425
+
426
+ const assigned = declarations.filter((declaration) => declaration.nodeIds !== undefined);
427
+ if (assigned.length > 0 && assigned.length !== declarations.length) {
428
+ throw new TypeError("plan.phases must assign nodeIds on every declaration or on none: mixing the two leaves the nodes named by the other declarations unattributed");
429
+ }
430
+ if (assigned.length > 0 && plannedNodeIds !== undefined) {
431
+ validateNodeAssignments(assigned, plannedNodeIds);
432
+ }
433
+ return declarations;
434
+ }
435
+
436
+ /**
437
+ * Refuse a node assignment that does not cover the plan exactly once. Called
438
+ * only for the nodeIds shape, where every declaration already names at least
439
+ * one node and at least one requirement.
440
+ *
441
+ * @param {PlanPhase[]} declarations
442
+ * @param {string[]} plannedNodeIds
443
+ * @returns {void}
444
+ */
445
+ function validateNodeAssignments(declarations, plannedNodeIds) {
446
+ const planned = new Set(plannedNodeIds);
447
+ /** @type {Map<string, string>} */
448
+ const owner = new Map();
449
+ for (const declaration of declarations) {
450
+ for (const nodeId of declaration.nodeIds ?? []) {
451
+ if (!planned.has(nodeId)) {
452
+ throw new TypeError(`plan.phases: phase ${declaration.id} assigns unknown node ${nodeId}, which is not one of the plan's nodes`);
453
+ }
454
+ const previous = owner.get(nodeId);
455
+ if (previous !== undefined) {
456
+ throw new TypeError(`plan.phases assigns node ${nodeId} to both ${previous} and ${declaration.id}; every planned node belongs to exactly one phase`);
457
+ }
458
+ owner.set(nodeId, declaration.id);
459
+ }
460
+ }
461
+ const missing = [...planned].filter((nodeId) => !owner.has(nodeId));
462
+ if (missing.length > 0) {
463
+ throw new TypeError(`plan.phases leaves planned node(s) assigned to no phase: ${missing.join(", ")}`);
464
+ }
402
465
  }
403
466
 
404
467
  const FINDING_FIELDS = new Set(["id", "severity", "nodeId", "text"]);
@@ -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 {