audit-tools 0.50.11 → 0.50.13

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.
Files changed (53) hide show
  1. package/dist/remediate/contractPipeline/derive.d.ts +7 -7
  2. package/dist/remediate/contractPipeline/derive.d.ts.map +1 -1
  3. package/dist/remediate/contractPipeline/derive.js +19 -21
  4. package/dist/remediate/contractPipeline/derive.js.map +1 -1
  5. package/dist/remediate/contractPipeline/phaseCut.d.ts +46 -8
  6. package/dist/remediate/contractPipeline/phaseCut.d.ts.map +1 -1
  7. package/dist/remediate/contractPipeline/phaseCut.js +71 -40
  8. package/dist/remediate/contractPipeline/phaseCut.js.map +1 -1
  9. package/dist/remediate/contractPipeline/phaseCutArtifact.d.ts +1 -1
  10. package/dist/remediate/contractPipeline/phaseCutArtifact.js +1 -1
  11. package/dist/remediate/contractPipeline/repairState.d.ts +108 -0
  12. package/dist/remediate/contractPipeline/repairState.d.ts.map +1 -0
  13. package/dist/remediate/contractPipeline/repairState.js +184 -0
  14. package/dist/remediate/contractPipeline/repairState.js.map +1 -0
  15. package/dist/remediate/index.d.ts.map +1 -1
  16. package/dist/remediate/index.js +4 -0
  17. package/dist/remediate/index.js.map +1 -1
  18. package/dist/remediate/state/types.d.ts +62 -0
  19. package/dist/remediate/state/types.d.ts.map +1 -1
  20. package/dist/remediate/state/types.js +17 -0
  21. package/dist/remediate/state/types.js.map +1 -1
  22. package/dist/remediate/steps/contractPipeline.d.ts +8 -0
  23. package/dist/remediate/steps/contractPipeline.d.ts.map +1 -1
  24. package/dist/remediate/steps/contractPipeline.js +192 -58
  25. package/dist/remediate/steps/contractPipeline.js.map +1 -1
  26. package/dist/remediate/steps/contractPipelinePrompts.d.ts.map +1 -1
  27. package/dist/remediate/steps/contractPipelinePrompts.js +3 -2
  28. package/dist/remediate/steps/contractPipelinePrompts.js.map +1 -1
  29. package/dist/remediate/steps/dispatch/hostHandoff.d.ts +8 -0
  30. package/dist/remediate/steps/dispatch/hostHandoff.d.ts.map +1 -1
  31. package/dist/remediate/steps/dispatch/hostHandoff.js +73 -2
  32. package/dist/remediate/steps/dispatch/hostHandoff.js.map +1 -1
  33. package/dist/remediate/steps/nextStep.d.ts.map +1 -1
  34. package/dist/remediate/steps/nextStep.js +94 -1
  35. package/dist/remediate/steps/nextStep.js.map +1 -1
  36. package/dist/remediate/steps/prompts.d.ts.map +1 -1
  37. package/dist/remediate/steps/prompts.js +20 -2
  38. package/dist/remediate/steps/prompts.js.map +1 -1
  39. package/dist/remediate/steps/types.d.ts +2 -2
  40. package/dist/remediate/steps/types.d.ts.map +1 -1
  41. package/dist/remediate/steps/types.js +6 -2
  42. package/dist/remediate/steps/types.js.map +1 -1
  43. package/dist/remediate/validation/artifacts.d.ts.map +1 -1
  44. package/dist/remediate/validation/artifacts.js +4 -0
  45. package/dist/remediate/validation/artifacts.js.map +1 -1
  46. package/dist/remediate/validation/contractPipeline.d.ts.map +1 -1
  47. package/dist/remediate/validation/contractPipeline.js +14 -0
  48. package/dist/remediate/validation/contractPipeline.js.map +1 -1
  49. package/dist/remediate/validation/contractPipelineGates.d.ts +9 -2
  50. package/dist/remediate/validation/contractPipelineGates.d.ts.map +1 -1
  51. package/dist/remediate/validation/contractPipelineGates.js +13 -7
  52. package/dist/remediate/validation/contractPipelineGates.js.map +1 -1
  53. package/package.json +1 -1
@@ -24,17 +24,17 @@ import { mkdir, readFile, rename, rm } from "node:fs/promises";
24
24
  import { isAbsolute, join, resolve } from "node:path";
25
25
  import { writeJsonFile, readOptionalJsonFile, formatValidationIssues, hashContent, isRecord, withFsRetry, projectApprovedFindings, captureStepBoundaryFriction, climbOutOfAuditTools, partitionCommandsByDeclaredShape, normalizeRepoPath, repoRelativePath, toPosixPath, } from "audit-tools/shared";
26
26
  import { createStepEmissionScaffold, } from "../../shared/steps/stepEmissionScaffold.js";
27
- import { counterexampleFingerprint } from "../contractPipeline/counterexampleFingerprint.js";
28
27
  import { OBLIGATION_KIND_PRIORITY, } from "../contractPipeline/obligationKinds.js";
29
28
  import { CP_ARTIFACT_NAMES, contractArtifactExists, contractArtifactFilePath, contractInputFilePath, contractPipelineDir, detectStaleArtifacts, envelopePayload, envelopeSemanticHash, isEnvelope, pathASeedFilePath, payloadSemanticHash, readContractArtifact, stampToolCreatedAt, writeContractArtifact, writeDerivedContractArtifact, } from "../contractPipeline/artifactStore.js";
30
29
  import { readIntakeRiskSignal, writeIntakeRiskSignal, escalateRiskSignal, decompositionRiskEvidence, adversarialDepthForTier, roundTripGranularityForTier, } from "../riskSignal.js";
31
- import { phaseOrdinalForObligations, moduleSlug, renderPhaseCutSection, } from "../contractPipeline/phaseCut.js";
30
+ import { phaseOrdinalForObligations, moduleSlug, moduleSlugForObligationId, renderPhaseCutSection, detectContractTokenCycles, } from "../contractPipeline/phaseCut.js";
32
31
  import { ensurePhaseCutArtifact, readPhaseCutArtifact } from "../contractPipeline/phaseCutArtifact.js";
33
32
  import { detectCyclicSeamObligations, validateAuthoredCycleBreak, } from "../contractPipeline/cyclicSeamResolution.js";
34
33
  import { deriveObligationLedger, deriveFinalizedModuleContracts, buildTestValidatorPlanScaffold, buildImplementationDagScaffold, acceptedCounterexampleIds, advisoryCritiqueItems, } from "../contractPipeline/derive.js";
35
34
  import { ensureNodeId, toBlockId } from "../contractPipeline/idRegistry.js";
36
35
  import { captureReviewSnapshot, computeReReviewDelta, isReviewArtifact, readReviewSnapshot, renderReReviewSection, reviewSnapshotExists, } from "../contractPipeline/reviewSnapshot.js";
37
36
  import { captureTestPlanCarry, readTestPlanCarry, } from "../contractPipeline/testPlanCarry.js";
37
+ import { readRepairState, writeRepairState, counterexamplesByIdOf, counterexampleKeyOf, counterexampleWaiversPath, foldCounterexampleWaivers, waivedAcceptedIds, waivedJudgeAcceptedIds, } from "../contractPipeline/repairState.js";
38
38
  import { renderContractPipelinePrompt, renderContractRepairPrompt, CONTRACT_PIPELINE_PHASE_ORDER, PHASE_TO_ARTIFACT, } from "./contractPipelinePrompts.js";
39
39
  // The seven cross-artifact validators this module used to call one by one are
40
40
  // gone from this list on purpose: every one of them is now reached through
@@ -123,22 +123,6 @@ export const MAX_DAG_REGENERATION_ATTEMPTS = 2;
123
123
  * (and, if that also fails, to `blocked`).
124
124
  */
125
125
  export const MAX_CYCLIC_SEAM_RESOLUTION_ATTEMPTS = 2;
126
- function repairStatePath(artifactsDir) {
127
- return join(contractPipelineDir(artifactsDir), "repair-state.json");
128
- }
129
- async function readRepairState(artifactsDir) {
130
- const state = await readOptionalJsonFile(repairStatePath(artifactsDir));
131
- return (state ?? {
132
- schema_version: "remediate-code-contract-pipeline/repair-state/v1alpha1",
133
- repairs: [],
134
- critique_repairs: [],
135
- dag_regenerations: [],
136
- });
137
- }
138
- async function writeRepairState(artifactsDir, state) {
139
- await mkdir(contractPipelineDir(artifactsDir), { recursive: true });
140
- await writeJsonFile(repairStatePath(artifactsDir), state);
141
- }
142
126
  function cyclicSeamRepairStatePath(artifactsDir) {
143
127
  return join(contractPipelineDir(artifactsDir), "cyclic-seam-repair-state.json");
144
128
  }
@@ -561,7 +545,47 @@ async function evaluateJudgeGate(artifactsDir) {
561
545
  const judge = envelopePayload(judgeEnvelope);
562
546
  if (!judge || judge.verdict === "approved")
563
547
  return { kind: "proceed" };
548
+ // Content-fingerprint keying (not raw id): two independent adversarial
549
+ // rounds may each label their genuinely-distinct top counterexample with
550
+ // the SAME reviewer id string (e.g. "CE-001", the prompt schema's own
551
+ // example value). Keying convergence on the raw id would then read "same CE
552
+ // re-accepted after a repair" and falsely escalate while a real new defect
553
+ // is being correctly repaired. Resolve each accepted id against the live
554
+ // counterexample artifact and key on content instead; an id with no
555
+ // matching counterexample falls back to raw-id keying — today's behavior —
556
+ // so nothing regresses when content can't be resolved. The keying is
557
+ // single-sourced with the waiver ledger (counterexampleKeyOf).
558
+ const cePayload = envelopePayload(await readContractArtifact(artifactsDir, "counterexample"));
559
+ const ceById = counterexamplesByIdOf(cePayload);
560
+ const keyOf = (rawId) => counterexampleKeyOf(ceById, rawId);
561
+ const acceptedIds = acceptedCeIdsOf(judge);
562
+ // Owner waivers (open-bugs.md:108, the recorded resolution verb): fold the
563
+ // host-written waiver file BEFORE any convergence math, so a waiver recorded
564
+ // against a blocked escalation unblocks this same invocation — including one
565
+ // recorded after a repair for this judge hash was already dispatched. An
566
+ // invalid file escalates loudly and applies NOTHING (never half-applied).
567
+ const fold = await foldCounterexampleWaivers(artifactsDir, {
568
+ counterexamplesById: ceById,
569
+ judgeAcceptedIds: new Set(acceptedIds),
570
+ });
571
+ if (fold.issues.length > 0) {
572
+ return {
573
+ kind: "escalate",
574
+ reason: "invalid_waivers",
575
+ outstanding: acceptedIds,
576
+ waiverIssues: fold.issues,
577
+ note: "The counterexample waiver file was refused and nothing was applied. " +
578
+ "Fix or delete it, then re-run next-step.",
579
+ };
580
+ }
564
581
  const repairState = await readRepairState(artifactsDir);
582
+ const waived = waivedAcceptedIds(repairState, ceById, acceptedIds);
583
+ const unwaivedAccepted = acceptedIds.filter((id) => !waived.has(id));
584
+ // Every accepted counterexample carries a recorded owner waiver → the
585
+ // needs_repair verdict is resolved by decision: proceed.
586
+ if (acceptedIds.length > 0 && unwaivedAccepted.length === 0) {
587
+ return { kind: "proceed" };
588
+ }
565
589
  const judgeHash = judgeEnvelope.content_hash;
566
590
  const alreadyHandled = repairState.repairs.some((repair) => repair.judge_hash === judgeHash);
567
591
  // Map judge.repair_directive.target if present; if absent, infer from classifications.
@@ -574,25 +598,9 @@ async function evaluateJudgeGate(artifactsDir) {
574
598
  instruction: rawDirective.instruction,
575
599
  }
576
600
  : inferRepairDirective(judge);
577
- const acceptedIds = acceptedCeIdsOf(judge);
578
- // Content-fingerprint keying (not raw id): two independent adversarial
579
- // rounds may each label their genuinely-distinct top counterexample with
580
- // the SAME reviewer id string (e.g. "CE-001", the prompt schema's own
581
- // example value). Keying convergence on the raw id would then read "same CE
582
- // re-accepted after a repair" and falsely escalate while a real new defect
583
- // is being correctly repaired. Resolve each accepted id against the live
584
- // counterexample artifact and key on content instead; an id with no
585
- // matching counterexample falls back to raw-id keying — today's behavior —
586
- // so nothing regresses when content can't be resolved.
587
- const cePayload = envelopePayload(await readContractArtifact(artifactsDir, "counterexample"));
588
- const ceById = new Map((cePayload?.counterexamples ?? []).map((ce) => [ce.id, ce]));
589
- const keyOf = (rawId) => {
590
- const ce = ceById.get(rawId);
591
- return ce ? `fp:${counterexampleFingerprint(ce)}` : `id:${rawId}`;
592
- };
593
601
  const addressed = new Set(repairState.repairs.flatMap((r) => r.addressed_ce_fingerprints ??
594
602
  (r.accepted_ce_ids ?? []).map((id) => `id:${id}`)));
595
- const newAccepted = acceptedIds.filter((id) => !addressed.has(keyOf(id)));
603
+ const newAccepted = unwaivedAccepted.filter((id) => !addressed.has(keyOf(id)));
596
604
  const newAcceptedFingerprints = newAccepted.map(keyOf);
597
605
  // Idempotent re-entry: this exact judge report already drove a repair (its hash
598
606
  // is recorded). Re-emit the same repair directive; do not re-evaluate convergence
@@ -611,7 +619,7 @@ async function evaluateJudgeGate(artifactsDir) {
611
619
  return {
612
620
  kind: "escalate",
613
621
  reason: "runaway",
614
- outstanding: acceptedIds,
622
+ outstanding: unwaivedAccepted,
615
623
  note: `The judge↔repair loop reached its runaway backstop (${repairState.repairs.length} repair rounds) without converging. Each round was still surfacing accepted counterexamples. This is pathological non-convergence — review the outstanding counterexamples and the contract design with the user before proceeding.`,
616
624
  };
617
625
  }
@@ -630,8 +638,8 @@ async function evaluateJudgeGate(artifactsDir) {
630
638
  return {
631
639
  kind: "escalate",
632
640
  reason: "stall",
633
- outstanding: acceptedIds,
634
- note: `The judge re-accepted counterexample(s) that a prior repair already addressed (${acceptedIds.join(", ") || "none newly accepted"}), with no new accepted counterexample this round. The repair loop is not converging on these items. Resolve them with the user — adjust the contract design or accept the counterexamples as known limitations — before the plan can be promoted.`,
641
+ outstanding: unwaivedAccepted,
642
+ note: `The judge re-accepted counterexample(s) that a prior repair already addressed (${unwaivedAccepted.join(", ") || "none newly accepted"}), with no new accepted counterexample this round. The repair loop is not converging on these items. Resolve them with the user — adjust the contract design, or record an owner waiver accepting them as known limitations — before the plan can be promoted.`,
635
643
  };
636
644
  }
637
645
  /** Blocking-severity critique item ids from a conceptual_design_critique payload. */
@@ -1485,7 +1493,16 @@ async function readCrossGateInputs(artifactsDir, root) {
1485
1493
  payloads.set(name, envelopePayload(envelope));
1486
1494
  }
1487
1495
  const findingEnumeration = await readOptionalJsonFile(intakePaths(artifactsDir).findingEnumeration);
1488
- return { payloads, findingEnumeration, root };
1496
+ // Waived counterexamples (open-bugs.md:108): the coverage gates must not
1497
+ // demand DAG nodes for a counterexample the owner recorded as an accepted
1498
+ // limitation — that would recreate the judge-gate wedge one gate later.
1499
+ const repairState = await readRepairState(artifactsDir);
1500
+ return {
1501
+ payloads,
1502
+ findingEnumeration,
1503
+ root,
1504
+ waivedCounterexampleIds: waivedJudgeAcceptedIds(repairState, payloads.get("judge_report"), payloads.get("counterexample")),
1505
+ };
1489
1506
  }
1490
1507
  /**
1491
1508
  * Read every contract-pipeline payload FRESH for the shared cross-gates.
@@ -1922,21 +1939,56 @@ const degenerateSeamReconciliationGate = async (ctx) => {
1922
1939
  });
1923
1940
  return { via: "rederive" };
1924
1941
  };
1942
+ /** Render the cycle section for the LLM finalization step: every declared-graph
1943
+ * cycle, its members, and the exact artifact tokens forming each edge. */
1944
+ function renderTokenCycleSection(cycles) {
1945
+ const parts = cycles.map((cycle, i) => {
1946
+ const edges = cycle.edges
1947
+ .map((e) => `- \`${e.consumer}\` depends on \`${e.producer}\` via \`artifact:${e.artifact}\` ` +
1948
+ `(${e.consumer} consumes it; ${e.producer} produces it)`)
1949
+ .join("\n");
1950
+ return `### Cycle ${i + 1}: [${cycle.members.join(", ")}]\n\n${edges}`;
1951
+ });
1952
+ return `## Cyclic Artifact-Token Dependencies — Resolve These In Your Output
1953
+
1954
+ The drafted contracts declare a CYCLIC artifact-token flow. Implementation ordering derives from
1955
+ producer/consumer \`artifact:<name>\` tokens ALONE, so the declared flow must be acyclic — a cycle
1956
+ cannot be phased, and the finalized contracts are REJECTED at validation while one remains.
1957
+
1958
+ ${parts.join("\n\n")}
1959
+
1960
+ Rewrite the finalized \`inputs\`/\`outputs\` so one direction owns each flow: move an artifact token
1961
+ to the module that genuinely produces it, split a shared primitive into an earlier module's output,
1962
+ or drop a token that does not describe a real data handoff. Keep the module SET unchanged — do not
1963
+ add, drop, rename, or merge modules.`;
1964
+ }
1925
1965
  /**
1926
1966
  * Deterministic contract_finalization (all module counts). Finalization is a
1927
- * mechanical merge, not fresh authoring: carry each drafted module contract
1928
- * verbatim (preserving neighbor_needs for the ordering derivation) and attach
1967
+ * mechanical merge, not fresh authoring: carry each drafted module contract's
1968
+ * interface fields verbatim (dropping neighbor_needs ordering derives from
1969
+ * the artifact-token graph alone, open-bugs.md:106) and attach
1929
1970
  * the agreed_interface of every seam that touches the module as a
1930
1971
  * seam_adjustment. The judgment already happened at seam_reconciliation.
1931
1972
  * Attaching each agreed interface verbatim guarantees the INV-CO-12
1932
1973
  * reconciliation-derivation gate passes. A downstream gate that still finds the
1933
1974
  * merge inadequate re-emits contract_finalization as an LLM step — the only path
1934
- * that still needs judgment.
1975
+ * that still needs judgment. A CYCLIC declared token graph takes that LLM path
1976
+ * up front: the mechanical merge would carry the cycle verbatim into an
1977
+ * artifact validation refuses, so the gate emits the finalization step with the
1978
+ * cycle named instead of deriving.
1935
1979
  */
1936
1980
  const contractFinalizationDerivationGate = async (ctx) => {
1937
1981
  if (ctx.nextPhase !== "contract_finalization")
1938
1982
  return null;
1939
1983
  const drafted = envelopePayload(await readContractArtifact(ctx.artifactsDir, "module_contracts"));
1984
+ const cycles = detectContractTokenCycles(drafted);
1985
+ if (cycles.length > 0) {
1986
+ return {
1987
+ via: "phase",
1988
+ phase: "contract_finalization",
1989
+ extraSection: renderTokenCycleSection(cycles),
1990
+ };
1991
+ }
1940
1992
  const seamReport = envelopePayload(await readContractArtifact(ctx.artifactsDir, "seam_reconciliation_report"));
1941
1993
  await writeDerivedContractArtifact(ctx.artifactsDir, "finalized_module_contracts", deriveFinalizedModuleContracts(drafted, seamReport));
1942
1994
  return { via: "rederive" };
@@ -1988,19 +2040,44 @@ const judgeRepairGate = async (ctx) => {
1988
2040
  note: `Judge↔repair loop escalated (${gate.reason}): ${gate.note}`,
1989
2041
  category: "trap",
1990
2042
  }, "remediate-code");
2043
+ const waiversPath = counterexampleWaiversPath(ctx.artifactsDir);
2044
+ const waiverIssuesSection = gate.waiverIssues && gate.waiverIssues.length > 0
2045
+ ? `\n\n## Waiver file refused — fix these first\n\n${gate.waiverIssues
2046
+ .map((issue) => `- ${issue}`)
2047
+ .join("\n")}`
2048
+ : "";
2049
+ const heading = gate.reason === "invalid_waivers"
2050
+ ? "# The Counterexample Waiver File Was Refused"
2051
+ : "# Judge↔Repair Loop Did Not Converge";
1991
2052
  return {
1992
2053
  via: "blocked",
1993
- prompt: `# Judge↔Repair Loop Did Not Converge
2054
+ prompt: `${heading}
1994
2055
 
1995
- ${gate.note}
2056
+ ${gate.note}${waiverIssuesSection}
1996
2057
 
1997
- ## Outstanding accepted counterexamples
2058
+ ## Outstanding accepted counterexamples (unwaived)
1998
2059
 
1999
2060
  ${gate.outstanding.length > 0
2000
2061
  ? gate.outstanding.map((id) => `- ${id}`).join("\n")
2001
2062
  : "_(none newly accepted this round)_"}
2002
2063
 
2003
- Read the judge_report and counterexample artifacts, decide with the user how to resolve each outstanding counterexample (revise the contract design and re-run, or accept it as a known limitation), then re-run next-step.`,
2064
+ ## Record an owner waiver (the recorded resolution verb)
2065
+
2066
+ To accept an outstanding counterexample as a KNOWN LIMITATION of this run, write the operator's decision to:
2067
+
2068
+ \`${waiversPath}\`
2069
+
2070
+ \`\`\`json
2071
+ {
2072
+ "waivers": [
2073
+ { "ce_id": "<id from the list above>", "rationale": "<why this is acceptable>", "waived_by": "<who decided>" }
2074
+ ]
2075
+ }
2076
+ \`\`\`
2077
+
2078
+ The next next-step validates the file, records each waiver in repair-state.json (attributable, content-fingerprint-keyed), consumes the file, and proceeds once every outstanding counterexample is repaired or waived. Record a waiver ONLY for a decision the operator actually made — the record names its decider.
2079
+
2080
+ Read the judge_report and counterexample artifacts, decide with the user how to resolve each outstanding counterexample (revise the contract design and re-run, or record a waiver as above), then re-run next-step.`,
2004
2081
  stopCondition: "Stop — the contract pipeline is blocked on a non-converging judge↔repair loop pending a user decision.",
2005
2082
  };
2006
2083
  }
@@ -2585,7 +2662,7 @@ const parallelModuleWaveGate = async (ctx) => {
2585
2662
  * dependency-ordered foundations→consumers phasing, instead of rejecting an
2586
2663
  * arbitrary N-goal change as "over-scoped" and forcing the host to re-scope by
2587
2664
  * hand at intake. The cut is derived from the finalized module contracts'
2588
- * directional neighbor_needs edges and PERSISTED as `phase_cut.json`, so the cut
2665
+ * producer/consumer artifact-token edges and PERSISTED as `phase_cut.json`, so the cut
2589
2666
  * the critic sees and the cut the implementation-DAG promotion enforces are one
2590
2667
  * source. Only injected when there is a genuine multi-phase cut to communicate.
2591
2668
  */
@@ -2927,20 +3004,43 @@ export async function evaluatePromotedPlanWriteScope(artifactsDir, root) {
2927
3004
  for (const block of Array.isArray(plan?.blocks) ? plan.blocks : []) {
2928
3005
  const blockId = typeof block.block_id === "string" ? block.block_id : "(unnamed block)";
2929
3006
  const touched = Array.isArray(block.touched_files) ? block.touched_files : [];
2930
- for (const path of touched) {
2931
- if (typeof path !== "string")
2932
- continue;
2933
- const key = normalizeRepoPath(path);
2934
- const parent = key.includes("/") ? key.slice(0, key.lastIndexOf("/")) : "";
2935
- if (corpus.files.has(key) || parent === "" || corpus.directories.has(parent)) {
2936
- continue;
2937
- }
2938
- violations.push(`Block "${blockId}" declares the write-scope path "${path}", whose directory does ` +
2939
- `not exist in the tracked tree.`);
2940
- }
3007
+ violations.push(...writeScopeCorpusViolations(corpus, touched.filter((path) => typeof path === "string"), `Block "${blockId}"`));
2941
3008
  }
2942
3009
  return violations.length > 0 ? { violations } : null;
2943
3010
  }
3011
+ /**
3012
+ * The ONE tracked-tree membership rule for a declared write-scope path: legal
3013
+ * when the file is tracked, sits at the repo root, or its parent directory
3014
+ * exists in the tracked tree (a block legitimately creates NEW files in
3015
+ * existing directories). Shared by the promotion gate above and the
3016
+ * clarification scope-delta validation, so the two cannot drift.
3017
+ */
3018
+ function writeScopeCorpusViolations(corpus, paths, label) {
3019
+ const violations = [];
3020
+ for (const path of paths) {
3021
+ const key = normalizeRepoPath(path);
3022
+ const parent = key.includes("/") ? key.slice(0, key.lastIndexOf("/")) : "";
3023
+ if (corpus.files.has(key) || parent === "" || corpus.directories.has(parent)) {
3024
+ continue;
3025
+ }
3026
+ violations.push(`${label} declares the write-scope path "${path}", whose directory does ` +
3027
+ `not exist in the tracked tree.`);
3028
+ }
3029
+ return violations;
3030
+ }
3031
+ /**
3032
+ * Tracked-tree parity for a POST-promotion write-scope widening
3033
+ * (open-bugs.md:110): a clarification scope delta must clear the same rule the
3034
+ * promotion gate enforced, or the delta lane becomes a bypass of it. Returns
3035
+ * violation lines; [] on an unreadable tree (fail-open, exactly as the
3036
+ * promotion gate degrades).
3037
+ */
3038
+ export async function checkWriteScopePathsAgainstTrackedTree(root, paths, label) {
3039
+ const corpus = await readTrackedWriteScopeCorpus(root);
3040
+ if (!corpus)
3041
+ return [];
3042
+ return writeScopeCorpusViolations(corpus, paths, label);
3043
+ }
2944
3044
  export function normalizeBlockTargetedCommands(commands, blockId) {
2945
3045
  const partitioned = partitionCommandsByDeclaredShape(commands, (kind, raw) => kind === "empty"
2946
3046
  ? `Block "${blockId}" declares an empty targeted_commands entry.`
@@ -3078,6 +3178,36 @@ export async function promoteImplementationDagToExtractedPlan(artifactsDir, root
3078
3178
  }
3079
3179
  const lastOrdinal = Math.max(0, ...slugToOrdinal.values());
3080
3180
  const hasMultiPhase = phaseCut ? phaseCut.phases.length > 1 : false;
3181
+ // Approved-contract attachment (open-bugs.md:474): resolve each node's
3182
+ // obligation-id slugs against the finalized module contracts, so every
3183
+ // promoted block carries VERBATIM the contract(s) it implements and the
3184
+ // dispatch prompt can bind the worker to the approved interface — the
3185
+ // workflow must never depend on the DAG author restating declared values.
3186
+ const finalizedForBlocks = envelopePayload(await readContractArtifact(artifactsDir, "finalized_module_contracts"));
3187
+ const contractByModuleName = new Map();
3188
+ const contractSlugToName = new Map();
3189
+ for (const mod of finalizedForBlocks?.module_contracts ?? []) {
3190
+ if (isRecord(mod) && typeof mod.name === "string" && mod.name.length > 0) {
3191
+ contractByModuleName.set(mod.name, mod);
3192
+ contractSlugToName.set(moduleSlug(mod.name), mod.name);
3193
+ }
3194
+ }
3195
+ const contractSlugsByLength = [...contractSlugToName.keys()].sort((a, b) => b.length - a.length);
3196
+ const moduleContractsForNode = (node) => {
3197
+ const names = new Set();
3198
+ for (const obligationId of [
3199
+ ...(node.satisfies_obligations ?? []),
3200
+ ...(node.verification_obligation_ids ?? []),
3201
+ ]) {
3202
+ const slug = moduleSlugForObligationId(obligationId, contractSlugsByLength);
3203
+ const name = slug === null ? undefined : contractSlugToName.get(slug);
3204
+ if (name !== undefined)
3205
+ names.add(name);
3206
+ }
3207
+ return [...names]
3208
+ .sort((left, right) => compareCodeUnits(left, right))
3209
+ .map((name) => ({ module: name, contract: contractByModuleName.get(name) }));
3210
+ };
3081
3211
  // Root-cause fix for scope-less nodes: the DAG's write scope
3082
3212
  // (`output_files`/`files_likely_touched`) is host-authored and a coarse
3083
3213
  // "Remediate <module>" decomposition can leave it EMPTY, which promotes a
@@ -3271,6 +3401,7 @@ export async function promoteImplementationDagToExtractedPlan(artifactsDir, root
3271
3401
  ...(node.verification_obligation_ids ?? []),
3272
3402
  ], slugToOrdinal, lastOrdinal)
3273
3403
  : undefined;
3404
+ const blockModuleContracts = moduleContractsForNode(node);
3274
3405
  return {
3275
3406
  block_id: toBlockId(nodeId),
3276
3407
  items: canonicalItemsByNodeId.get(nodeId) ?? [nodeId],
@@ -3283,6 +3414,9 @@ export async function promoteImplementationDagToExtractedPlan(artifactsDir, root
3283
3414
  touched_files: touchedFiles,
3284
3415
  ...(phaseOrdinal !== undefined ? { phase_ordinal: phaseOrdinal } : {}),
3285
3416
  ...(targetedCommands.length > 0 ? { targeted_commands: targetedCommands } : {}),
3417
+ ...(blockModuleContracts.length > 0
3418
+ ? { module_contracts: blockModuleContracts }
3419
+ : {}),
3286
3420
  };
3287
3421
  });
3288
3422
  const extractedPlan = {