@tea-agent/loop-agent 0.39.0-beta.7 → 0.39.0-beta.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 (164) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +23 -5
  3. package/README.md +2 -2
  4. package/dist/application/task-lifecycle/advance.js +12 -0
  5. package/dist/build-stamp.json +3 -3
  6. package/dist/cli/command-definitions.js +7 -0
  7. package/dist/cli/program.js +5 -0
  8. package/dist/cli/update/runtime-activity.js +1 -29
  9. package/dist/commands/dag-follow-up.js +138 -0
  10. package/dist/commands/dag-rerun.js +1 -1
  11. package/dist/commands/init.js +26 -0
  12. package/dist/executors/dag-pi-executor.js +326 -8
  13. package/dist/executors/pi-read-budget-policy.js +166 -0
  14. package/dist/executors/shell-executor.js +26 -30
  15. package/dist/shared/dag-prompt-override.js +4 -5
  16. package/dist/shared/update/npm-client.js +33 -11
  17. package/dist/task/config-types.js +15 -0
  18. package/dist/task/contract/apply.js +11 -1
  19. package/dist/task/contract/constants.js +2 -0
  20. package/dist/task/contract/hash.js +3 -0
  21. package/dist/task/contract/observe.js +25 -4
  22. package/dist/task/contract/paths.js +2 -1
  23. package/dist/task/contract/project.js +15 -1
  24. package/dist/task/contract/schema.js +2 -0
  25. package/dist/task/contract/transaction.js +12 -0
  26. package/dist/task/frontend-project-capability.js +123 -4
  27. package/dist/task/source-prepare/completeness.js +188 -0
  28. package/dist/task/source-prepare/fragment-inventory.js +477 -0
  29. package/dist/task/source-prepare/index.js +3 -0
  30. package/dist/task/source-prepare/ledger-reconciliation.js +127 -0
  31. package/dist/task/source-prepare/ledger-review.js +214 -0
  32. package/dist/task/source-prepare/ledger.js +545 -0
  33. package/dist/task/source-prepare/prepare.js +262 -1
  34. package/dist/task/source-prepare/semantic-intake.js +25 -5
  35. package/dist/task/source-prepare/source-fidelity-pi.js +384 -0
  36. package/dist/task/source-prepare/types.js +22 -0
  37. package/dist/worker/cli.js +12 -26
  38. package/dist/worker/console/console-handoff.js +82 -0
  39. package/dist/worker/console/console-update-and-init.js +115 -0
  40. package/dist/worker/console/console-update-runtime.js +84 -0
  41. package/dist/worker/console/draft-store.js +49 -0
  42. package/dist/worker/console/operation-store.js +1 -1
  43. package/dist/worker/console/operator-actions.js +129 -4
  44. package/dist/worker/console/operator-surface-health.js +1 -0
  45. package/dist/worker/console/prd-intake-bridge.js +13 -0
  46. package/dist/worker/console/routes.js +3 -0
  47. package/dist/worker/console/server.js +73 -5
  48. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-bPer1waP.js → abnfDiagram-N423BO3Z-D1-Z-BBa.js} +1 -1
  49. package/dist/worker/console/static/assets/{arc-BLdkwucW.js → arc-q8qAYm5m.js} +1 -1
  50. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-Bw4wo4I1.js → architectureDiagram-T3A2C74G-CAu4_lve.js} +1 -1
  51. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DUUkc0gr.js → blockDiagram-VBNYF7ZC-D5GhQvHm.js} +1 -1
  52. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BC3O0nob.js → c4Diagram-5PPSVZJV-ClQjeLkO.js} +1 -1
  53. package/dist/worker/console/static/assets/channel-ClAuhSUL.js +1 -0
  54. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-Dd4g2nti.js → chunk-2GRJ4B5K-DLqWlFbo.js} +1 -1
  55. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-s2EDm7-n.js → chunk-2Q5K7J3B-DkRT_usL.js} +1 -1
  56. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-GqtHM2yQ.js → chunk-5RXB4S5H-Cqp7Chi3.js} +1 -1
  57. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BzRFrN8j.js → chunk-5VM5RSS4-CedEdFB9.js} +1 -1
  58. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Bw9nFjCJ.js → chunk-6Q2QTUOP-BHM8vP5x.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B4n-03Qm.js → chunk-GF5L2VYU-D7EFr377.js} +1 -1
  60. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-Dt75ZdBD.js → chunk-JWPE2WC7-DR38mTWV.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-B_afMhHB.js → chunk-KBJHAD2P-BFv2LsR3.js} +1 -1
  62. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-Bd8Rqqg9.js → chunk-RYQCIY6F-BAvpJS5z.js} +1 -1
  63. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-9boOMji4.js → chunk-XXDRQBXY-eKI9TO72.js} +1 -1
  64. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-cBgeCpVU.js +1 -0
  65. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-cBgeCpVU.js +1 -0
  66. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BtCycfCc.js → cose-bilkent-JH36ORCC-BMJXpgo4.js} +1 -1
  67. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-eTOts9L1.js → cynefin-VYW2F7L2-CKJ2ofZx.js} +1 -1
  68. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Cibd2mjg.js → cynefinDiagram-MW4NZA55-CsKnsazm.js} +1 -1
  69. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CDjDZtDV.js → dagre-VZM6K2ZE-DVQ46vIw.js} +1 -1
  70. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Btexn-JJ.js → diagram-7IWD3JNH-D_RuonYS.js} +1 -1
  71. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Cyl8sdpM.js → diagram-B4RE2ZJO-D2Mt0nMu.js} +1 -1
  72. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-Ev-83E1t.js → diagram-LBJQPF4R-DjBDuye6.js} +1 -1
  73. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-41LqE0Mc.js → diagram-Q27KOJAE-8VZkqpE_.js} +1 -1
  74. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BC39T5R3.js → diagram-UB23O5K3-D0izYxia.js} +1 -1
  75. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DzcNRuri.js → ebnfDiagram-BXEA7PRR-CncG2I95.js} +1 -1
  76. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-B5Frvvnz.js → erDiagram-JOGREHBK-DiBUUXR8.js} +1 -1
  77. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BapxROIM.js → flowDiagram-UKHOOZJN-Dtc0fArb.js} +1 -1
  78. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-Cd4d4wQ0.js → ganttDiagram-PKOTCBZU-BPNFP5OZ.js} +1 -1
  79. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CrL6buFW.js → gitGraphDiagram-DS77QQ5N-CsgpxEEd.js} +1 -1
  80. package/dist/worker/console/static/assets/{index-DBh_x_Ek.js → index-Bai8Z25q.js} +81 -81
  81. package/dist/worker/console/static/assets/index-CH0Tr5hY.css +1 -0
  82. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BrSiNxyd.js → infoDiagram-6WML65LV-kHGxkJUS.js} +1 -1
  83. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-gvlvY-n9.js → ishikawaDiagram-WSZJBQD7-CbLe9wrr.js} +1 -1
  84. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-D-YaPzRQ.js → journeyDiagram-NVQOT4AX-BOkwxAcP.js} +1 -1
  85. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BsmDv9E_.js → kanban-definition-27J2QSJJ-BHYQ2rDu.js} +1 -1
  86. package/dist/worker/console/static/assets/{linear-UWaoDN3h.js → linear-CHT4ujob.js} +1 -1
  87. package/dist/worker/console/static/assets/{mermaid.core-BlvpGVG8.js → mermaid.core-yL8sfqnY.js} +5 -5
  88. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-CUCIP5CP.js → mindmap-definition-FAOFIHXS-DZx1coV-.js} +1 -1
  89. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CB1aQUAK.js → pegDiagram-VL7TDLO6-DStWRCI8.js} +1 -1
  90. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BwehHxhf.js → pieDiagram-7S7Q4E2Y-DSKZkwzK.js} +1 -1
  91. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CFz30APB.js → quadrantDiagram-CIZ2JOQS-CmoEGOuW.js} +1 -1
  92. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BwNmw1i6.js → railroadDiagram-AXF67PYL-BLqzhKyq.js} +1 -1
  93. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-jg-tPXlI.js → requirementDiagram-LRYGKXZP-gbHeUabM.js} +1 -1
  94. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-BlyReDKl.js → sankeyDiagram-W5VNT64P-CaKkBCKO.js} +1 -1
  95. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DBUzW1f3.js → sequenceDiagram-SI44F4Z6-PUbi3Upu.js} +1 -1
  96. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-BYcI2RAr.js → sizeCapture-X5ZJPWSS-dm7S9Qfe.js} +1 -1
  97. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-D7AiyJdo.js → stateDiagram-OKZ733FA-C7WSzv4p.js} +1 -1
  98. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-sQDv5AP3.js +1 -0
  99. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BHGt7Kkv.js → swimlanes-SLNWSIFB-CBKCiQwd.js} +2 -2
  100. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BXwsmcPs.js +8 -0
  101. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-OPjooWcX.js → timeline-definition-Z64GVDOM-CvnUlylk.js} +1 -1
  102. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-ggsWDvrO.js → vennDiagram-T6HMQDX7-xM0StY1O.js} +1 -1
  103. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DjNe-BjZ.js → wardleyDiagram-T6FBY63Y-CWtJMagy.js} +1 -1
  104. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-Byp63Hei.js → xychartDiagram-ELKLHX3M-BO_-MeK8.js} +1 -1
  105. package/dist/worker/console/static/index.html +2 -2
  106. package/dist/worker/console/static-src/app/console-types.js +40 -1
  107. package/dist/worker/console/static-src/app/useOperatorActions.js +5 -0
  108. package/dist/worker/console/static-src/app/useTaskWizard.js +73 -1
  109. package/dist/worker/observability/init-runtime-activity.js +26 -0
  110. package/dist/worker/observe/node-input.js +88 -2
  111. package/dist/worker/observe/routes.js +188 -0
  112. package/dist/worker/observe/static/api.js +3 -3
  113. package/dist/worker/observe/static/format.js +37 -0
  114. package/dist/worker/observe/static/index.html +5 -0
  115. package/dist/worker/observe/static/prompt-restart-candidates.d.ts +18 -0
  116. package/dist/worker/observe/static/prompt-restart-candidates.js +36 -0
  117. package/dist/worker/observe/static/state.js +2 -2
  118. package/dist/worker/observe/static/styles.css +331 -35
  119. package/dist/worker/observe/static/views/dag-inspector.js +509 -78
  120. package/dist/worker/observe/static/views/task.js +55 -0
  121. package/dist/workflows/dag/frontend-design-policy.js +19 -9
  122. package/dist/workflows/dag/frontend-implementation-contract.js +117 -50
  123. package/dist/workflows/dag/frontend-plan-render.js +27 -0
  124. package/dist/workflows/dag/frontend-prewrite-gate.js +45 -1
  125. package/dist/workflows/dag/frontend-recovery-controller.js +1 -0
  126. package/dist/workflows/dag/frontend-review-context.js +102 -15
  127. package/dist/workflows/dag/frontend-review-findings.js +42 -10
  128. package/dist/workflows/dag/frontend-shadow-dual-write.js +36 -2
  129. package/dist/workflows/dag/frontend-source-fidelity-ledger.js +108 -0
  130. package/dist/workflows/dag/frontend-worktree-diff.js +154 -0
  131. package/dist/workflows/dag/frontend-writer-admission.js +12 -0
  132. package/dist/workflows/dag/init-hybrid.js +297 -65
  133. package/dist/workflows/dag/interrupt-request.js +7 -0
  134. package/dist/workflows/dag/node-execution.js +111 -1
  135. package/dist/workflows/dag/prompt.js +34 -1
  136. package/dist/workflows/dag/recovery-lease.js +80 -0
  137. package/dist/workflows/dag/rerun-feedback.js +31 -8
  138. package/dist/workflows/dag/rerun-run.js +23 -0
  139. package/dist/workflows/dag/rerun-task.js +29 -0
  140. package/dist/workflows/dag/retry-policy.js +34 -9
  141. package/dist/workflows/dag/runner.js +83 -16
  142. package/dist/workflows/dag/types.js +67 -11
  143. package/docs/README.md +1 -0
  144. package/docs/architecture/evolution.md +45 -0
  145. package/docs/init-surface.manifest.json +4 -0
  146. package/docs/templates/README.md +2 -1
  147. package/docs/templates/frontend-implementation-contract.schema.json +684 -24
  148. package/docs/templates/spec-registry.schema.json +45 -0
  149. package/harness.json +3 -3
  150. package/package.json +1 -1
  151. package/skills/frontend-bounded-implement/SKILL.md +8 -14
  152. package/skills/frontend-design-review/SKILL.md +20 -41
  153. package/skills/frontend-implementation/SKILL.md +2 -3
  154. package/skills/frontend-implementation/references/design-spec.md +4 -3
  155. package/skills/frontend-implementation/references/node-contracts.md +4 -3
  156. package/skills/frontend-review/SKILL.md +22 -14
  157. package/skills/frontend-review/references/review-findings.md +6 -7
  158. package/skills/loop-agent/references/hybrid-dag.md +7 -0
  159. package/dist/worker/console/static/assets/channel-AbFtVnFm.js +0 -1
  160. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-n1yf2Jcv.js +0 -1
  161. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-n1yf2Jcv.js +0 -1
  162. package/dist/worker/console/static/assets/index-DwOGauxU.css +0 -1
  163. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BBSH5c8m.js +0 -1
  164. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D2F7-U4H.js +0 -8
@@ -5,6 +5,8 @@ import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-s
5
5
  import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "./frontend-worktree-diff.js";
6
6
  import { frontendLintAssessmentArtifactSchema } from "./frontend-lint-baseline.js";
7
7
  import { deterministicSha256, frontendImplementationContractSchema, } from "./frontend-implementation-contract.js";
8
+ import { resolveDagTaskSourcePath } from "../../task/dag-source-paths.js";
9
+ import { ledgerStatsLine, parseLedgerJson } from "../../task/source-prepare/ledger.js";
8
10
  export const FRONTEND_REVIEW_CONTEXT_SCHEMA_ID = "frontend-review-context-v1";
9
11
  /**
10
12
  * Deterministically assemble the cumulative diff from a task baseline and the
@@ -20,7 +22,8 @@ export function assembleCumulativeDiffContext(input) {
20
22
  ...currentByPath.keys(),
21
23
  ]);
22
24
  const sortedPaths = [...allPaths].sort();
23
- const perPath = sortedPaths.map((p) => {
25
+ const perPath = sortedPaths
26
+ .map((p) => {
24
27
  const baselineSha256 = baselineByPath.get(p) ?? null;
25
28
  const hasCurrent = currentByPath.has(p);
26
29
  const currentSha256 = hasCurrent
@@ -34,10 +37,17 @@ export function assembleCumulativeDiffContext(input) {
34
37
  status = hasCurrent ? "deleted" : "unchanged";
35
38
  }
36
39
  else {
37
- status = currentSha256 === baselineSha256 ? "unchanged" : "modified";
40
+ status =
41
+ currentSha256 === baselineSha256 ? "unchanged" : "modified";
38
42
  }
39
43
  return { path: p, status, baselineSha256, currentSha256 };
40
- });
44
+ })
45
+ // Cumulative diff carries only actual changes. The worktree baseline is
46
+ // the full repository inventory (git ls-files -co), so an unfiltered
47
+ // perPath enumerates thousands of unchanged hashes (e.g. 3000+ entries
48
+ // ≈ 700KB), ballooning the review context and blowing the review node
49
+ // read budget on a single read. Unchanged entries add no diff evidence.
50
+ .filter((entry) => entry.status !== "unchanged");
41
51
  const baselineDigest = deterministicSha256([...input.baselineEntries].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
42
52
  const cumulativeDigest = deterministicSha256(perPath);
43
53
  return {
@@ -45,7 +55,7 @@ export function assembleCumulativeDiffContext(input) {
45
55
  baselineDigest,
46
56
  perPath,
47
57
  cumulativeDigest,
48
- empty: perPath.every((entry) => entry.status === "unchanged"),
58
+ empty: perPath.length === 0,
49
59
  };
50
60
  }
51
61
  async function sha256File(filePath) {
@@ -154,12 +164,31 @@ export function compareTypedReviewToLegacyJsonVerdict(input) {
154
164
  : `typed review verdict ${typedVerdict} maps to ${expected} but legacy JSON verdict is ${legacyVerdict}`,
155
165
  };
156
166
  }
167
+ /**
168
+ * review-context 稳定失败枚举(AC-011 / AC-HARD-005):v2 drift / manifest 失败返回
169
+ * 结构化、可消费的 failure code(稳定枚举 + reason 的 envelope),既有调用方无需字符串
170
+ * 匹配裸 Error 文本。message 保持人类可读(shell stderr 兼容),code 供机械化路由。
171
+ */
172
+ export const FRONTEND_REVIEW_CONTEXT_FAILURE_CODES = [
173
+ "review-context-invalid-contract",
174
+ "review-context-verification-unbound",
175
+ "review-context-ledger-drift",
176
+ "review-context-manifest-missing",
177
+ ];
178
+ export class FrontendReviewContextFailure extends Error {
179
+ code;
180
+ constructor(code, message, options) {
181
+ super(message, options);
182
+ this.name = "FrontendReviewContextFailure";
183
+ this.code = code;
184
+ }
185
+ }
157
186
  async function readRequiredJson(runDir, relativePath) {
158
187
  try {
159
188
  return JSON.parse(await readFile(path.join(runDir, relativePath), "utf8"));
160
189
  }
161
- catch {
162
- throw new Error(`frontend review context missing or invalid ${relativePath}`);
190
+ catch (error) {
191
+ throw new FrontendReviewContextFailure("review-context-manifest-missing", `frontend review context missing or invalid ${relativePath}: ${error instanceof Error ? error.message : String(error)}`);
163
192
  }
164
193
  }
165
194
  async function readOptionalLintAssessment(runDir) {
@@ -211,8 +240,9 @@ async function readOptionalVerifyFailure(runDir) {
211
240
  }
212
241
  function assertReviewEvidence(input) {
213
242
  const contract = frontendImplementationContractSchema.safeParse(input.contract);
214
- if (!contract.success)
215
- throw new Error("frontend review context invalid implementation contract");
243
+ if (!contract.success) {
244
+ throw new FrontendReviewContextFailure("review-context-invalid-contract", "frontend review context invalid implementation contract");
245
+ }
216
246
  const trace = input.verificationTrace;
217
247
  const expectedTargets = new Map(contract.data.verificationTargets.map((target) => [target.id, target]));
218
248
  const actualTargets = Array.isArray(trace?.targets) ? trace.targets : [];
@@ -248,17 +278,15 @@ function assertReviewEvidence(input) {
248
278
  actualIds.size !== expectedTargets.size ||
249
279
  !mockEvidenceBound ||
250
280
  trace.mockStrategy !== contract.data.mockApi.strategy) {
251
- throw new Error("frontend review context verification trace is not bound to a passing contract");
281
+ throw new FrontendReviewContextFailure("review-context-verification-unbound", "frontend review context verification trace is not bound to a passing contract");
252
282
  }
253
- // M8: no repair-assessment hard requirement. When the artifact exists it must
254
- // be schema-valid; when absent the verify failure capsule (if any) is
255
- // already surfaced through the payload as verifyFailure.
256
283
  }
257
284
  export async function runFrontendReviewContextGate(input) {
258
285
  const contract = await readRequiredJson(input.runDir, "contracts/frontend-implementation-contract.json");
259
286
  const parsedContract = frontendImplementationContractSchema.safeParse(contract);
260
- if (!parsedContract.success)
261
- throw new Error("frontend review context invalid implementation contract");
287
+ if (!parsedContract.success) {
288
+ throw new FrontendReviewContextFailure("review-context-invalid-contract", "frontend review context invalid implementation contract");
289
+ }
262
290
  const diff = await runFrontendWorktreeDiffGate({
263
291
  ...input,
264
292
  authorizedChangedPaths: parsedContract.data.targets.files,
@@ -267,12 +295,15 @@ export async function runFrontendReviewContextGate(input) {
267
295
  const repairAssessment = await readOptionalRepairAssessment(input.runDir);
268
296
  const verifyFailure = await readOptionalVerifyFailure(input.runDir);
269
297
  const lintAssessment = await readOptionalLintAssessment(input.runDir);
270
- assertReviewEvidence({ contract, verificationTrace });
271
298
  const cumulativeDiff = await captureCumulativeDiffContext({
272
299
  runDir: input.runDir,
273
300
  workspaceRoot: input.workspaceRoot,
274
301
  diff,
275
302
  });
303
+ assertReviewEvidence({ contract, verificationTrace, repairAssessment });
304
+ // Source fidelity ledger 核对(AC-008 / AC-FIDELITY-008):ledger 缺失/漂移即阻断;
305
+ // 同时把核对清单注入 review-context 供最终 reviewer 机械化核对。
306
+ const sourceFidelity = await buildReviewSourceFidelityChecklist(input, parsedContract.data);
276
307
  const payload = {
277
308
  schemaVersion: 1,
278
309
  schemaId: FRONTEND_REVIEW_CONTEXT_SCHEMA_ID,
@@ -281,10 +312,13 @@ export async function runFrontendReviewContextGate(input) {
281
312
  ...(repairAssessment ? { repairAssessment } : {}),
282
313
  ...(verifyFailure ? { verifyFailure } : {}),
283
314
  ...(lintAssessment ? { lintAssessment } : {}),
315
+ sourceFidelity,
284
316
  diff: {
285
317
  schemaId: diff.schemaId,
286
318
  patchPath: diff.patchPath,
287
319
  patchSha256: diff.patchSha256,
320
+ reviewSummaryPath: diff.reviewSummaryPath,
321
+ reviewIndexPath: diff.reviewIndexPath,
288
322
  changedFiles: diff.changedFiles,
289
323
  untrackedFiles: diff.untrackedFiles,
290
324
  empty: diff.empty,
@@ -294,6 +328,59 @@ export async function runFrontendReviewContextGate(input) {
294
328
  const artifactPath = await writeDagRunJsonArtifact(input.runDir, "contracts/frontend-review-context.json", payload);
295
329
  return { ok: true, artifactPath, diff };
296
330
  }
331
+ /**
332
+ * 最终 review 核对清单(机械化):ledger 缺失/漂移、canonical requirement 漏项、
333
+ * diff 越界、验证证据缺失均阻断 pass。清单以有界安全摘要注入提示词,不泄漏原文。
334
+ */
335
+ async function buildReviewSourceFidelityChecklist(input, contract) {
336
+ const binding = contract.sourceBinding;
337
+ const checklist = [];
338
+ // 契约级 sourceBinding 的 v2 语义:四个 ledger 字段必须齐全(缺失即 v1 遗留绑定)。
339
+ const boundV2 = Boolean(binding.ledgerPath) &&
340
+ Boolean(binding.ledgerSha256) &&
341
+ Boolean(binding.inputDigest) &&
342
+ binding.requirementToFragments !== undefined;
343
+ if (!boundV2) {
344
+ return {
345
+ bound: false,
346
+ ledger: null,
347
+ checklist: [
348
+ "source fidelity ledger: not bound (v1 binding); 按现有语义评审",
349
+ "canonical contract 与实际 diff 逐项核对,验证证据齐全后 verdict 可为 pass",
350
+ ],
351
+ };
352
+ }
353
+ const absolute = resolveDagTaskSourcePath({
354
+ workspaceRoot: input.workspaceRoot,
355
+ taskId: contract.sourceBinding.taskId,
356
+ sourcePath: binding.ledgerPath,
357
+ });
358
+ let ledger = null;
359
+ try {
360
+ const raw = await readFile(absolute, "utf8");
361
+ const actualSha = createHash("sha256").update(raw).digest("hex");
362
+ if (actualSha !== binding.ledgerSha256) {
363
+ throw new Error(`ledger sha256 mismatch (bound ${binding.ledgerSha256}, observed ${actualSha})`);
364
+ }
365
+ ledger = parseLedgerJson(raw);
366
+ }
367
+ catch (error) {
368
+ // AC-011 / AC-HARD-005:v2 drift 返回结构化 failure code(稳定枚举 + reason),
369
+ // 不再裸 throw new Error;message 保持人类可读供 shell stderr。
370
+ throw new FrontendReviewContextFailure("review-context-ledger-drift", `frontend review context source fidelity ledger unavailable or drifted: ${error instanceof Error ? error.message : String(error)}`);
371
+ }
372
+ checklist.push("ledger: 存在且绑定 sha256 一致(无缺失/漂移)", `ledger 覆盖统计: ${ledgerStatsLine(ledger)}`, "canonical contract requirements 必须覆盖 ledger 全部 canonical requirement IDs", "实际 diff 必须限制在契约 targets 内(越界即阻断 pass)", "验证证据必须绑定契约 verificationTargets(缺失即阻断 pass)", "仅满足派生摘要而遗漏 ledger requirement 时 verdict 不得为 pass");
373
+ return {
374
+ bound: true,
375
+ ledger: {
376
+ path: binding.ledgerPath,
377
+ sha256: binding.ledgerSha256,
378
+ inputDigest: binding.inputDigest,
379
+ stats: ledgerStatsLine(ledger),
380
+ },
381
+ checklist,
382
+ };
383
+ }
297
384
  export function formatFrontendReviewContextStdout(result) {
298
385
  return [
299
386
  "Frontend review context: pass",
@@ -10,10 +10,17 @@ import { readTypedEventStoreFromJsonl, } from "./frontend-typed-event-store.js";
10
10
  * rendered into a downstream-task PRD draft that a later DAG round can consume
11
11
  * instead of repeating the previous flow. Rendering is deterministic (pure);
12
12
  * only the JSONL read and PRD write are IO.
13
+ *
14
+ * The design-review analog (`request_design_changes`) reuses the same PRD
15
+ * rendering and typed-fact channel: a design rejection is a definite finding
16
+ * (not a protocol/environment error), so it auto-re-enters the next plan round
17
+ * with the committed design findings instead of terminating the run.
13
18
  */
14
19
  export const FRONTEND_REVIEW_FINDINGS_PRD_REL = "contracts/frontend-review-findings-prd.md";
15
20
  /** Review node id whose committed typed facts drive the findings PRD. */
16
21
  export const FRONTEND_REVIEW_NODE_ID = "frontend-review-pi";
22
+ /** Design review node id whose committed typed facts drive the findings PRD. */
23
+ export const FRONTEND_DESIGN_REVIEW_NODE_ID = "frontend-design-review-pi";
17
24
  /** Deterministic issueCategory → failure owner mapping (A+B AC-009 §4.10). */
18
25
  export function classifyReviewIssueCategoryFailureOwner(issueCategory) {
19
26
  switch (issueCategory) {
@@ -122,10 +129,34 @@ export function deriveFrontendReviewFixScope(fact) {
122
129
  * approve_review, missing store, or an unreadable store).
123
130
  */
124
131
  export async function readCommittedReviewRequestFact(input) {
132
+ const { runDir: reviewRunDir } = input;
125
133
  const reviewNodeId = input.reviewNodeId ?? FRONTEND_REVIEW_NODE_ID;
134
+ return readCommittedRequestFactByKind({
135
+ runDir: reviewRunDir,
136
+ nodeId: reviewNodeId,
137
+ factsFile: "review-typed-facts.jsonl",
138
+ kind: "request_review_changes",
139
+ });
140
+ }
141
+ /**
142
+ * Read the committed `request_design_changes` fact from the design review
143
+ * node's typed event store. Returns undefined when no committed request fact
144
+ * exists (e.g. approve_design, missing store, or an unreadable store).
145
+ */
146
+ export async function readCommittedDesignRequestFact(input) {
147
+ const { runDir: designRunDir } = input;
148
+ const designNodeId = input.designNodeId ?? FRONTEND_DESIGN_REVIEW_NODE_ID;
149
+ return readCommittedRequestFactByKind({
150
+ runDir: designRunDir,
151
+ nodeId: designNodeId,
152
+ factsFile: "design-typed-facts.jsonl",
153
+ kind: "request_design_changes",
154
+ });
155
+ }
156
+ async function readCommittedRequestFactByKind(input) {
126
157
  let records;
127
158
  try {
128
- records = await readTypedEventStoreFromJsonl(path.join(input.runDir, reviewNodeId, "review-typed-facts.jsonl"));
159
+ records = await readTypedEventStoreFromJsonl(path.join(input.runDir, input.nodeId, input.factsFile));
129
160
  }
130
161
  catch {
131
162
  return undefined;
@@ -133,9 +164,7 @@ export async function readCommittedReviewRequestFact(input) {
133
164
  const committed = records.filter((record) => record.phase === "committed");
134
165
  if (committed.length === 0)
135
166
  return undefined;
136
- // A committed approve_review means the review passed; only a committed
137
- // request_review_changes carries a "not passed" verdict.
138
- const request = committed.find((record) => record.fact.kind === "request_review_changes");
167
+ const request = committed.find((record) => record.fact.kind === input.kind);
139
168
  if (!request)
140
169
  return undefined;
141
170
  const fact = request.fact;
@@ -166,25 +195,28 @@ function findingLines(findings) {
166
195
  }
167
196
  /**
168
197
  * Deterministically render a downstream-task PRD draft from a committed
169
- * `request_review_changes` fact. Same fact always renders byte-identical
170
- * output; the parent run id and review node id are the only context.
198
+ * `request_review_changes` / `request_design_changes` fact. Same fact always
199
+ * renders byte-identical output; the parent run id and review node id are the
200
+ * only context.
171
201
  */
172
202
  export function renderFrontendReviewFindingsPrd(input) {
173
203
  const { parentRunId, fact } = input;
174
- const reviewNodeId = input.reviewNodeId ?? FRONTEND_REVIEW_NODE_ID;
204
+ const isDesign = fact.kind === "request_design_changes";
205
+ const reviewNodeId = input.reviewNodeId ??
206
+ (isDesign ? FRONTEND_DESIGN_REVIEW_NODE_ID : FRONTEND_REVIEW_NODE_ID);
175
207
  const restartPhase = classifyReviewIssueCategoryFailureOwner(fact.issueCategory);
176
208
  const scope = deriveFrontendReviewFixScope(fact);
177
209
  const targetSummary = scope.targetFiles.length > 0
178
210
  ? scope.targetFiles.join(", ")
179
211
  : fact.issueCategory;
180
212
  return [
181
- `# 修复 review 发现(${scope.shape}):${targetSummary}`,
213
+ `# 修复 ${isDesign ? "设计" : "review"} 发现(${scope.shape}):${targetSummary}`,
182
214
  "",
183
215
  "## 背景",
184
216
  "",
185
217
  `- parentRunId: \`${parentRunId}\``,
186
218
  `- reviewNodeId: \`${reviewNodeId}\``,
187
- `- typedVerdict: \`request_review_changes\``,
219
+ `- typedVerdict: \`${fact.kind}\``,
188
220
  `- issueCategory: \`${fact.issueCategory}\``,
189
221
  `- restartPhase/failureOwner: \`${restartPhase}\``,
190
222
  "",
@@ -216,7 +248,7 @@ export function renderFrontendReviewFindingsPrd(input) {
216
248
  "",
217
249
  `- 按 issueCategory 映射的重启阶段(${restartPhase})定点修复,不复用上一轮未通过的实现。`,
218
250
  "- 逐条关闭上面「问题清单」中的 findings,并保留上一轮已通过的验证目标继续有效。",
219
- "- 完成修复后重新走 frontend DAG 的 implement → verify → review 链,直到 review 提交 approve_review",
251
+ `- 完成修复后重新走 frontend DAG 的 ${isDesign ? "plan → design" : "implement → verify → review"} 链,直到${isDesign ? " design-review 提交 approve_design" : " review 提交 approve_review"}。`,
220
252
  "",
221
253
  ].join("\n");
222
254
  }
@@ -270,7 +270,7 @@ export function restorePlanPatchFromCommittedFacts(records) {
270
270
  * writeSet-external compile authority (`frontend-implementation-contract.ts`)
271
271
  * can still restore a plain `patch` object.
272
272
  */
273
- export function assemblePlanPatchFromCommittedFacts(records) {
273
+ export function assemblePlanPatchFromCommittedFacts(records, contractRequirements) {
274
274
  const facts = records
275
275
  .filter((record) => record.phase === "committed")
276
276
  .map((record) => record.fact)
@@ -344,7 +344,41 @@ export function assemblePlanPatchFromCommittedFacts(records) {
344
344
  return { ...entry, expectedOutcome: `implied by contract: ${id}` };
345
345
  }
346
346
  return entry;
347
- });
347
+ })
348
+ // Source fidelity ledger (AC-005/AC-006): the contract node declared the
349
+ // authoritative sourceFragmentIds/sourceRefs for each requirement from
350
+ // the ledger's requirement→fragment mapping. The plan inherits them by
351
+ // requirement id so the compiled canonical contract carries the same
352
+ // provenance evidence without the plan re-deriving (or fabricating) it.
353
+ .map((entry) => {
354
+ if (!contractRequirements)
355
+ return entry;
356
+ const id = asString(entry.id);
357
+ if (!id)
358
+ return entry;
359
+ const inherited = contractRequirements.get(id);
360
+ if (!inherited)
361
+ return entry;
362
+ const merged = { ...entry };
363
+ // Inherit the contract-declared provenance only when the plan did
364
+ // not already declare it (model override wins; missing inherits).
365
+ if (!Array.isArray(entry.sourceFragmentIds)) {
366
+ if (inherited.sourceFragmentIds) {
367
+ merged.sourceFragmentIds = inherited.sourceFragmentIds;
368
+ }
369
+ }
370
+ if (!Array.isArray(entry.sourceRefs)) {
371
+ if (inherited.sourceRefs) {
372
+ merged.sourceRefs = inherited.sourceRefs;
373
+ }
374
+ }
375
+ return merged;
376
+ })
377
+ // A requirement id is a canonical identity; drop duplicates so the
378
+ // compiled patch never carries the same requirement twice (the
379
+ // record_plan_requirement tool rejects re-records, this dedupes any
380
+ // legacy/edge duplicates at the compile authority).
381
+ .filter((entry, index, all) => all.findIndex((other) => asString(other.id) === asString(entry.id)) === index);
348
382
  if (requirements.length > 0)
349
383
  patch.requirements = requirements;
350
384
  const verificationTargets = facts
@@ -0,0 +1,108 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { resolveDagTaskSourcePath } from "../../task/dag-source-paths.js";
4
+ import { parseLedgerJson, validateRequirementLedger, } from "../../task/source-prepare/ledger.js";
5
+ import { isDagSourceBindingV2 } from "./types.js";
6
+ /**
7
+ * Validate the semantic source-fidelity ledger binding for a canonical frontend
8
+ * contract. This is deliberately shared: the compatibility prewrite gate may
9
+ * report a structured prewrite failure, while the generated production DAG
10
+ * must deny writer admission on exactly the same evidence.
11
+ */
12
+ export async function checkFrontendSourceFidelityLedger(input) {
13
+ const { binding } = input;
14
+ if (!isDagSourceBindingV2(binding)) {
15
+ return { ok: true, failureCode: null, failureReason: null, ledger: null };
16
+ }
17
+ const absolute = resolveDagTaskSourcePath({
18
+ workspaceRoot: input.workspaceRoot,
19
+ taskId: binding.taskId,
20
+ sourcePath: binding.ledgerPath,
21
+ });
22
+ let raw;
23
+ try {
24
+ raw = await readFile(absolute, "utf8");
25
+ }
26
+ catch (error) {
27
+ return {
28
+ ok: false,
29
+ failureCode: "source-fidelity-ledger-missing",
30
+ failureReason: `frontend source fidelity ledger missing: ${binding.ledgerPath} (${error instanceof Error ? error.message : String(error)})`,
31
+ ledger: null,
32
+ };
33
+ }
34
+ const actualSha = createHash("sha256").update(raw).digest("hex");
35
+ if (actualSha !== binding.ledgerSha256) {
36
+ return {
37
+ ok: false,
38
+ failureCode: "source-fidelity-ledger-stale",
39
+ failureReason: `frontend source fidelity ledger stale or tampered: ${binding.ledgerPath} (bound ${binding.ledgerSha256}, observed ${actualSha})`,
40
+ ledger: null,
41
+ };
42
+ }
43
+ let ledger;
44
+ try {
45
+ ledger = parseLedgerJson(raw);
46
+ }
47
+ catch (error) {
48
+ return {
49
+ ok: false,
50
+ failureCode: "source-fidelity-ledger-stale",
51
+ failureReason: `frontend source fidelity ledger is not a valid v1 ledger: ${error instanceof Error ? error.message : String(error)}`,
52
+ ledger: null,
53
+ };
54
+ }
55
+ if (ledger.inputDigest !== binding.inputDigest) {
56
+ return {
57
+ ok: false,
58
+ failureCode: "source-fidelity-ledger-stale",
59
+ failureReason: `frontend source fidelity ledger inputDigest mismatch: bound ${binding.inputDigest}, ledger ${ledger.inputDigest}`,
60
+ ledger,
61
+ };
62
+ }
63
+ const validation = validateRequirementLedger(ledger);
64
+ const firstError = (code) => validation.find((error) => error.code === code)?.message ?? null;
65
+ const invalid = (failureCode, failureReason) => ({ ok: false, failureCode, failureReason, ledger });
66
+ if (ledger.stats.unresolvedCount > 0 || validation.some((e) => e.code === "SOURCE_FIDELITY_UNRESOLVED")) {
67
+ return invalid("source-fidelity-unresolved", firstError("SOURCE_FIDELITY_UNRESOLVED") ?? `frontend source fidelity ledger has ${ledger.stats.unresolvedCount} unresolved fragment(s)`);
68
+ }
69
+ if (ledger.stats.conflictCount > 0 || validation.some((e) => e.code === "SOURCE_FIDELITY_CONFLICT")) {
70
+ return invalid("source-fidelity-conflict", firstError("SOURCE_FIDELITY_CONFLICT") ?? `frontend source fidelity ledger has ${ledger.stats.conflictCount} conflict(s)`);
71
+ }
72
+ if (ledger.stats.highRiskUncoveredCount > 0 || validation.some((e) => e.code === "SOURCE_FIDELITY_HIGH_RISK_UNCOVERED")) {
73
+ return invalid("source-fidelity-high-risk-uncovered", firstError("SOURCE_FIDELITY_HIGH_RISK_UNCOVERED") ?? `frontend source fidelity ledger has ${ledger.stats.highRiskUncoveredCount} high-risk uncovered fragment(s)`);
74
+ }
75
+ const notCoveredError = validation.find((e) => e.code === "SOURCE_FIDELITY_REQUIREMENT_NOT_COVERED");
76
+ if (notCoveredError) {
77
+ return invalid("source-fidelity-requirement-not-covered", notCoveredError.message);
78
+ }
79
+ const refInvalidError = validation.find((e) => e.code === "SOURCE_FIDELITY_SOURCE_REF_INVALID" || e.code === "SOURCE_FIDELITY_FRAGMENT_INVALID");
80
+ if (refInvalidError) {
81
+ return invalid("source-fidelity-source-ref-invalid", refInvalidError.message);
82
+ }
83
+ const ledgerIds = new Set(ledger.canonicalRequirements.map((requirement) => requirement.id));
84
+ const contractIds = new Set(input.contractRequirements.map((r) => r.id));
85
+ const uncoveredRequirements = [...ledgerIds].filter((id) => !contractIds.has(id));
86
+ if (uncoveredRequirements.length > 0) {
87
+ return invalid("source-fidelity-requirement-not-covered", `frontend contract requirements do not cover ledger canonical requirement ids: ${uncoveredRequirements.join(", ")}`);
88
+ }
89
+ const fragmentById = new Map(ledger.fragments.map((fragment) => [fragment.id, fragment]));
90
+ for (const requirement of input.contractRequirements) {
91
+ if (!ledgerIds.has(requirement.id))
92
+ continue;
93
+ const declared = requirement.sourceFragmentIds ?? [];
94
+ if (declared.length === 0) {
95
+ return invalid("source-fidelity-source-ref-invalid", `frontend contract requirement ${requirement.id} lacks sourceFragmentIds for the bound ledger`);
96
+ }
97
+ const boundFragments = new Set(ledger.canonicalRequirements.find((r) => r.id === requirement.id)?.sourceFragmentIds ?? []);
98
+ for (const fragmentId of declared) {
99
+ if (!fragmentById.has(fragmentId)) {
100
+ return invalid("source-fidelity-source-ref-invalid", `frontend contract requirement ${requirement.id} references unknown fragment ${fragmentId}`);
101
+ }
102
+ if (!boundFragments.has(fragmentId)) {
103
+ return invalid("source-fidelity-source-ref-invalid", `frontend contract requirement ${requirement.id} declares fabricated fragment reference ${fragmentId} (not bound in ledger)`);
104
+ }
105
+ }
106
+ }
107
+ return { ok: true, failureCode: null, failureReason: null, ledger };
108
+ }
@@ -5,6 +5,10 @@ import { runResilientGitCommand } from "../../shared/resilient-git.js";
5
5
  import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
6
6
  import { pathMatchesPattern } from "../../shared/git-progress.js";
7
7
  export const FRONTEND_WORKTREE_DIFF_SCHEMA_ID = "frontend-worktree-diff-v1";
8
+ export const FRONTEND_REVIEW_DIFF_INDEX_SCHEMA_ID = "frontend-review-diff-index-v1";
9
+ const MAX_REVIEW_DIFF_SUMMARY_ENTRIES = 48;
10
+ /** Maximum diff payload per review artifact; metadata adds a small fixed header. */
11
+ const MAX_REVIEW_DIFF_FRAGMENT_BYTES = 20 * 1024;
8
12
  export const FRONTEND_WORKTREE_BASELINE_SCHEMA_ID = "frontend-worktree-baseline-v1";
9
13
  async function runGit(cwd, args) {
10
14
  const result = await runResilientGitCommand({ cwd, args, readOnly: true });
@@ -56,6 +60,98 @@ function splitLines(text) {
56
60
  .map((line) => line.trim())
57
61
  .filter(Boolean);
58
62
  }
63
+ function buildReviewDiffSummary(input) {
64
+ const files = [...input.hunks.reduce((grouped, hunk) => {
65
+ const existing = grouped.get(hunk.path);
66
+ if (existing) {
67
+ existing.bytes += hunk.bytes;
68
+ existing.lastPatchPath = hunk.patchPath;
69
+ return grouped;
70
+ }
71
+ grouped.set(hunk.path, {
72
+ path: hunk.path,
73
+ bytes: hunk.bytes,
74
+ parts: hunk.parts,
75
+ firstPatchPath: hunk.patchPath,
76
+ lastPatchPath: hunk.patchPath,
77
+ });
78
+ return grouped;
79
+ }, new Map()).values()];
80
+ const listedFiles = files.slice(0, MAX_REVIEW_DIFF_SUMMARY_ENTRIES);
81
+ const remainingFiles = files.length - listedFiles.length;
82
+ const listedUntracked = input.untrackedFiles.slice(0, MAX_REVIEW_DIFF_SUMMARY_ENTRIES);
83
+ const remainingUntracked = input.untrackedFiles.length - listedUntracked.length;
84
+ return [
85
+ "# Frontend review diff summary",
86
+ "",
87
+ "Read this bounded summary first. Do not read artifacts/diff_patch.patch in full; it is retained only as the canonical audit artifact.",
88
+ "For a semantic finding, read only the matching per-file diff fragment below and then the current source file when necessary. Read fragments in part order when a file has more than one.",
89
+ "",
90
+ `Canonical patch SHA-256: ${input.patchSha256}`,
91
+ `Canonical patch bytes: ${input.patchBytes}`,
92
+ `Tracked changed files: ${files.length}`,
93
+ `Review diff fragments: ${input.hunks.length}`,
94
+ `Untracked files (inventory only): ${input.untrackedFiles.length}`,
95
+ "",
96
+ "## Tracked file diff fragment references",
97
+ ...(listedFiles.length > 0
98
+ ? listedFiles.map((file) => file.parts === 1
99
+ ? `- ${file.path} — ${file.bytes} bytes — read ${file.firstPatchPath} only if this file needs review`
100
+ : `- ${file.path} — ${file.bytes} bytes across ${file.parts} ordered fragments — read ${file.firstPatchPath} through ${file.lastPatchPath} only if this file needs review`)
101
+ : ["- (none)"]),
102
+ ...(remainingFiles > 0
103
+ ? [`- … ${remainingFiles} additional tracked files are recorded in contracts/frontend-review-diff-index.json; do not bulk-read them.`]
104
+ : []),
105
+ "",
106
+ "## Untracked inventory",
107
+ ...(listedUntracked.length > 0
108
+ ? listedUntracked.map((file) => `- ${file}`)
109
+ : ["- (none)"]),
110
+ ...(remainingUntracked > 0
111
+ ? [`- … ${remainingUntracked} additional untracked files are recorded in contracts/frontend-review-diff-index.json.`]
112
+ : []),
113
+ "",
114
+ ].join("\n");
115
+ }
116
+ function splitReviewDiffFragments(text) {
117
+ const fragments = [];
118
+ let current = "";
119
+ for (const line of text.match(/[^\n]*\n|[^\n]+/g) ?? []) {
120
+ const lineBytes = Buffer.byteLength(line);
121
+ if (current.length > 0 &&
122
+ Buffer.byteLength(current) + lineBytes >
123
+ MAX_REVIEW_DIFF_FRAGMENT_BYTES) {
124
+ fragments.push(current);
125
+ current = "";
126
+ }
127
+ if (lineBytes <= MAX_REVIEW_DIFF_FRAGMENT_BYTES) {
128
+ current += line;
129
+ continue;
130
+ }
131
+ // Generated/minified files can produce a single diff line larger than the
132
+ // review budget. Split by Unicode code point so no fragment exceeds the
133
+ // byte limit while preserving the original patch bytes when concatenated.
134
+ let oversizedLineFragment = "";
135
+ let oversizedLineFragmentBytes = 0;
136
+ for (const codePoint of line) {
137
+ const codePointBytes = Buffer.byteLength(codePoint);
138
+ if (oversizedLineFragmentBytes > 0 &&
139
+ oversizedLineFragmentBytes + codePointBytes >
140
+ MAX_REVIEW_DIFF_FRAGMENT_BYTES) {
141
+ fragments.push(oversizedLineFragment);
142
+ oversizedLineFragment = "";
143
+ oversizedLineFragmentBytes = 0;
144
+ }
145
+ oversizedLineFragment += codePoint;
146
+ oversizedLineFragmentBytes += codePointBytes;
147
+ }
148
+ if (oversizedLineFragment)
149
+ fragments.push(oversizedLineFragment);
150
+ }
151
+ if (current.length > 0)
152
+ fragments.push(current);
153
+ return fragments.length > 0 ? fragments : [""];
154
+ }
59
155
  async function sha256File(filePath) {
60
156
  try {
61
157
  const bytes = await readFile(filePath);
@@ -206,11 +302,65 @@ export async function runFrontendWorktreeDiffGate(input) {
206
302
  const absolutePatch = path.join(input.runDir, ...relativePatch.split("/"));
207
303
  await mkdir(path.dirname(absolutePatch), { recursive: true });
208
304
  await writeFile(absolutePatch, patchText, "utf8");
305
+ // Keep the complete patch as immutable audit evidence, but create a bounded
306
+ // reviewer entrypoint plus one file-scoped artifact per tracked change. This
307
+ // prevents final review from loading a multi-thousand-line patch into a
308
+ // single Pi session merely to inspect one or two relevant files.
309
+ const reviewHunks = [];
310
+ const reviewHunksDir = path.join(input.runDir, "artifacts", "diff-hunks");
311
+ await mkdir(reviewHunksDir, { recursive: true });
312
+ for (const [index, file] of changedFiles.entries()) {
313
+ const filePatch = await run(root, ["diff", "--binary", "HEAD", "--", file]);
314
+ if (filePatch.code !== 0) {
315
+ throw new Error(`frontend worktree diff hunk failed for ${file}: ${filePatch.stderr.trim() || filePatch.stdout.trim()}`);
316
+ }
317
+ const text = filePatch.stdout.endsWith("\n") || filePatch.stdout.length === 0
318
+ ? filePatch.stdout
319
+ : `${filePatch.stdout}\n`;
320
+ const fragments = splitReviewDiffFragments(text);
321
+ for (const [partIndex, fragment] of fragments.entries()) {
322
+ const part = partIndex + 1;
323
+ const patchPath = `artifacts/diff-hunks/${String(index + 1).padStart(3, "0")}-${String(part).padStart(3, "0")}.patch`;
324
+ const artifact = [
325
+ "# Frontend review diff fragment",
326
+ `# path: ${file}`,
327
+ `# part: ${part}/${fragments.length}`,
328
+ "",
329
+ fragment,
330
+ ].join("\n");
331
+ await writeFile(path.join(input.runDir, ...patchPath.split("/")), artifact, "utf8");
332
+ reviewHunks.push({
333
+ path: file,
334
+ patchPath,
335
+ sha256: createHash("sha256").update(artifact).digest("hex"),
336
+ bytes: Buffer.byteLength(artifact),
337
+ part,
338
+ parts: fragments.length,
339
+ });
340
+ }
341
+ }
342
+ const reviewIndexPath = "contracts/frontend-review-diff-index.json";
343
+ await writeDagRunJsonArtifact(input.runDir, reviewIndexPath, {
344
+ schemaVersion: 1,
345
+ schemaId: FRONTEND_REVIEW_DIFF_INDEX_SCHEMA_ID,
346
+ patchSha256,
347
+ hunks: reviewHunks,
348
+ untrackedFiles,
349
+ });
350
+ const reviewSummaryPath = "artifacts/diff_summary.md";
351
+ await writeFile(path.join(input.runDir, ...reviewSummaryPath.split("/")), buildReviewDiffSummary({
352
+ patchSha256,
353
+ patchBytes: Buffer.byteLength(patchText),
354
+ hunks: reviewHunks,
355
+ untrackedFiles,
356
+ }), "utf8");
209
357
  const payload = {
210
358
  schemaVersion: 1,
211
359
  schemaId: FRONTEND_WORKTREE_DIFF_SCHEMA_ID,
212
360
  patchPath: relativePatch,
213
361
  patchSha256,
362
+ reviewSummaryPath,
363
+ reviewIndexPath,
214
364
  changedFiles,
215
365
  untrackedFiles,
216
366
  empty: changedFiles.length === 0 && untrackedFiles.length === 0,
@@ -223,6 +373,8 @@ export async function runFrontendWorktreeDiffGate(input) {
223
373
  patchPath: relativePatch,
224
374
  patchAbsolutePath: absolutePatch,
225
375
  patchSha256,
376
+ reviewSummaryPath,
377
+ reviewIndexPath,
226
378
  changedFiles,
227
379
  untrackedFiles,
228
380
  empty: payload.empty,
@@ -235,6 +387,8 @@ export function formatFrontendWorktreeDiffStdout(result) {
235
387
  `schemaId=${result.schemaId}`,
236
388
  `patchPath=${result.patchPath}`,
237
389
  `patchSha256=${result.patchSha256}`,
390
+ `reviewSummaryPath=${result.reviewSummaryPath}`,
391
+ `reviewIndexPath=${result.reviewIndexPath}`,
238
392
  `changedFiles=${result.changedFiles.length}`,
239
393
  `untrackedFiles=${result.untrackedFiles.length}`,
240
394
  `empty=${result.empty ? "true" : "false"}`,