@tea-agent/loop-agent 0.44.0-next.7 → 0.44.0-next.9

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 (147) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/dist/application/evaluation/budget.js +21 -0
  3. package/dist/application/evaluation/corpus-hash.js +10 -15
  4. package/dist/application/evaluation/corpus.js +2 -1
  5. package/dist/application/evaluation/frontend-browser-acceptance.js +77 -0
  6. package/dist/application/evaluation/frontend-corpus-browser-acceptance.js +175 -0
  7. package/dist/application/evaluation/frontend-gateway-evidence.js +226 -0
  8. package/dist/application/evaluation/frontend-pair-registration.js +143 -0
  9. package/dist/application/evaluation/frontend-paired-summary.js +150 -0
  10. package/dist/application/evaluation/frontend-run-observation.js +144 -0
  11. package/dist/application/evaluation/frontend-shared-evidence.js +105 -0
  12. package/dist/application/evaluation/types.js +45 -12
  13. package/dist/application/task-lifecycle/observe.js +26 -1
  14. package/dist/build-stamp.json +3 -3
  15. package/dist/cli/command-definitions.js +11 -0
  16. package/dist/cli/program.js +4 -0
  17. package/dist/commands/task-advance.js +3 -5
  18. package/dist/commands/task-source-prepare.js +13 -1
  19. package/dist/executors/dag-pi/guards.js +4 -0
  20. package/dist/executors/dag-pi/model-config.js +30 -0
  21. package/dist/{workflows/dag/frontend-plan-completeness.js → executors/dag-pi/plan/facts.js} +25 -41
  22. package/dist/executors/dag-pi/plan/ledger-contract.js +55 -0
  23. package/dist/executors/dag-pi/tools/contract-tools.js +515 -0
  24. package/dist/executors/dag-pi/tools/design-terminal-tools.js +166 -0
  25. package/dist/executors/dag-pi/tools/plan-decision-tools.js +519 -0
  26. package/dist/executors/dag-pi/tools/review-terminal-tools.js +166 -0
  27. package/dist/executors/dag-pi/tools/scout-evidence-tools.js +270 -0
  28. package/dist/executors/dag-pi-executor.js +198 -118
  29. package/dist/executors/pi-executor.js +6 -1
  30. package/dist/executors/pi-sdk-executor.js +76 -11
  31. package/dist/executors/shell-executor.js +88 -12
  32. package/dist/infrastructure/console/operation-store.js +2 -0
  33. package/dist/infrastructure/evaluation/corpus-store.js +37 -12
  34. package/dist/shared/operator/capabilities.js +10 -5
  35. package/dist/task/config-types.js +22 -9
  36. package/dist/task/contract/project.js +13 -0
  37. package/dist/task/contract/schema.js +2 -8
  38. package/dist/task/frontend-preflight.js +1 -1
  39. package/dist/task/source-prepare/parse-intent.js +124 -5
  40. package/dist/task/source-prepare/prepare.js +6 -1
  41. package/dist/task/source-prepare/semantic-intake.js +18 -3
  42. package/dist/task/source-prepare/source-execution.js +65 -0
  43. package/dist/task/source-prepare/source-fidelity-pi.js +22 -9
  44. package/dist/task/source-prepare/source-provider-budget.js +290 -0
  45. package/dist/worker/console/operator-actions.js +0 -1
  46. package/dist/worker/console/operator-user-error.js +1 -1
  47. package/dist/worker/console/prd-intake-bridge.js +5 -15
  48. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DTuku_-7.js → abnfDiagram-N423BO3Z-r1NAPU_5.js} +1 -1
  49. package/dist/worker/console/static/assets/{arc-CQbCsSqb.js → arc-dT5T4sVL.js} +1 -1
  50. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-C-xUntTR.js → architectureDiagram-T3A2C74G-CP_cOq46.js} +1 -1
  51. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DhNlwGAl.js → blockDiagram-VBNYF7ZC-sToEZxEf.js} +1 -1
  52. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B7j3nx8o.js → c4Diagram-5PPSVZJV-DSxwwi2m.js} +1 -1
  53. package/dist/worker/console/static/assets/channel-CyBY_yGk.js +1 -0
  54. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-BCrcxEh2.js → chunk-2GRJ4B5K-B9fh0IkF.js} +1 -1
  55. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CsaePDb3.js → chunk-2Q5K7J3B-CAUNl-A6.js} +1 -1
  56. package/dist/worker/console/static/assets/{chunk-5RXB4S5H--SRQc1rN.js → chunk-5RXB4S5H-BomeqbNX.js} +1 -1
  57. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-C5BFY2Po.js → chunk-5VM5RSS4-BNAuTOLE.js} +1 -1
  58. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Caso6uN7.js → chunk-6Q2QTUOP-DfzjRD5y.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-AY2ZSAx2.js → chunk-GF5L2VYU-C3uE-yc2.js} +1 -1
  60. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-DiMqZQ_8.js → chunk-JWPE2WC7-C3sQMMYr.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-gpvvriOp.js → chunk-KBJHAD2P-D_AtEYQL.js} +1 -1
  62. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CUdyoJhI.js → chunk-RYQCIY6F-DW4TwhKv.js} +1 -1
  63. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-Bp1xW3T4.js → chunk-XXDRQBXY-CTQDmJcp.js} +1 -1
  64. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-D5lN4E_E.js +1 -0
  65. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-D5lN4E_E.js +1 -0
  66. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-D-JIcOIT.js → cose-bilkent-JH36ORCC-iMqPuBsL.js} +1 -1
  67. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-C-NKFflx.js → cynefin-VYW2F7L2-DktIuDWs.js} +1 -1
  68. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-CSOcvOAA.js → cynefinDiagram-MW4NZA55-BgVnjyEs.js} +1 -1
  69. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BQSBltMy.js → dagre-VZM6K2ZE-CR25E4bu.js} +1 -1
  70. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B0LVC45G.js → diagram-7IWD3JNH-CPM2h5HY.js} +1 -1
  71. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Cypg3nqy.js → diagram-B4RE2ZJO-DqF0x2g1.js} +1 -1
  72. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-BBDU88XJ.js → diagram-LBJQPF4R-G8BYp2eb.js} +1 -1
  73. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BZusLqSb.js → diagram-Q27KOJAE-BbC4kT_D.js} +1 -1
  74. package/dist/worker/console/static/assets/{diagram-UB23O5K3-Cfolotcr.js → diagram-UB23O5K3-CKZwZ-G5.js} +1 -1
  75. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-CjfyUy0m.js → ebnfDiagram-BXEA7PRR-kJDmheia.js} +1 -1
  76. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DJdz7txE.js → erDiagram-JOGREHBK-DFTVHyxB.js} +1 -1
  77. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CQqgPO6y.js → flowDiagram-UKHOOZJN-BqdN5_os.js} +1 -1
  78. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BOpGfS2m.js → ganttDiagram-PKOTCBZU-SYDjVakh.js} +1 -1
  79. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-C6UCu0qx.js → gitGraphDiagram-DS77QQ5N-Bf8bNpGA.js} +1 -1
  80. package/dist/worker/console/static/assets/index-B28Onmfy.css +1 -0
  81. package/dist/worker/console/static/assets/{index-DWKejNVJ.js → index-CTMZsDmh.js} +98 -98
  82. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BWeaVEUB.js → infoDiagram-6WML65LV-DIeAmJ45.js} +1 -1
  83. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-C8gmlBbk.js → ishikawaDiagram-WSZJBQD7-oikqUAlo.js} +1 -1
  84. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DF3bm8Bp.js → journeyDiagram-NVQOT4AX-mKEF_xYT.js} +1 -1
  85. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-D2fG2Vxi.js → kanban-definition-27J2QSJJ-BdWJHPnN.js} +1 -1
  86. package/dist/worker/console/static/assets/{linear-BGKgMc2T.js → linear-DGRlieMH.js} +1 -1
  87. package/dist/worker/console/static/assets/{mermaid.core-DF6mMPJ9.js → mermaid.core-4P9yPC9c.js} +5 -5
  88. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-AMhePheC.js → mindmap-definition-FAOFIHXS-5PTSkos6.js} +1 -1
  89. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DN1Cl0W0.js → pegDiagram-VL7TDLO6-DONIFpG5.js} +1 -1
  90. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-Cx9ngUeN.js → pieDiagram-7S7Q4E2Y-D_Z4S70M.js} +1 -1
  91. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-BcYPTQJb.js → quadrantDiagram-CIZ2JOQS-CAsjh8iY.js} +1 -1
  92. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BqHUZWnH.js → railroadDiagram-AXF67PYL-Cx4Dd6ac.js} +1 -1
  93. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CmqWZ0-B.js → requirementDiagram-LRYGKXZP-cQb7mwyH.js} +1 -1
  94. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P--4YetH1y.js → sankeyDiagram-W5VNT64P-Cd-DhUdf.js} +1 -1
  95. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DGD4VMfD.js → sequenceDiagram-SI44F4Z6-AlKYRgIk.js} +1 -1
  96. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-QVw9q7rh.js → sizeCapture-X5ZJPWSS-CUy_ET4e.js} +1 -1
  97. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-CuemV9pe.js → stateDiagram-OKZ733FA-CTuVe6Sa.js} +1 -1
  98. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BsWhs8O7.js +1 -0
  99. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-C7oKzZKs.js → swimlanes-SLNWSIFB-ChFLJb-v.js} +2 -2
  100. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CJcioYGH.js +8 -0
  101. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-C2Of60nT.js → timeline-definition-Z64GVDOM-DZZeiAPr.js} +1 -1
  102. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DHtpQHyL.js → vennDiagram-T6HMQDX7-t4O91cZU.js} +1 -1
  103. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-COi9OhSy.js → wardleyDiagram-T6FBY63Y-CV5qGHZu.js} +1 -1
  104. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-DPHQfRrt.js → xychartDiagram-ELKLHX3M-DyXbytUI.js} +1 -1
  105. package/dist/worker/console/static/index.html +2 -2
  106. package/dist/workflows/dag/budget-enforcement.js +31 -2
  107. package/dist/workflows/dag/frontend-closeout.js +3 -1
  108. package/dist/workflows/dag/frontend-contract-facts.js +11 -8
  109. package/dist/workflows/dag/frontend-design-policy.js +6 -6
  110. package/dist/workflows/dag/frontend-durable-tools.js +90 -70
  111. package/dist/workflows/dag/frontend-implementation-contract.js +146 -59
  112. package/dist/workflows/dag/frontend-plan-canary.js +66 -16
  113. package/dist/workflows/dag/frontend-plan-decision-contract.js +13 -21
  114. package/dist/workflows/dag/frontend-plan-progress.js +92 -0
  115. package/dist/workflows/dag/frontend-recovery-plan.js +11 -10
  116. package/dist/workflows/dag/frontend-recovery-run.js +51 -46
  117. package/dist/workflows/dag/frontend-repair-assertions.js +36 -0
  118. package/dist/workflows/dag/frontend-review-context.js +29 -1
  119. package/dist/workflows/dag/frontend-review-scopes.js +92 -2
  120. package/dist/workflows/dag/frontend-risk.js +8 -3
  121. package/dist/workflows/dag/frontend-root-observation.js +261 -0
  122. package/dist/workflows/dag/frontend-session-budget.js +52 -39
  123. package/dist/workflows/dag/frontend-session-context.js +10 -0
  124. package/dist/workflows/dag/frontend-test-execution-evidence.js +206 -48
  125. package/dist/workflows/dag/frontend-typed-event-store.js +11 -6
  126. package/dist/workflows/dag/frontend-verification-trace.js +6 -0
  127. package/dist/workflows/dag/frontend-writer-admission.js +2 -1
  128. package/dist/workflows/dag/init-hybrid.js +108 -37
  129. package/dist/workflows/dag/node-execution.js +11 -11
  130. package/dist/workflows/dag/rerun-feedback.js +35 -4
  131. package/dist/workflows/dag/rerun-task.js +62 -67
  132. package/dist/workflows/dag/runner.js +58 -12
  133. package/dist/workflows/dag/types.js +15 -8
  134. package/docs/architecture/runtime-boundaries.md +1 -1
  135. package/docs/templates/backend-test-dag.json +4 -4
  136. package/docs/templates/frontend-implementation-contract.schema.json +31 -11
  137. package/package.json +1 -1
  138. package/skills/frontend-design-review/SKILL.md +8 -11
  139. package/skills/frontend-design-review/references/review-checklist.md +3 -4
  140. package/skills/frontend-review/SKILL.md +5 -1
  141. package/skills/loop-agent/references/command-reference.md +8 -0
  142. package/dist/worker/console/static/assets/channel-5_os7t_x.js +0 -1
  143. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-loz0n18W.js +0 -1
  144. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-loz0n18W.js +0 -1
  145. package/dist/worker/console/static/assets/index-CUT440A8.css +0 -1
  146. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BdlGdIJw.js +0 -1
  147. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DjjbHbde.js +0 -8
@@ -1,19 +1,15 @@
1
+ import { FRONTEND_PLAN_CANARY_SCENARIOS, readFrontendCanaryEvidence, compareFrontendPairIdentity, readFrontendRootObservation } from "./frontend-root-observation.js";
1
2
  import { readFrontendPlanCanaryRunMetrics, } from "./frontend-session-budget.js";
2
- export const FRONTEND_PLAN_CANARY_SCENARIOS = [
3
- "missing-idle-loading-empty",
4
- "loading-not-mounted",
5
- "test-layer-does-not-prove-wiring",
6
- "mock-claimed-as-real-integration",
7
- ];
3
+ export { FRONTEND_PLAN_CANARY_SCENARIOS } from "./frontend-root-observation.js";
8
4
  const reduction = (before, after) => before !== null && after !== null && before > 0 ? ((before - after) / before) * 100 : null;
9
5
  /** Compare two same-input runs. Missing observations fail the canary rather
10
6
  * than being interpreted as a zero-cost candidate. */
11
7
  export function evaluateFrontendPlanCanary(input) {
12
- const required = { planSessionsPct: 30, planWallClockPct: 30, protocolRetryPct: 50, ...input.requiredReductions };
8
+ const required = { planSessionsPct: 30, planWallClockPct: 30, factReplacementPct: 50, ...input.requiredReductions };
13
9
  const reductions = {
14
10
  planSessionsPct: reduction(input.baseline.planSessions, input.candidate.planSessions),
15
11
  planWallClockPct: reduction(input.baseline.planWallClockMs, input.candidate.planWallClockMs),
16
- protocolRetryPct: reduction(input.baseline.retries, input.candidate.retries),
12
+ factReplacementPct: reduction(input.baseline.factReplacements, input.candidate.factReplacements),
17
13
  };
18
14
  const failedChecks = [];
19
15
  if (reductions.planSessionsPct === null || reductions.planSessionsPct < required.planSessionsPct)
@@ -23,31 +19,85 @@ export function evaluateFrontendPlanCanary(input) {
23
19
  // A zero retry baseline is already optimal: a zero->zero candidate is not a
24
20
  // regression and must not block, while zero->positive or missing evidence
25
21
  // still fails closed.
26
- const retriesAlreadyOptimal = input.baseline.retries === 0 && input.candidate.retries === 0;
22
+ const retriesAlreadyOptimal = input.baseline.factReplacements === 0 && input.candidate.factReplacements === 0;
27
23
  if (!retriesAlreadyOptimal &&
28
- (reductions.protocolRetryPct === null || reductions.protocolRetryPct < required.protocolRetryPct)) {
29
- failedChecks.push("protocol-retry-reduction");
24
+ (reductions.factReplacementPct === null || reductions.factReplacementPct < required.factReplacementPct)) {
25
+ failedChecks.push("fact-replacement-reduction");
30
26
  }
31
27
  for (const scenario of FRONTEND_PLAN_CANARY_SCENARIOS) {
32
28
  const result = input.scenarios[scenario];
33
29
  if (result !== "blocked" && result !== "reported")
34
30
  failedChecks.push(`regression:${scenario}`);
35
31
  }
36
- return { pass: failedChecks.length === 0, reductions, failedChecks };
32
+ for (const metric of ["planModelRequests", "planTokens", "toolRejections", "transportRetries", "correctionSessions", "finalizeRejections"]) {
33
+ const before = input.baseline[metric], after = input.candidate[metric];
34
+ if (before == null || after == null || !Number.isFinite(before) || !Number.isFinite(after) || after < 0 || after > before)
35
+ failedChecks.push(`plan-cost:${metric}`);
36
+ }
37
+ const planPass = failedChecks.length === 0;
38
+ for (const metric of ["rootTokens", "rootReservedRequests"]) {
39
+ const before = input.baseline[metric], after = input.candidate[metric];
40
+ const allowance = metric === "rootTokens" ? 1.1 : 1;
41
+ if (before == null || after == null || !Number.isFinite(before) || !Number.isFinite(after) || before < 0 || after < 0 || after > before * allowance)
42
+ failedChecks.push(`root-cost:${metric}`);
43
+ }
44
+ for (const metric of ["dagWallClockMs", "designReviewRejectRate", "finalReviewDefectRate", "recoveryCount"]) {
45
+ const before = input.baseline[metric], after = input.candidate[metric];
46
+ if (before == null || after == null || !Number.isFinite(before) || !Number.isFinite(after) || after < 0 || after > before)
47
+ failedChecks.push(`quality:${metric}`);
48
+ }
49
+ return { pass: failedChecks.length === 0, planPass, reductions, failedChecks };
37
50
  }
38
51
  /** Read two frozen run directories and evaluate the same-input canary. Plan
39
- * cost metrics come from persisted session receipts; supplemental DAG/review/
40
- * recovery values come from each run's `frontend-plan-canary-metrics.json`.
52
+ * cost metrics come from persisted session receipts and optionally explicit root
53
+ * lineage. Free numeric supplements cannot establish quality evidence.
41
54
  * Missing observations surface as `null` and fail the evaluation, never as a
42
55
  * manufactured zero-cost improvement. */
43
56
  export async function compareFrontendPlanCanaryRuns(input) {
44
57
  const baseline = await readFrontendPlanCanaryRunMetrics(input.baselineRunDir);
45
58
  const candidate = await readFrontendPlanCanaryRunMetrics(input.candidateRunDir);
59
+ const lineageChecks = [];
60
+ const regressionEvidence = [];
61
+ const identities = {};
62
+ if (input.rootRuns) {
63
+ for (const [name, metrics, runId] of [["baseline", baseline, input.rootRuns.baselineRunId], ["candidate", candidate, input.rootRuns.candidateRunId]]) {
64
+ const root = await readFrontendRootObservation(input.rootRuns.workspaceRoot, runId);
65
+ const consumed = root.cumulativeBudget?.consumed;
66
+ Object.assign(metrics, root.plan, { dagWallClockMs: root.dagWallClockMs, recoveryCount: root.recoveryCount,
67
+ rootTokens: root.complete && consumed?.missingTokenNodeIds?.length === 0 ? consumed.tokens : null,
68
+ rootReservedRequests: root.complete ? consumed?.providerRequests ?? null : null });
69
+ if (!root.complete)
70
+ lineageChecks.push(`lineage:${name}`);
71
+ if (root.complete && input.evidence?.[name]) {
72
+ try {
73
+ const evidence = await readFrontendCanaryEvidence({ workspaceRoot: input.rootRuns.workspaceRoot, runDir: name === "baseline" ? input.baselineRunDir : input.candidateRunDir, runIds: root.runIds, manifest: input.evidence[name] });
74
+ identities[name] = evidence.identity;
75
+ regressionEvidence.push(evidence.regressions);
76
+ Object.assign(metrics, { designReviewRejectRate: evidence.designReviewRejectRate, finalReviewDefectRate: evidence.finalReviewDefectRate });
77
+ if (!evidence.acceptancePassed)
78
+ lineageChecks.push(`acceptance:${name}`);
79
+ }
80
+ catch (error) {
81
+ lineageChecks.push(`evidence:${name}:${error instanceof Error ? error.message : String(error)}`);
82
+ }
83
+ }
84
+ else
85
+ lineageChecks.push(`missing-evidence:${name}`);
86
+ }
87
+ }
88
+ else
89
+ lineageChecks.push("missing-root-lineage");
90
+ const scenarios = {};
91
+ for (const scenario of FRONTEND_PLAN_CANARY_SCENARIOS) {
92
+ scenarios[scenario] = regressionEvidence.length === 2 && regressionEvidence.every(evidence => evidence[scenario] === "blocked" || evidence[scenario] === "reported") ? "reported" : "not-observed";
93
+ }
46
94
  const evaluation = evaluateFrontendPlanCanary({
47
95
  baseline,
48
96
  candidate,
49
- scenarios: input.scenarios,
97
+ scenarios,
50
98
  requiredReductions: input.requiredReductions,
51
99
  });
52
- return { ...evaluation, baseline, candidate };
100
+ const identityChecks = compareFrontendPairIdentity(identities.baseline ?? {}, identities.candidate ?? {});
101
+ const failedChecks = [...evaluation.failedChecks, ...identityChecks, ...lineageChecks];
102
+ return { ...evaluation, pass: failedChecks.length === 0, failedChecks, baseline, candidate };
53
103
  }
@@ -1,3 +1,4 @@
1
+ import { repairAssertionSchema } from "./frontend-repair-assertions.js";
1
2
  import { z } from "zod";
2
3
  import { pathMatchesPattern } from "../../shared/git-progress.js";
3
4
  import { deterministicSha256 } from "./frontend-implementation-contract.js";
@@ -94,7 +95,7 @@ export function projectFrontendSafetyFacts(input) {
94
95
  const ids = sorted(group?.requirementIds ?? []);
95
96
  for (const id of ids)
96
97
  verificationByRequirement[id]?.push(focus.id);
97
- return { id: focus.id, file: focus.file, commandId: focus.commandId, requirementIds: ids };
98
+ return { id: focus.id, file: focus.file, commandId: focus.commandId, requirementIds: ids, evidenceLevel: focus.evidenceLevel, ...(focus.boundary ? { boundary: focus.boundary } : {}) };
98
99
  });
99
100
  for (const id of requirementIds)
100
101
  verificationByRequirement[id] = sorted(verificationByRequirement[id]);
@@ -443,6 +444,8 @@ export function buildFrontendPlanRelationshipPatch(input) {
443
444
  : declaredStateNamesByGroup.get(focus.behaviorGroupId) ?? [];
444
445
  return {
445
446
  id: focus.id,
447
+ evidenceLevel: focus.evidenceLevel,
448
+ ...(focus.boundary ? { boundary: focus.boundary } : {}),
446
449
  commandId: focus.commandId,
447
450
  file: focus.file,
448
451
  requirementIds,
@@ -450,7 +453,7 @@ export function buildFrontendPlanRelationshipPatch(input) {
450
453
  };
451
454
  });
452
455
  const verificationTargetIds = new Set(verificationTargets.map(target => target.id));
453
- // ---- requirements: derived group expansion + contract deliverable
456
+ // ---- requirements: preserve group placement separately from contract deliverable
454
457
  // obligations. Every requirement must keep a placement and at least one
455
458
  // verification target (both are guaranteed post-finalize because the
456
459
  // decision safety projection blocks unplaced / unverified requirements).
@@ -458,7 +461,7 @@ export function buildFrontendPlanRelationshipPatch(input) {
458
461
  const groupId = groupByRequirement.get(requirement.id);
459
462
  const placed = sorted(placementPathsById.get(groupId ?? "") ?? []);
460
463
  const deliverable = sorted(requiredByRequirement.get(requirement.id) ?? []);
461
- const implementationTargets = sorted([...placed, ...deliverable]);
464
+ const implementationTargets = placed;
462
465
  const derivedVerificationTargetIds = sorted([...(vtIdsByGroup.get(groupId ?? "") ?? [])].filter(id => verificationTargetIds.has(id)));
463
466
  return {
464
467
  id: requirement.id,
@@ -613,12 +616,6 @@ export function buildFrontendPlanRelationshipPatch(input) {
613
616
  ...(stylingStrategy ? { stylingStrategy } : {}),
614
617
  };
615
618
  }
616
- const repairAssertionSchema = z.discriminatedUnion("check", [
617
- z.object({ check: z.literal("file-absent"), token: z.string().min(1), source: z.string() }).strict(),
618
- z.object({ check: z.literal("state-present"), token: z.string().min(1), source: z.string() }).strict(),
619
- z.object({ check: z.literal("vt-changed"), token: z.string().min(1), source: z.string(),
620
- expectedRequirementIds: z.array(z.string().min(1)).min(1) }).strict(),
621
- ]);
622
619
  /** No heuristic fallback: absent structured expectations mean that semantic
623
620
  * closure must be decided by the subsequent design review. Invalid explicit
624
621
  * assertions throw rather than silently disabling a requested check. */
@@ -742,26 +739,21 @@ export function collapseDecisionEntriesByKind(kind, collected) {
742
739
  };
743
740
  const byKey = new Map();
744
741
  const keyless = [];
745
- // Retracted tombstones remove their own key and every other record keyed to
746
- // the same interaction — a wrong-named record cannot survive a rename (r19).
747
- const retractedKeys = new Set();
748
- for (const entry of collected) {
749
- if (entry.retracted === true && typeof entry.interaction === "string") {
750
- retractedKeys.add(entry.interaction);
751
- }
752
- }
742
+ // Chronological last-wins semantics also permit a later explicit correction
743
+ // after a tombstone. Never return the original tombstones as active rows.
753
744
  for (const entry of collected) {
754
- if (entry.retracted === true)
755
- continue;
756
745
  const key = keyOf(entry);
757
- if (kind === "data-flow" && typeof entry.interaction === "string" && retractedKeys.has(entry.interaction))
746
+ if (entry.retracted === true) {
747
+ if (key)
748
+ byKey.delete(key);
758
749
  continue;
750
+ }
759
751
  if (key)
760
752
  byKey.set(key, entry);
761
753
  else
762
754
  keyless.push(entry);
763
755
  }
764
- return byKey.size > 0 ? [...byKey.values(), ...keyless] : [...collected];
756
+ return [...byKey.values(), ...keyless];
765
757
  }
766
758
  /** Compile committed decision facts into a FrontendPlanDecisionContract. Each
767
759
  * fact's `entry` is already schema-shaped (the tool validates it); this collects
@@ -0,0 +1,92 @@
1
+ import { assemblePlanPatchFromCommittedFacts } from "./frontend-committed-facts.js";
2
+ import { createHash } from "node:crypto";
3
+ const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
4
+ const identity = (params) => {
5
+ const p = object(params), entry = object(p.entry ?? p.choice);
6
+ return String(p.id ?? p.scopeId ?? p.requirementId ?? entry.id ?? entry.name ?? entry.purpose ?? entry.state ?? entry.interaction ?? entry.boundary ?? "singleton");
7
+ };
8
+ /** Counts unresolved diagnostic identities, not tool activity. A changed
9
+ * rationale/read/heartbeat cannot resolve the same compiler diagnostic. */
10
+ export function createFrontendPlanProgressGuard() {
11
+ let stop;
12
+ let terminal = false;
13
+ let failures = [];
14
+ const states = [];
15
+ const warned = new Set();
16
+ const listeners = new Set();
17
+ return {
18
+ readStop: () => stop,
19
+ acceptTerminal: () => { if (!stop) {
20
+ terminal = true;
21
+ failures = [];
22
+ } },
23
+ subscribe(listener) { listeners.add(listener); if (stop)
24
+ listener(); return () => { listeners.delete(listener); }; },
25
+ observe(tool, params, details, committed, effectiveDigest) {
26
+ if (stop)
27
+ return stop;
28
+ if (terminal)
29
+ return undefined;
30
+ const key = `${tool}:${identity(params)}`;
31
+ if (details?.ok === true) {
32
+ // Only a successful durable correction of the rejected identity (or
33
+ // a successful compile) resolves it. Unrelated writes do not reset it.
34
+ if (tool.startsWith("finalize_") && committed) {
35
+ terminal = true;
36
+ failures = [];
37
+ }
38
+ else if (committed)
39
+ failures = failures.filter(f => f.key !== key);
40
+ return undefined;
41
+ }
42
+ const stage = typeof details?.validationStage === "string" ? details.validationStage : undefined;
43
+ const completed = Array.isArray(details?.completedValidationStages) ? details.completedValidationStages : [];
44
+ failures = failures.filter(f => !f.stage || !completed.includes(f.stage));
45
+ const error = String(details?.error ?? details?.code ?? "unknown tool failure").replace(/\s+/g, " ").trim();
46
+ const fingerprint = createHash("sha256").update(JSON.stringify([key, details?.code ?? null, error])).digest("hex");
47
+ failures.push({ fingerprint, key, stage });
48
+ const state = `${stage ?? key}:${fingerprint}:${effectiveDigest ?? "unknown"}`;
49
+ states.push(state);
50
+ if (states.length > 8)
51
+ states.shift();
52
+ failures = failures.slice(-12);
53
+ const tail = failures.slice(-6);
54
+ const repeated = failures.filter(f => f.fingerprint === fingerprint).length;
55
+ if (repeated >= 6 || (tail.length === 6 && new Set(tail.map(f => f.fingerprint)).size <= 2) || (states.length === 8 && new Set(states).size <= 2)) {
56
+ stop = { code: "FRONTEND_PLAN_NO_PROGRESS", fingerprint, error: `Unresolved repeated Plan diagnostic: ${error}` };
57
+ for (const listener of listeners)
58
+ listener();
59
+ return stop;
60
+ }
61
+ if (repeated >= 3 && !warned.has(fingerprint)) {
62
+ warned.add(fingerprint);
63
+ return { code: "FRONTEND_PLAN_REPAIR_HINT", fingerprint, error: `The same diagnostic remains unresolved. Correct the named fact before repeating this call: ${error}` };
64
+ }
65
+ return undefined;
66
+ },
67
+ };
68
+ }
69
+ /** Effective facts for the existing node retry ladder. Replacement history and
70
+ * explanation-only edits must not look like a new implementation decision. */
71
+ export function frontendPlanSemanticDigest(records) {
72
+ const current = new Map();
73
+ const strip = (value) => Array.isArray(value) ? value.map(strip) : value && typeof value === "object"
74
+ ? Object.fromEntries(Object.entries(value).filter(([key]) => !["rationale", "replaces"].includes(key)).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, strip(item)])) : value;
75
+ const ordinary = records.some(r => ["plan-requirement", "plan-verification-target", "state-registry", "component-choice", "state-flow", "mock-api"].includes(String(object(r.fact).kind)));
76
+ if (ordinary) {
77
+ try {
78
+ const patch = assemblePlanPatchFromCommittedFacts(records.map(r => ({ ...r, fact: { ...object(r.fact), origin: "plan" } })));
79
+ if (patch)
80
+ return createHash("sha256").update(JSON.stringify(strip(patch))).digest("hex");
81
+ }
82
+ catch { /* An incomplete ledger still needs a stable diagnostic state. */ }
83
+ }
84
+ for (const record of records) {
85
+ if (record.phase !== "committed")
86
+ continue;
87
+ const fact = object(record.fact);
88
+ const key = `${fact.kind}:${identity(fact)}`;
89
+ current.set(key, strip(fact));
90
+ }
91
+ return createHash("sha256").update(JSON.stringify([...current].sort(([a], [b]) => a.localeCompare(b)))).digest("hex");
92
+ }
@@ -14,8 +14,7 @@ export const FRONTEND_RECOVERY_IMPORT_MANIFEST_REL_PATH = ".runtime/import-manif
14
14
  /** Staging directory prefix under `active/` for in-flight child materialization. */
15
15
  export const FRONTEND_RECOVERY_STAGING_PREFIX = ".frontend-recovery-staging-";
16
16
  export function deriveFrontendRecoveryFailureSource(result) {
17
- if (result.failureSource === "frontend-contract-pi" ||
18
- result.failureSource === "frontend-plan-pi" ||
17
+ if (result.failureSource === "frontend-plan-pi" ||
19
18
  result.failureSource === "frontend-design-policy-shell" ||
20
19
  result.failureSource === "frontend-implement-pi" ||
21
20
  result.failureSource === "frontend-verify-shell") {
@@ -62,14 +61,14 @@ function computeFrontendRecoveryPlanForSource(input) {
62
61
  // verification commands / trace. Resetting only verify + its descendants
63
62
  // would re-run the same failing checks against the same implementation, so
64
63
  // reset from implement: the recovery re-implements and re-verifies.
65
- const resetRoot = input.failureSource === "frontend-contract-pi"
66
- ? "frontend-contract-pi"
67
- :
68
- input.failureSource === "frontend-verify-shell"
69
- ? "frontend-implement-pi"
70
- : input.failureSource === "frontend-review-pi"
71
- ? "frontend-implement-pi"
72
- : input.failureSource;
64
+ const ownerRoots = { contract: "frontend-contract-pi", scout: "frontend-scout-pi", plan: "frontend-plan-pi", implementation: "frontend-implement-pi", "verification-config": "frontend-contract-pi" };
65
+ if (input.failureOwner && !ownerRoots[input.failureOwner])
66
+ throw new Error(`FRONTEND_RECOVERY_OWNER_UNSUPPORTED: ${input.failureOwner}; resolve the owning input before retrying`);
67
+ const resetRoot = input.failureOwner ? ownerRoots[input.failureOwner] :
68
+ input.failureSource === "frontend-verify-shell" || input.failureSource === "frontend-review-pi" ? "frontend-implement-pi" :
69
+ input.failureSource === "frontend-design-review-pi" ? "frontend-plan-pi" : input.failureSource;
70
+ if (!input.spec.tasks.some(task => task.id === resetRoot))
71
+ throw new Error(`FRONTEND_RECOVERY_PRODUCER_UNAVAILABLE: ${resetRoot}; this topology has no authorized producer, reproject the task contract instead of importing the same candidate`);
73
72
  const resetNodeIds = computeResetClosure(input.spec, resetRoot);
74
73
  const resetSet = new Set(resetNodeIds);
75
74
  const importedNodeIds = input.spec.tasks
@@ -81,6 +80,8 @@ function computeFrontendRecoveryPlanForSource(input) {
81
80
  parentRunId: input.parentRunId,
82
81
  requestId: input.requestId,
83
82
  failureSource: input.failureSource,
83
+ ...(input.failureOwner ? { failureOwner: input.failureOwner } : {}),
84
+ resetRootNodeId: resetRoot,
84
85
  resetNodeIds,
85
86
  importedNodeIds,
86
87
  };
@@ -298,6 +298,34 @@ async function casUpdateFrontendRecoveryState(parentRunDir, expectedRevision, de
298
298
  }
299
299
  async function materializeChildStaging(input) {
300
300
  const { cwd, parentRunDir, parentRunId, parentSpec, parentState, recovery, recoveryPlan, requestId, childRunId, stagingDir, createdAt, } = input;
301
+ // Phase 5: writer transient partial-write recovery resets the writer subtree
302
+ // (frontend-implement-pi) instead of the candidate producer; everything
303
+ // upstream is imported as verified parent facts.
304
+ let plan;
305
+ if (recoveryPlan) {
306
+ plan = recoveryPlan;
307
+ }
308
+ else if (recovery.failureSource) {
309
+ plan = computeFrontendRecoveryPlanForSource({
310
+ spec: parentSpec,
311
+ parentRunId,
312
+ requestId,
313
+ failureSource: recovery.failureSource,
314
+ failureOwner: recovery.failureOwner,
315
+ });
316
+ }
317
+ else {
318
+ const admissionRead = await readFrontendWriterAdmissionResult(parentRunDir);
319
+ if (!admissionRead.ok) {
320
+ throw new Error(admissionRead.reason);
321
+ }
322
+ plan = computeFrontendRecoveryPlan({
323
+ spec: parentSpec,
324
+ parentRunId,
325
+ requestId,
326
+ result: admissionRead.result,
327
+ });
328
+ }
301
329
  await prepareRunDir(stagingDir);
302
330
  // Design-review rejection must be visible to the reset planner. Recovery
303
331
  // children otherwise copy the old spec and only rerun design review, which
@@ -305,6 +333,8 @@ async function materializeChildStaging(input) {
305
333
  // findings into the child spec as ordinary rerun feedback.
306
334
  let childSpec = parentSpec;
307
335
  if (recovery.failureSource === "frontend-plan-pi" ||
336
+ recovery.failureSource === "frontend-design-review-pi" ||
337
+ plan.resetRootNodeId === "frontend-contract-pi" ||
308
338
  recovery.failureSource === "frontend-review-pi") {
309
339
  const designRecovery = parentState.terminalReason === "design-request-changes";
310
340
  const reviewRecovery = recovery.failureSource === "frontend-review-pi" ||
@@ -331,12 +361,13 @@ async function materializeChildStaging(input) {
331
361
  }
332
362
  }
333
363
  catch (error) {
334
- if (designRecovery || reviewRecovery) {
335
- throw new Error(`frontend recovery feedback unavailable: ${error instanceof Error ? error.message : String(error)}`);
336
- }
364
+ throw new Error(`frontend recovery feedback unavailable: ${error instanceof Error ? error.message : String(error)}`);
337
365
  }
338
366
  if (feedback) {
339
367
  childSpec = { ...parentSpec, rerunFeedback: feedback };
368
+ if (plan.resetRootNodeId === "frontend-contract-pi") {
369
+ childSpec = { ...childSpec, tasks: childSpec.tasks.map(task => task.id !== plan.resetRootNodeId ? task : { ...task, subtask_prompt: `${task.subtask_prompt}\n\n<contract_recovery_diagnostic>\nParent run: ${parentRunId}\nSource node: ${feedback.sourceNodeId}\nRe-extract only from the unchanged frozen task source. These parent findings are diagnostic hypotheses, not new requirements or authorization. Do not import the parent's Plan as Contract facts. Report unresolved source ambiguity rather than inventing a decision.\n${feedback.feedbackText}\n</contract_recovery_diagnostic>` }) };
370
+ }
340
371
  }
341
372
  else if (designRecovery || reviewRecovery) {
342
373
  // A design/review rejection cannot be safely retried without its typed
@@ -346,33 +377,6 @@ async function materializeChildStaging(input) {
346
377
  }
347
378
  }
348
379
  await writeJsonVerified(stagingDir, "run.json", childSpec);
349
- // Phase 5: writer transient partial-write recovery resets the writer subtree
350
- // (frontend-implement-pi) instead of the candidate producer; everything
351
- // upstream is imported as verified parent facts.
352
- let plan;
353
- if (recoveryPlan) {
354
- plan = recoveryPlan;
355
- }
356
- else if (recovery.failureSource) {
357
- plan = computeFrontendRecoveryPlanForSource({
358
- spec: parentSpec,
359
- parentRunId,
360
- requestId,
361
- failureSource: recovery.failureSource,
362
- });
363
- }
364
- else {
365
- const admissionRead = await readFrontendWriterAdmissionResult(parentRunDir);
366
- if (!admissionRead.ok) {
367
- throw new Error(admissionRead.reason);
368
- }
369
- plan = computeFrontendRecoveryPlan({
370
- spec: parentSpec,
371
- parentRunId,
372
- requestId,
373
- result: admissionRead.result,
374
- });
375
- }
376
380
  const { ranks } = topoSortToRanks(childSpec);
377
381
  const resetSet = new Set(plan.resetNodeIds);
378
382
  const importedSet = new Set(plan.importedNodeIds);
@@ -408,16 +412,16 @@ async function materializeChildStaging(input) {
408
412
  }
409
413
  // Canonical contract + writer-admission identity facts are copied alongside
410
414
  // the per-node facts so reset nodes can re-read them (AC-4).
411
- const canonicalContract = await copyArtifactVerified({
415
+ const canonicalContract = plan.importedNodeIds.some(id => ["frontend-design-policy-shell", "frontend-contract-import-shell"].includes(id)) ? await copyArtifactVerified({
412
416
  parentRunDir,
413
417
  newRunDir: stagingDir,
414
418
  relativePath: resolveCanonicalContractRelPath(childSpec),
415
- });
416
- const writerAdmissionResult = await copyArtifactVerified({
419
+ }) : undefined;
420
+ const writerAdmissionResult = plan.importedNodeIds.includes("frontend-writer-admission-shell") ? await copyArtifactVerified({
417
421
  parentRunDir,
418
422
  newRunDir: stagingDir,
419
423
  relativePath: FRONTEND_WRITER_ADMISSION_RESULT_ARTIFACT,
420
- });
424
+ }) : undefined;
421
425
  // Imported nodes keep their parent FINISHED record, so the run-owned files
422
426
  // their bodies wrote outside the node directory travel with them (smoke r31:
423
427
  // a review-triggered recovery reached review-context with no worktree
@@ -470,6 +474,7 @@ async function materializeChildStaging(input) {
470
474
  }
471
475
  }
472
476
  const childState = {
477
+ ...(parentSpec.sourceProviderBudget ? { sourceProviderBudget: structuredClone(parentSpec.sourceProviderBudget) } : {}),
473
478
  ...(parentState.budget ? { budget: structuredClone(parentState.budget) } : {}),
474
479
  ...(parentState.budgetLedger ? { budgetLedger: structuredClone(parentState.budgetLedger) } : {}),
475
480
  version: 1,
@@ -506,6 +511,8 @@ async function materializeChildStaging(input) {
506
511
  ...(recovery.failureSource
507
512
  ? { failureSource: recovery.failureSource }
508
513
  : {}),
514
+ ...(plan.failureOwner ? { failureOwner: plan.failureOwner } : {}),
515
+ ...(plan.resetRootNodeId ? { resetRootNodeId: plan.resetRootNodeId } : {}),
509
516
  revision: 0,
510
517
  },
511
518
  ...(parentState.evaluation
@@ -518,14 +525,8 @@ async function materializeChildStaging(input) {
518
525
  parentRunId,
519
526
  requestId,
520
527
  createdAt,
521
- canonicalContract: {
522
- relativePath: canonicalContract.destinationRelativePath,
523
- sha256: canonicalContract.sha256,
524
- },
525
- writerAdmissionResult: {
526
- relativePath: writerAdmissionResult.destinationRelativePath,
527
- sha256: writerAdmissionResult.sha256,
528
- },
528
+ ...(canonicalContract ? { canonicalContract: { relativePath: canonicalContract.destinationRelativePath, sha256: canonicalContract.sha256 } } : {}),
529
+ ...(writerAdmissionResult ? { writerAdmissionResult: { relativePath: writerAdmissionResult.destinationRelativePath, sha256: writerAdmissionResult.sha256 } } : {}),
529
530
  importedRunArtifacts,
530
531
  importedFacts,
531
532
  };
@@ -590,7 +591,8 @@ export async function stageRecoveryChild(options) {
590
591
  recovery.recoveryRootRunId !== intent.recoveryRootRunId ||
591
592
  recovery.attemptIndex !== intent.attemptIndex ||
592
593
  recovery.continuationCount !== intent.continuationCount ||
593
- recovery.failureSource !== intent.failureSource) {
594
+ recovery.failureSource !== intent.failureSource ||
595
+ recovery.failureOwner !== intent.failureOwner || recovery.resetRootNodeId !== intent.resetRootNodeId) {
594
596
  throw new Error("frontend recovery intent replay binding mismatch");
595
597
  }
596
598
  }
@@ -620,7 +622,8 @@ export async function stageRecoveryChild(options) {
620
622
  if (options.recoveryPlan) {
621
623
  if (options.recoveryPlan.parentRunId !== parentRunId ||
622
624
  options.recoveryPlan.requestId !== requestId ||
623
- options.recoveryPlan.failureSource !== recovery.failureSource) {
625
+ options.recoveryPlan.failureSource !== recovery.failureSource ||
626
+ options.recoveryPlan.failureOwner !== recovery.failureOwner || options.recoveryPlan.resetRootNodeId !== recovery.resetRootNodeId) {
624
627
  throw new Error("frontend recovery plan binding mismatch");
625
628
  }
626
629
  const expectedPlan = computeFrontendRecoveryPlanForSource({
@@ -628,9 +631,11 @@ export async function stageRecoveryChild(options) {
628
631
  parentRunId,
629
632
  requestId,
630
633
  failureSource: recovery.failureSource,
634
+ failureOwner: recovery.failureOwner,
631
635
  });
632
- if (JSON.stringify(expectedPlan.resetNodeIds) !==
633
- JSON.stringify(options.recoveryPlan.resetNodeIds) ||
636
+ if (expectedPlan.resetRootNodeId !== options.recoveryPlan.resetRootNodeId ||
637
+ JSON.stringify(expectedPlan.resetNodeIds) !==
638
+ JSON.stringify(options.recoveryPlan.resetNodeIds) ||
634
639
  JSON.stringify(expectedPlan.importedNodeIds) !==
635
640
  JSON.stringify(options.recoveryPlan.importedNodeIds)) {
636
641
  throw new Error("frontend recovery plan drifted from parent DAG");
@@ -0,0 +1,36 @@
1
+ import { z } from "zod";
2
+ export const repairAssertionSchema = z.discriminatedUnion("check", [
3
+ z.object({ check: z.literal("file-absent"), token: z.string().min(1).max(512), source: z.string().max(4096) }).strict(),
4
+ z.object({ check: z.literal("state-present"), token: z.string().min(1).max(512), source: z.string().max(4096) }).strict(),
5
+ z.object({ check: z.literal("vt-changed"), token: z.string().min(1).max(512), source: z.string().max(4096), expectedRequirementIds: z.array(z.string().min(1)).min(1).max(128) }).strict(),
6
+ ]);
7
+ /** Invalid optional assertions never erase a blocking finding or its verdict. */
8
+ export function sanitizeRepairAssertions(value) {
9
+ if (value === undefined)
10
+ return { assertions: [], diagnostics: [] };
11
+ if (!Array.isArray(value))
12
+ return { assertions: [], diagnostics: ["repairAssertions must be an array; semantic review remains required"] };
13
+ const assertions = [], diagnostics = [];
14
+ for (const [index, raw] of value.slice(0, 32).entries()) {
15
+ const parsed = repairAssertionSchema.safeParse(raw);
16
+ if (parsed.success)
17
+ assertions.push(parsed.data);
18
+ else
19
+ diagnostics.push(`repairAssertions[${index}] invalid; semantic review remains required`);
20
+ }
21
+ if (value.length > 32)
22
+ diagnostics.push("repairAssertions exceeds 32 items; excess items require semantic review");
23
+ return { assertions, diagnostics };
24
+ }
25
+ /** Review annotations cannot authorize deletion or invent requirements/states. */
26
+ export function authorizeRepairAssertions(assertions, input) {
27
+ const files = new Set(input.parent.targets?.files ?? []);
28
+ const required = new Set([...(input.parent.targets?.requiredDeliverables ?? []), ...(input.requiredDeliverables ?? [])]);
29
+ const states = new Set(input.stateIds);
30
+ const targets = new Set(input.parent.verificationTargets?.map(target => String(target.id)) ?? []);
31
+ return assertions.flatMap(assertion => {
32
+ const permitted = assertion.check === "file-absent" ? files.has(assertion.token) && !required.has(assertion.token) :
33
+ assertion.check === "state-present" ? states.has(assertion.token) : targets.has(assertion.token) && assertion.expectedRequirementIds.every(id => input.requirementIds.includes(id));
34
+ return permitted ? [] : [`${assertion.check}: ${assertion.token} is outside frozen repair authority or contradicts a required delivery`];
35
+ });
36
+ }
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { readFile } from "node:fs/promises";
2
+ import { readFile, lstat } from "node:fs/promises";
3
+ import { readFileBoundarySafe } from "../../shared/path-safety.js";
3
4
  import path from "node:path";
4
5
  import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
5
6
  import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "./frontend-worktree-diff.js";
@@ -244,6 +245,31 @@ function assertReviewEvidence(input) {
244
245
  throw new FrontendReviewContextFailure("review-context-verification-unbound", "frontend review context verification trace is not bound to a passing contract");
245
246
  }
246
247
  }
248
+ /** Snapshot only exact behavior-test paths already named by the frozen contract. */
249
+ export async function captureFrontendReviewTestSources(input) {
250
+ const sources = [];
251
+ for (const [index, file] of [...new Set(input.files)].entries()) {
252
+ const targetPath = path.resolve(input.workspaceRoot, file);
253
+ const exists = await lstat(targetPath).catch((error) => { if (error.code === "ENOENT")
254
+ return undefined; throw error; });
255
+ if (!exists) {
256
+ sources.push({ file, status: "unavailable", scopes: [] });
257
+ continue;
258
+ }
259
+ const bytes = await readFileBoundarySafe({ boundaryRoot: input.workspaceRoot, targetPath, label: "frontend review test source" });
260
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
261
+ const text = bytes.toString("utf8");
262
+ const scopes = [];
263
+ // Small text slices bound each model input; all slices are retained.
264
+ for (let offset = 0; offset < Math.max(1, text.length); offset += 2000) {
265
+ const relative = `contracts/test-source-${index}-${offset / 2000}.json`;
266
+ await writeDagRunJsonArtifact(input.runDir, relative, { schemaVersion: 1, file, sourceSha256: sha256, offset, totalCharacters: text.length, text: text.slice(offset, offset + 2000) });
267
+ scopes.push({ path: relative, sha256: createHash("sha256").update(await readFile(path.join(input.runDir, relative))).digest("hex") });
268
+ }
269
+ sources.push({ file, status: "captured", sha256, scopes });
270
+ }
271
+ return sources;
272
+ }
247
273
  export async function runFrontendReviewContextGate(input) {
248
274
  const contractRelativePath = "contracts/frontend-implementation-contract.json";
249
275
  let contractRaw;
@@ -284,6 +310,7 @@ export async function runFrontendReviewContextGate(input) {
284
310
  const verifyFailure = await readOptionalVerifyFailure(input.runDir);
285
311
  const lintAssessment = await readOptionalLintAssessment(input.runDir);
286
312
  const verificationEvidence = await readVerificationEvidence(input.runDir);
313
+ const testSources = await captureFrontendReviewTestSources({ ...input, files: parsedContract.data.verificationTargets.filter(target => target.mode === "behavior").map(target => target.file) });
287
314
  const cumulativeDiff = await captureCumulativeDiffContext({
288
315
  runDir: input.runDir,
289
316
  workspaceRoot: input.workspaceRoot,
@@ -320,6 +347,7 @@ export async function runFrontendReviewContextGate(input) {
320
347
  },
321
348
  verificationTrace,
322
349
  verificationEvidence,
350
+ testSources,
323
351
  // Protocol-legal lint vocabulary; `lintConfigured: false` tells the
324
352
  // reviewer that this task declares no lint surface at all, so
325
353
  // `unavailable` reads as "not applicable", not "missing evidence".