@tea-agent/loop-agent 0.39.0-beta.8 → 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 (158) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +17 -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/update/runtime-activity.js +1 -29
  7. package/dist/commands/dag-rerun.js +1 -1
  8. package/dist/commands/init.js +26 -0
  9. package/dist/executors/dag-pi-executor.js +326 -8
  10. package/dist/executors/pi-read-budget-policy.js +166 -0
  11. package/dist/executors/shell-executor.js +26 -30
  12. package/dist/shared/dag-prompt-override.js +4 -5
  13. package/dist/shared/update/npm-client.js +33 -11
  14. package/dist/task/contract/apply.js +11 -1
  15. package/dist/task/contract/constants.js +2 -0
  16. package/dist/task/contract/hash.js +3 -0
  17. package/dist/task/contract/observe.js +25 -4
  18. package/dist/task/contract/paths.js +2 -1
  19. package/dist/task/contract/project.js +15 -1
  20. package/dist/task/contract/schema.js +2 -0
  21. package/dist/task/contract/transaction.js +12 -0
  22. package/dist/task/frontend-project-capability.js +123 -4
  23. package/dist/task/source-prepare/completeness.js +188 -0
  24. package/dist/task/source-prepare/fragment-inventory.js +477 -0
  25. package/dist/task/source-prepare/index.js +3 -0
  26. package/dist/task/source-prepare/ledger-reconciliation.js +127 -0
  27. package/dist/task/source-prepare/ledger-review.js +214 -0
  28. package/dist/task/source-prepare/ledger.js +545 -0
  29. package/dist/task/source-prepare/prepare.js +262 -1
  30. package/dist/task/source-prepare/semantic-intake.js +25 -5
  31. package/dist/task/source-prepare/source-fidelity-pi.js +384 -0
  32. package/dist/task/source-prepare/types.js +22 -0
  33. package/dist/worker/cli.js +12 -26
  34. package/dist/worker/console/console-handoff.js +82 -0
  35. package/dist/worker/console/console-update-and-init.js +115 -0
  36. package/dist/worker/console/console-update-runtime.js +84 -0
  37. package/dist/worker/console/draft-store.js +49 -0
  38. package/dist/worker/console/operation-store.js +1 -1
  39. package/dist/worker/console/operator-actions.js +129 -4
  40. package/dist/worker/console/operator-surface-health.js +1 -0
  41. package/dist/worker/console/prd-intake-bridge.js +13 -0
  42. package/dist/worker/console/routes.js +3 -0
  43. package/dist/worker/console/server.js +73 -5
  44. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-bPer1waP.js → abnfDiagram-N423BO3Z-D1-Z-BBa.js} +1 -1
  45. package/dist/worker/console/static/assets/{arc-BLdkwucW.js → arc-q8qAYm5m.js} +1 -1
  46. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-Bw4wo4I1.js → architectureDiagram-T3A2C74G-CAu4_lve.js} +1 -1
  47. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DUUkc0gr.js → blockDiagram-VBNYF7ZC-D5GhQvHm.js} +1 -1
  48. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BC3O0nob.js → c4Diagram-5PPSVZJV-ClQjeLkO.js} +1 -1
  49. package/dist/worker/console/static/assets/channel-ClAuhSUL.js +1 -0
  50. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-Dd4g2nti.js → chunk-2GRJ4B5K-DLqWlFbo.js} +1 -1
  51. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-s2EDm7-n.js → chunk-2Q5K7J3B-DkRT_usL.js} +1 -1
  52. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-GqtHM2yQ.js → chunk-5RXB4S5H-Cqp7Chi3.js} +1 -1
  53. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BzRFrN8j.js → chunk-5VM5RSS4-CedEdFB9.js} +1 -1
  54. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Bw9nFjCJ.js → chunk-6Q2QTUOP-BHM8vP5x.js} +1 -1
  55. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B4n-03Qm.js → chunk-GF5L2VYU-D7EFr377.js} +1 -1
  56. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-Dt75ZdBD.js → chunk-JWPE2WC7-DR38mTWV.js} +1 -1
  57. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-B_afMhHB.js → chunk-KBJHAD2P-BFv2LsR3.js} +1 -1
  58. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-Bd8Rqqg9.js → chunk-RYQCIY6F-BAvpJS5z.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-9boOMji4.js → chunk-XXDRQBXY-eKI9TO72.js} +1 -1
  60. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-cBgeCpVU.js +1 -0
  61. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-cBgeCpVU.js +1 -0
  62. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BtCycfCc.js → cose-bilkent-JH36ORCC-BMJXpgo4.js} +1 -1
  63. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-eTOts9L1.js → cynefin-VYW2F7L2-CKJ2ofZx.js} +1 -1
  64. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Cibd2mjg.js → cynefinDiagram-MW4NZA55-CsKnsazm.js} +1 -1
  65. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CDjDZtDV.js → dagre-VZM6K2ZE-DVQ46vIw.js} +1 -1
  66. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Btexn-JJ.js → diagram-7IWD3JNH-D_RuonYS.js} +1 -1
  67. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Cyl8sdpM.js → diagram-B4RE2ZJO-D2Mt0nMu.js} +1 -1
  68. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-Ev-83E1t.js → diagram-LBJQPF4R-DjBDuye6.js} +1 -1
  69. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-41LqE0Mc.js → diagram-Q27KOJAE-8VZkqpE_.js} +1 -1
  70. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BC39T5R3.js → diagram-UB23O5K3-D0izYxia.js} +1 -1
  71. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DzcNRuri.js → ebnfDiagram-BXEA7PRR-CncG2I95.js} +1 -1
  72. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-B5Frvvnz.js → erDiagram-JOGREHBK-DiBUUXR8.js} +1 -1
  73. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BapxROIM.js → flowDiagram-UKHOOZJN-Dtc0fArb.js} +1 -1
  74. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-Cd4d4wQ0.js → ganttDiagram-PKOTCBZU-BPNFP5OZ.js} +1 -1
  75. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CrL6buFW.js → gitGraphDiagram-DS77QQ5N-CsgpxEEd.js} +1 -1
  76. package/dist/worker/console/static/assets/{index-DBh_x_Ek.js → index-Bai8Z25q.js} +81 -81
  77. package/dist/worker/console/static/assets/index-CH0Tr5hY.css +1 -0
  78. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BrSiNxyd.js → infoDiagram-6WML65LV-kHGxkJUS.js} +1 -1
  79. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-gvlvY-n9.js → ishikawaDiagram-WSZJBQD7-CbLe9wrr.js} +1 -1
  80. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-D-YaPzRQ.js → journeyDiagram-NVQOT4AX-BOkwxAcP.js} +1 -1
  81. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BsmDv9E_.js → kanban-definition-27J2QSJJ-BHYQ2rDu.js} +1 -1
  82. package/dist/worker/console/static/assets/{linear-UWaoDN3h.js → linear-CHT4ujob.js} +1 -1
  83. package/dist/worker/console/static/assets/{mermaid.core-BlvpGVG8.js → mermaid.core-yL8sfqnY.js} +5 -5
  84. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-CUCIP5CP.js → mindmap-definition-FAOFIHXS-DZx1coV-.js} +1 -1
  85. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CB1aQUAK.js → pegDiagram-VL7TDLO6-DStWRCI8.js} +1 -1
  86. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BwehHxhf.js → pieDiagram-7S7Q4E2Y-DSKZkwzK.js} +1 -1
  87. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CFz30APB.js → quadrantDiagram-CIZ2JOQS-CmoEGOuW.js} +1 -1
  88. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BwNmw1i6.js → railroadDiagram-AXF67PYL-BLqzhKyq.js} +1 -1
  89. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-jg-tPXlI.js → requirementDiagram-LRYGKXZP-gbHeUabM.js} +1 -1
  90. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-BlyReDKl.js → sankeyDiagram-W5VNT64P-CaKkBCKO.js} +1 -1
  91. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DBUzW1f3.js → sequenceDiagram-SI44F4Z6-PUbi3Upu.js} +1 -1
  92. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-BYcI2RAr.js → sizeCapture-X5ZJPWSS-dm7S9Qfe.js} +1 -1
  93. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-D7AiyJdo.js → stateDiagram-OKZ733FA-C7WSzv4p.js} +1 -1
  94. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-sQDv5AP3.js +1 -0
  95. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BHGt7Kkv.js → swimlanes-SLNWSIFB-CBKCiQwd.js} +2 -2
  96. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BXwsmcPs.js +8 -0
  97. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-OPjooWcX.js → timeline-definition-Z64GVDOM-CvnUlylk.js} +1 -1
  98. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-ggsWDvrO.js → vennDiagram-T6HMQDX7-xM0StY1O.js} +1 -1
  99. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DjNe-BjZ.js → wardleyDiagram-T6FBY63Y-CWtJMagy.js} +1 -1
  100. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-Byp63Hei.js → xychartDiagram-ELKLHX3M-BO_-MeK8.js} +1 -1
  101. package/dist/worker/console/static/index.html +2 -2
  102. package/dist/worker/console/static-src/app/console-types.js +40 -1
  103. package/dist/worker/console/static-src/app/useOperatorActions.js +5 -0
  104. package/dist/worker/console/static-src/app/useTaskWizard.js +73 -1
  105. package/dist/worker/observability/init-runtime-activity.js +26 -0
  106. package/dist/worker/observe/node-input.js +88 -2
  107. package/dist/worker/observe/routes.js +188 -0
  108. package/dist/worker/observe/static/api.js +3 -3
  109. package/dist/worker/observe/static/format.js +37 -0
  110. package/dist/worker/observe/static/index.html +5 -0
  111. package/dist/worker/observe/static/prompt-restart-candidates.d.ts +18 -0
  112. package/dist/worker/observe/static/prompt-restart-candidates.js +36 -0
  113. package/dist/worker/observe/static/state.js +2 -2
  114. package/dist/worker/observe/static/styles.css +331 -35
  115. package/dist/worker/observe/static/views/dag-inspector.js +509 -78
  116. package/dist/worker/observe/static/views/task.js +55 -0
  117. package/dist/workflows/dag/frontend-design-policy.js +19 -9
  118. package/dist/workflows/dag/frontend-implementation-contract.js +109 -49
  119. package/dist/workflows/dag/frontend-plan-render.js +27 -0
  120. package/dist/workflows/dag/frontend-prewrite-gate.js +45 -1
  121. package/dist/workflows/dag/frontend-recovery-controller.js +1 -0
  122. package/dist/workflows/dag/frontend-review-context.js +102 -15
  123. package/dist/workflows/dag/frontend-shadow-dual-write.js +36 -2
  124. package/dist/workflows/dag/frontend-source-fidelity-ledger.js +108 -0
  125. package/dist/workflows/dag/frontend-worktree-diff.js +154 -0
  126. package/dist/workflows/dag/frontend-writer-admission.js +12 -0
  127. package/dist/workflows/dag/init-hybrid.js +293 -65
  128. package/dist/workflows/dag/interrupt-request.js +7 -0
  129. package/dist/workflows/dag/node-execution.js +111 -1
  130. package/dist/workflows/dag/prompt.js +34 -1
  131. package/dist/workflows/dag/recovery-lease.js +80 -0
  132. package/dist/workflows/dag/rerun-run.js +23 -0
  133. package/dist/workflows/dag/rerun-task.js +29 -0
  134. package/dist/workflows/dag/retry-policy.js +26 -3
  135. package/dist/workflows/dag/runner.js +38 -8
  136. package/dist/workflows/dag/types.js +51 -11
  137. package/docs/README.md +1 -0
  138. package/docs/architecture/evolution.md +45 -0
  139. package/docs/init-surface.manifest.json +4 -0
  140. package/docs/templates/README.md +2 -1
  141. package/docs/templates/frontend-implementation-contract.schema.json +684 -24
  142. package/docs/templates/spec-registry.schema.json +45 -0
  143. package/harness.json +3 -3
  144. package/package.json +1 -1
  145. package/skills/frontend-bounded-implement/SKILL.md +8 -14
  146. package/skills/frontend-design-review/SKILL.md +20 -41
  147. package/skills/frontend-implementation/SKILL.md +2 -3
  148. package/skills/frontend-implementation/references/design-spec.md +4 -3
  149. package/skills/frontend-implementation/references/node-contracts.md +4 -3
  150. package/skills/frontend-review/SKILL.md +22 -14
  151. package/skills/frontend-review/references/review-findings.md +6 -7
  152. package/skills/loop-agent/references/hybrid-dag.md +7 -0
  153. package/dist/worker/console/static/assets/channel-AbFtVnFm.js +0 -1
  154. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-n1yf2Jcv.js +0 -1
  155. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-n1yf2Jcv.js +0 -1
  156. package/dist/worker/console/static/assets/index-DwOGauxU.css +0 -1
  157. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BBSH5c8m.js +0 -1
  158. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D2F7-U4H.js +0 -8
@@ -410,6 +410,13 @@ export function startInterruptWatcher(input) {
410
410
  let watcher;
411
411
  try {
412
412
  watcher = watch(path.join(input.runDir, ".runtime"), () => tick());
413
+ // fs.watch can report EMFILE/ENOSPC asynchronously after watch() returns.
414
+ // Polling below is the reliable path, so watcher errors must never escape
415
+ // as an unhandled EventEmitter error and orphan the DAG runner.
416
+ watcher.on("error", () => {
417
+ watcher?.close();
418
+ watcher = undefined;
419
+ });
413
420
  watcher.unref?.();
414
421
  }
415
422
  catch {
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { pathMatchesPattern } from "../../shared/git-progress.js";
6
+ import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
6
7
  import { recordDecisionEnvelopeForNode, shouldPauseOnHumanEscalation, writeHumanEscalationArtifacts, } from "./decision-envelope.js";
7
8
  import { FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT, FRONTEND_WRITER_NODE_IDS, isFrontendWriterAuthorized, readFrontendPrewriteResult, } from "./scheduler.js";
8
9
  import { writeNodeRecord, writeNodeSkillArtifacts } from "./run-store.js";
@@ -164,6 +165,45 @@ function frontendStructuredArtifactRetryGuidance(schemaId) {
164
165
  ];
165
166
  }
166
167
  const FRONTEND_PLAN_NODE_ID = "frontend-plan-pi";
168
+ const COMPACT_RETRY_SECTION_MAX_CHARS = 1_200;
169
+ const COMPACT_RETRY_TASK_MAX_CHARS = 4_800;
170
+ function compactRetryText(text, maxChars) {
171
+ if (text.length <= maxChars)
172
+ return text;
173
+ const headChars = Math.floor(maxChars * 0.78);
174
+ const tailChars = Math.max(0, maxChars - headChars - 52);
175
+ return `${text.slice(0, headChars)}\n… [omitted after context-overflow] …\n${text.slice(-tailChars)}`;
176
+ }
177
+ function compactRetrySection(prompt, name, maxChars) {
178
+ const match = prompt.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`));
179
+ return compactRetryText(match?.[1]?.trim() ?? "(not available)", maxChars);
180
+ }
181
+ /**
182
+ * A context-overflow retry must not resend the full resolved skills, governance
183
+ * payload, repeated source excerpts, and upstream previews that caused the
184
+ * previous session to overflow. Keep the immutable node contract plus bounded
185
+ * objective/criteria/task excerpts; canonical artifacts remain readable by
186
+ * pointer from the retained task instructions.
187
+ */
188
+ export function buildContextOverflowRetryPrompt(task, basePrompt) {
189
+ return [
190
+ "<compact_retry_context>",
191
+ "This is a fresh, compact retry after provider context-overflow. The original full prompt remains audit evidence but is intentionally not resent.",
192
+ "Do not re-read task sources, upstream stdout, skills, or full diff artifacts that are already represented by canonical artifacts. Start from the smallest retained artifact and expand only the exact file or diff fragment needed.",
193
+ "</compact_retry_context>",
194
+ `<dag_objective>\n${compactRetrySection(basePrompt, "dag_objective", COMPACT_RETRY_SECTION_MAX_CHARS)}\n</dag_objective>`,
195
+ `<success_criteria>\n${compactRetrySection(basePrompt, "success_criteria", COMPACT_RETRY_SECTION_MAX_CHARS)}\n</success_criteria>`,
196
+ `<global_constraints>\n${compactRetrySection(basePrompt, "global_constraints", COMPACT_RETRY_SECTION_MAX_CHARS)}\n</global_constraints>`,
197
+ `<node_contract>\n${compactRetrySection(basePrompt, "node_contract", COMPACT_RETRY_SECTION_MAX_CHARS)}\n</node_contract>`,
198
+ `<upstream_context>\n${compactRetrySection(basePrompt, "upstream_context", COMPACT_RETRY_SECTION_MAX_CHARS)}\n</upstream_context>`,
199
+ [
200
+ "<task>",
201
+ `Original task id: ${task.id}`,
202
+ compactRetrySection(basePrompt, "task", COMPACT_RETRY_TASK_MAX_CHARS),
203
+ "</task>",
204
+ ].join("\n"),
205
+ ].join("\n\n");
206
+ }
167
207
  function isFrontendPlanLadderTask(task) {
168
208
  return task.id === FRONTEND_PLAN_NODE_ID;
169
209
  }
@@ -184,9 +224,25 @@ async function readFrontendPlanCommittedSnapshot(runDir, nodeId) {
184
224
  const hasTerminalFact = committed.some((record) => record.fact.kind === "finalize_plan");
185
225
  return { digest, hasTerminalFact };
186
226
  }
187
- function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCategory, previousProtocolReason, recoveryTargetPaths, recoveryDiagnostics, frontendPlanRetryStep) {
227
+ export function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCategory, previousProtocolReason, recoveryTargetPaths, recoveryDiagnostics, frontendPlanRetryStep) {
188
228
  if (attemptNumber <= 1)
189
229
  return basePrompt;
230
+ // Context overflow is a transport/session failure, not a plan-protocol
231
+ // failure. It must take precedence over the plan retry ladder so every
232
+ // retry, including a repeated plan overflow, starts from the compact
233
+ // envelope rather than re-sending the original prompt.
234
+ if (previousFailureCategory === "context-overflow") {
235
+ return [
236
+ buildContextOverflowRetryPrompt(task, basePrompt),
237
+ "",
238
+ "<retry_instruction>",
239
+ "The previous attempt exceeded the provider context window. This retry starts a fresh session: keep the evidence surface narrow.",
240
+ "Do not re-read task sources, upstream stdout, skills, or full diff artifacts that are already represented by the canonical contract/review context. Read the smallest relevant artifact first, then only the specific source file or diff fragment needed to decide a finding.",
241
+ "Do not read artifacts/diff_patch.patch in full. For frontend review, read the bounded diff summary and then at most the specific per-file diff fragments it identifies, in part order.",
242
+ "Use concise tool calls and finish the required terminal/output protocol as soon as the evidence is sufficient.",
243
+ "</retry_instruction>",
244
+ ].join("\n");
245
+ }
190
246
  if (frontendPlanRetryStep === "compact-terminal-first") {
191
247
  return [
192
248
  basePrompt,
@@ -239,6 +295,16 @@ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCate
239
295
  ].join("\n");
240
296
  }
241
297
  if (previousFailureCategory === "read-burst") {
298
+ if (task.id !== FRONTEND_PLAN_NODE_ID) {
299
+ return [
300
+ basePrompt,
301
+ "",
302
+ "<retry_instruction>",
303
+ "The previous attempt exceeded its read budget. Start a fresh, evidence-minimal pass: do not re-read task sources, upstream stdout, skills, or full diff artifacts already represented by a canonical context artifact.",
304
+ "Read the smallest relevant summary first, then only the specific source file or diff fragment needed for the required decision. Finish the required terminal/output protocol as soon as evidence is sufficient.",
305
+ "</retry_instruction>",
306
+ ].join("\n");
307
+ }
242
308
  return [
243
309
  basePrompt,
244
310
  "",
@@ -430,6 +496,35 @@ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCate
430
496
  "</retry_instruction>",
431
497
  ].join("\n");
432
498
  }
499
+ const PROMPT_RESTART_CANDIDATE_MAX_BYTES = 64 * 1024;
500
+ function promptRestartCandidateForAttempt(input) {
501
+ if (input.attemptPrompt === input.baseRuntimePrompt) {
502
+ return input.taskPrompt;
503
+ }
504
+ const appendedPrefix = `${input.baseRuntimePrompt}\n\n`;
505
+ if (input.attemptPrompt.startsWith(appendedPrefix)) {
506
+ return [
507
+ input.taskPrompt,
508
+ input.attemptPrompt.slice(appendedPrefix.length),
509
+ ]
510
+ .filter(Boolean)
511
+ .join("\n\n");
512
+ }
513
+ return input.attemptPrompt;
514
+ }
515
+ async function persistFailedPromptRestartCandidate(input) {
516
+ const redacted = redactSecrets(input.prompt);
517
+ const preview = truncateUtf8Preview(redacted, PROMPT_RESTART_CANDIDATE_MAX_BYTES);
518
+ await writeDagNodeJsonArtifact(input.runDir, input.nodeId, `prompt-candidate-attempt-${input.attempt}.json`, {
519
+ schemaVersion: 1,
520
+ attempt: input.attempt,
521
+ failureCategory: input.failureCategory ?? "unknown",
522
+ prompt: preview,
523
+ truncated: preview !== redacted,
524
+ maxBytes: PROMPT_RESTART_CANDIDATE_MAX_BYTES,
525
+ sha256: createHash("sha256").update(redacted).digest("hex"),
526
+ });
527
+ }
433
528
  export async function buildNodePromptWithResolvedSkillInstructions(spec, task, upstream, cwd, options) {
434
529
  const policy = resolveContextPolicy(spec);
435
530
  const skillNames = policy.resolveSkills(spec, task);
@@ -904,6 +999,7 @@ export async function executeDagNode(input) {
904
999
  // exactly this attempt's segment (B1: earlier attempts legitimately use
905
1000
  // ls/find and must not be attributed to the repair attempt).
906
1001
  const attemptSessionEventsOffset = await sessionEventsByteLength(runDir, nodeId);
1002
+ let promptRestartCandidate = task.subtask_prompt;
907
1003
  let result;
908
1004
  try {
909
1005
  validateRepairArtifactGateBeforeShell({
@@ -974,6 +1070,11 @@ export async function executeDagNode(input) {
974
1070
  previousReason: previousProtocolReason,
975
1071
  });
976
1072
  }
1073
+ promptRestartCandidate = promptRestartCandidateForAttempt({
1074
+ taskPrompt: task.subtask_prompt,
1075
+ baseRuntimePrompt: prompt,
1076
+ attemptPrompt,
1077
+ });
977
1078
  result = await executeNode({
978
1079
  task,
979
1080
  cwd,
@@ -1101,6 +1202,15 @@ export async function executeDagNode(input) {
1101
1202
  const attemptWallDurationMs = durationBetween(attemptStartedAt, attemptFinishedAt);
1102
1203
  totalAttemptWallDurationMs += attemptWallDurationMs;
1103
1204
  const rawFailureCategory = result.rawFailureCategory ?? result.failureCategory;
1205
+ if (task.executor === "pi" && !result.ok) {
1206
+ await persistFailedPromptRestartCandidate({
1207
+ runDir,
1208
+ nodeId,
1209
+ attempt: attemptNumber,
1210
+ failureCategory: result.failureCategory,
1211
+ prompt: promptRestartCandidate,
1212
+ });
1213
+ }
1104
1214
  const attemptRecord = {
1105
1215
  attempt: attemptNumber,
1106
1216
  startedAt: attemptStartedAt,
@@ -45,9 +45,42 @@ function formatResolvedSkillInstructions(instructions) {
45
45
  if (!instructions || instructions.length === 0)
46
46
  return "(none)";
47
47
  return instructions
48
- .map((instruction) => instruction.promptText)
48
+ .map((instruction) => formatSkillChecklist(instruction))
49
49
  .join("\n\n---\n\n");
50
50
  }
51
+ /** Keep skill provenance, but turn bounded inline manuals into a small action list. */
52
+ function formatSkillChecklist(instruction) {
53
+ const metadata = [
54
+ `Skill: ${instruction.name}`,
55
+ `Resolution: ${instruction.resolution}`,
56
+ ...(instruction.path ? [`Path: ${instruction.path}`] : []),
57
+ ...(instruction.sha256 ? [`Sha256: ${instruction.sha256}`] : []),
58
+ "Injection mode: checklist",
59
+ ];
60
+ if (instruction.resolution !== "resolved")
61
+ return [...metadata, instruction.promptText].join("\n");
62
+ const source = instruction.promptText.split("Instructions:\n")[1] ?? "";
63
+ const candidates = source
64
+ .split(/\r?\n/)
65
+ .map((line) => line.trim())
66
+ .filter((line) => /^(?:#{1,3}\s+|[-*]\s+|\d+[.)]\s+)/.test(line) || line.length > 0)
67
+ .map((line) => line.replace(/^#{1,3}\s+/, "").replace(/^[-*]\s+/, "").replace(/^\d+[.)]\s+/, ""))
68
+ .filter((line) => line.length > 0);
69
+ const checklist = [];
70
+ let chars = 0;
71
+ for (const item of candidates) {
72
+ const bounded = item.slice(0, 240);
73
+ if (checklist.length >= 10 || chars + bounded.length > 1_600)
74
+ break;
75
+ checklist.push(`- ${bounded}`);
76
+ chars += bounded.length;
77
+ }
78
+ return [
79
+ ...metadata,
80
+ "Checklist (apply these actions; consult the immutable resolved file only when a listed action requires detail):",
81
+ ...(checklist.length > 0 ? checklist : ["- Follow the resolved skill's declared workflow."]),
82
+ ].join("\n");
83
+ }
51
84
  export function formatOutputLanguageBlock(language = DEFAULT_DAG_OUTPUT_LANGUAGE) {
52
85
  if (language === "en") {
53
86
  return [
@@ -0,0 +1,80 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
5
+ import { DAG_RUNS_DIR } from "./lifecycle.js";
6
+ function recoveryLeasePath(cwd, parentRunId) {
7
+ // Never derive a filesystem name directly from an externally supplied legacy
8
+ // run id. The content remains auditable while the filename is traversal-safe.
9
+ const key = createHash("sha256").update(parentRunId).digest("hex");
10
+ return path.join(cwd, DAG_RUNS_DIR, ".recovery-leases", `${key}.json`);
11
+ }
12
+ function parseLease(raw, parentRunId) {
13
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
14
+ throw new Error("invalid dag recovery lease: expected object");
15
+ }
16
+ const record = raw;
17
+ if (record.schemaVersion !== 1 ||
18
+ record.parentRunId !== parentRunId ||
19
+ typeof record.requestId !== "string" ||
20
+ record.requestId.length === 0 ||
21
+ (record.kind !== "auto-frontend-recovery" &&
22
+ record.kind !== "rerun-from-node" &&
23
+ record.kind !== "standalone-task-rerun") ||
24
+ typeof record.leaseToken !== "string" ||
25
+ record.leaseToken.length === 0 ||
26
+ typeof record.acquiredAt !== "string" ||
27
+ typeof record.executionRef !== "undefined" &&
28
+ typeof record.executionRef !== "string") {
29
+ throw new Error("invalid dag recovery lease: schema mismatch");
30
+ }
31
+ return record;
32
+ }
33
+ export async function acquireDagRecoveryLease(input) {
34
+ const leasePath = recoveryLeasePath(input.cwd, input.parentRunId);
35
+ const lease = {
36
+ schemaVersion: 1,
37
+ parentRunId: input.parentRunId,
38
+ requestId: input.requestId,
39
+ kind: input.kind,
40
+ leaseToken: randomBytes(16).toString("hex"),
41
+ acquiredAt: (input.now ?? (() => new Date()))().toISOString(),
42
+ };
43
+ await mkdir(path.dirname(leasePath), { recursive: true });
44
+ try {
45
+ // `wx` is the cross-process compare-and-create primitive. The lease is
46
+ // deliberately immutable for a parent run: a later recovery must follow
47
+ // the committed child lineage rather than fork another DAG.
48
+ await writeFile(leasePath, `${JSON.stringify(lease, null, 2)}\n`, {
49
+ encoding: "utf8",
50
+ flag: "wx",
51
+ });
52
+ return { kind: "acquired", leasePath, lease };
53
+ }
54
+ catch (error) {
55
+ if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) {
56
+ throw error;
57
+ }
58
+ const existing = parseLease(JSON.parse(await readFile(leasePath, "utf8")), input.parentRunId);
59
+ return { kind: "held", leasePath, lease: existing };
60
+ }
61
+ }
62
+ /** Persist the operation/run identity before the caller is allowed to execute it. */
63
+ export async function bindDagRecoveryLeaseExecution(input) {
64
+ if (!input.executionRef.trim()) {
65
+ throw new Error("dag recovery lease executionRef is required");
66
+ }
67
+ const existing = parseLease(JSON.parse(await readFile(input.leasePath, "utf8")), input.lease.parentRunId);
68
+ if (existing.leaseToken !== input.lease.leaseToken) {
69
+ throw new Error("dag recovery lease ownership drift");
70
+ }
71
+ if (existing.executionRef && existing.executionRef !== input.executionRef) {
72
+ throw new Error("dag recovery lease execution identity drift");
73
+ }
74
+ const bound = {
75
+ ...existing,
76
+ executionRef: input.executionRef,
77
+ };
78
+ await writeJsonAtomic(input.leasePath, bound);
79
+ return bound;
80
+ }
@@ -12,6 +12,7 @@ import { prepareActiveRunDir, writeNodeRecord, writeRunSpec, writeRunState, } fr
12
12
  import { cloneParentSkillSnapshotForContinuation, readSkillSnapshot, } from "./skill-snapshot.js";
13
13
  import { topoSortToRanks } from "./topo.js";
14
14
  import { captureWorkspaceCheckpoint, readWorkspaceCheckpoint, WORKSPACE_CHECKPOINT_START_REL, WORKSPACE_CHECKPOINT_TERMINAL_REL, writeWorkspaceCheckpoint, } from "./workspace-checkpoint.js";
15
+ import { acquireDagRecoveryLease, bindDagRecoveryLeaseExecution, } from "./recovery-lease.js";
15
16
  export const RERUN_PLAN_REL_PATH = ".runtime/rerun-plan.json";
16
17
  export const IMPORT_MANIFEST_REL_PATH = ".runtime/import-manifest.json";
17
18
  export const WORKER_ASSOCIATION_REL_PATH = ".runtime/worker-association.json";
@@ -572,6 +573,20 @@ export async function executeDagRerun(input) {
572
573
  }
573
574
  const parentSpec = await readDagRunSpec(located.runDir);
574
575
  const parentState = await readDagRunState(located.runDir);
576
+ const recoveryLease = await acquireDagRecoveryLease({
577
+ cwd: input.cwd,
578
+ parentRunId: input.parentRunId,
579
+ requestId: input.requestId,
580
+ kind: "rerun-from-node",
581
+ });
582
+ if (recoveryLease.kind === "held") {
583
+ return {
584
+ ok: false,
585
+ plan,
586
+ errorCode: "RECOVERY_LEASE_HELD",
587
+ message: `parent run recovery is already claimed by ${recoveryLease.lease.kind} request ${recoveryLease.lease.requestId}`,
588
+ };
589
+ }
575
590
  const staged = await stageContinuationRun({
576
591
  cwd: input.cwd,
577
592
  parentRunDir: located.runDir,
@@ -583,6 +598,14 @@ export async function executeDagRerun(input) {
583
598
  requestId: input.requestId,
584
599
  promptOverrides: input.promptOverrides,
585
600
  });
601
+ // Stage is non-executing; bind the child identity immediately before handing
602
+ // it to the runner so no recovery DAG can start without durable parent
603
+ // lineage ownership.
604
+ await bindDagRecoveryLeaseExecution({
605
+ leasePath: recoveryLease.leasePath,
606
+ lease: recoveryLease.lease,
607
+ executionRef: staged.state.runId,
608
+ });
586
609
  const continuationOptions = {
587
610
  cwd: input.cwd,
588
611
  spec: staged.spec,
@@ -4,6 +4,7 @@ import { generateTaskDagUseCase } from "../../application/dag/generate-task-dag.
4
4
  import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
5
5
  import { DAG_RUNS_DIR, isTerminalDagRunStatus, locateDagRun, readDagRunSpec, readDagRunState, } from "./lifecycle.js";
6
6
  import { deriveDagRerunFeedback } from "./rerun-feedback.js";
7
+ import { acquireDagRecoveryLease, bindDagRecoveryLeaseExecution, } from "./recovery-lease.js";
7
8
  import { parseDagSpec, } from "./types.js";
8
9
  export const RERUN_TASK_LINEAGE_REL_PATH = ".runtime/rerun-task-lineage.json";
9
10
  export function parseDagRerunTaskArgs(args) {
@@ -369,6 +370,34 @@ export async function rerunStandaloneTask(input) {
369
370
  }
370
371
  }
371
372
  }
373
+ const recoveryLease = execute
374
+ ? await acquireDagRecoveryLease({
375
+ cwd: input.cwd,
376
+ parentRunId: input.parentRunId,
377
+ requestId: input.requestId,
378
+ kind: "standalone-task-rerun",
379
+ })
380
+ : undefined;
381
+ if (recoveryLease?.kind === "held") {
382
+ return blockedResult({
383
+ parentRunId: input.parentRunId,
384
+ reason: input.reason,
385
+ requestId: input.requestId,
386
+ blockedReason: "recovery-lease-held",
387
+ note: `parent run recovery is already claimed by ${recoveryLease.lease.kind} request ${recoveryLease.lease.requestId}`,
388
+ taskId: taskResolution.taskId,
389
+ });
390
+ }
391
+ if (recoveryLease?.kind === "acquired") {
392
+ // Persist a non-empty execution identity before generateTaskDagUseCase can
393
+ // execute its new DAG. The generated run id is not available yet, so the
394
+ // request id is the stable lineage handle for this entry point.
395
+ await bindDagRecoveryLeaseExecution({
396
+ leasePath: recoveryLease.leasePath,
397
+ lease: recoveryLease.lease,
398
+ executionRef: `standalone-task-rerun:${input.requestId}`,
399
+ });
400
+ }
372
401
  const generateResult = await generateTaskDagUseCase({
373
402
  repoRoot: input.cwd,
374
403
  taskId: taskResolution.taskId,
@@ -132,13 +132,24 @@ export const dagRetryPolicySchema = z
132
132
  /**
133
133
  * The default retry policy applied to safe generated read-only Pi nodes.
134
134
  * Total attempts: 3, exponential backoff with cap.
135
+ *
136
+ * Includes `context-overflow`: a read-only node that blew the context window
137
+ * (400 request too large, e.g. a review node accumulating too many reads) is a
138
+ * transient, safe-to-retry failure — the retry re-runs the node with a
139
+ * compact-context instruction instead of failing the whole run. Previously
140
+ * only writer transport policies had context-overflow; contract/scout/
141
+ * design-review/review fell straight to ERROR without a retry attempt.
135
142
  */
136
143
  export const DEFAULT_READ_ONLY_PI_RETRY_POLICY = {
137
144
  maxAttempts: 3,
138
145
  backoff: "exponential",
139
146
  initialDelayMs: 2000,
140
147
  maxDelayMs: 30000,
141
- retryCategories: [...DEFAULT_DAG_RETRY_CATEGORIES],
148
+ retryCategories: [
149
+ ...DEFAULT_DAG_RETRY_CATEGORIES,
150
+ READ_BURST_RETRY_CATEGORY,
151
+ CONTEXT_OVERFLOW_RETRY_CATEGORY,
152
+ ],
142
153
  };
143
154
  /**
144
155
  * Structured planner nodes may retry with a compact-output instruction
@@ -148,7 +159,11 @@ export const DEFAULT_READ_ONLY_PI_RETRY_POLICY = {
148
159
  */
149
160
  export const STRUCTURED_REQUIRED_PI_RETRY_POLICY = {
150
161
  ...DEFAULT_READ_ONLY_PI_RETRY_POLICY,
151
- retryCategories: [...STRUCTURED_REQUIRED_DAG_RETRY_CATEGORIES],
162
+ retryCategories: [
163
+ ...STRUCTURED_REQUIRED_DAG_RETRY_CATEGORIES,
164
+ READ_BURST_RETRY_CATEGORY,
165
+ CONTEXT_OVERFLOW_RETRY_CATEGORY,
166
+ ],
152
167
  };
153
168
  /**
154
169
  * Planner read-only nodes (standard DAG contract-pi / plan-pi) may retry with
@@ -159,7 +174,12 @@ export const STRUCTURED_REQUIRED_PI_RETRY_POLICY = {
159
174
  */
160
175
  export const PLANNER_OUTPUT_LIMIT_RETRY_POLICY = {
161
176
  ...DEFAULT_READ_ONLY_PI_RETRY_POLICY,
162
- retryCategories: [...DEFAULT_DAG_RETRY_CATEGORIES, STRUCTURED_OUTPUT_RETRY_CATEGORY],
177
+ retryCategories: [
178
+ ...DEFAULT_DAG_RETRY_CATEGORIES,
179
+ STRUCTURED_OUTPUT_RETRY_CATEGORY,
180
+ READ_BURST_RETRY_CATEGORY,
181
+ CONTEXT_OVERFLOW_RETRY_CATEGORY,
182
+ ],
163
183
  };
164
184
  /**
165
185
  * Default retry for reviewer / recovery nodes that declare outputProtocol.
@@ -180,6 +200,8 @@ export const FRONTEND_REVIEW_TERMINAL_RETRY_POLICY = {
180
200
  ...DEFAULT_READ_ONLY_PI_RETRY_POLICY,
181
201
  retryCategories: [
182
202
  ...DEFAULT_DAG_RETRY_CATEGORIES,
203
+ READ_BURST_RETRY_CATEGORY,
204
+ CONTEXT_OVERFLOW_RETRY_CATEGORY,
183
205
  REVIEW_TERMINAL_MISSING_RETRY_CATEGORY,
184
206
  ],
185
207
  };
@@ -553,5 +575,6 @@ export const FRONTEND_PLAN_LADDER_RETRY_POLICY = {
553
575
  ...DEFAULT_DAG_RETRY_CATEGORIES,
554
576
  STRUCTURED_ARTIFACT_INVALID_RETRY_CATEGORY,
555
577
  READ_BURST_RETRY_CATEGORY,
578
+ CONTEXT_OVERFLOW_RETRY_CATEGORY,
556
579
  ],
557
580
  };
@@ -31,6 +31,7 @@ import { bindFrontendShapeStateToRunTopology, commitFrontendShapeTransitionCas,
31
31
  import { assertFrontendTopologyBinding, buildFrontendTopologyBinding, computeFrontendShapeSourceDigest, } from "./frontend-shape.js";
32
32
  import { ensureFrontendShapeCapsuleRecord } from "./frontend-shape-capsule-store.js";
33
33
  import { parseDagSpec, resolveModelForTask, } from "./types.js";
34
+ import { acquireDagRecoveryLease, bindDagRecoveryLeaseExecution, } from "./recovery-lease.js";
34
35
  import { executeDynamicCondition } from "./dynamic-runtime/condition.js";
35
36
  import { executeDynamicLoopUntil } from "./dynamic-runtime/loop-until.js";
36
37
  import { executeDynamicMapExpansion } from "./dynamic-runtime/map.js";
@@ -822,6 +823,15 @@ async function executeDagCheckpoint(input) {
822
823
  "frontend recovery request-id reuse conflict";
823
824
  state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, "auto-recovery-blocked");
824
825
  }
826
+ else if (created.kind === "lease-held") {
827
+ // A different recovery entrance has already durably claimed this
828
+ // parent. Do not "help" by running its operation: that would reopen
829
+ // the exact two-child-DAG race the lease prevents.
830
+ state.status = "failed";
831
+ state.terminalReason =
832
+ "frontend recovery parent lease already held";
833
+ state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, "auto-recovery-blocked");
834
+ }
825
835
  else {
826
836
  const { runRecoveryOperationController } = await import("./frontend-recovery-controller.js");
827
837
  const result = await runRecoveryOperationController({
@@ -1263,16 +1273,25 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd, f
1263
1273
  /**
1264
1274
  * Construct (or reuse) the recovery operation for a failed frontend run.
1265
1275
  *
1266
- * The operation is keyed by a deterministic `clientRequestId`
1267
- * (`dag-recovery-<parentRunId>`), so repeated wakeups of the same parent hit
1268
- * `OperationStore.create`'s idempotent short-circuit instead of creating a
1269
- * second recovery run (AC-004). The continuation DagSpec/reset partition is
1270
- * frozen via `computeFrontendRecoveryPlanForSource` and serialized under the
1271
- * run dir's bounded `.runtime/` namespace never the repo root or `.harness`
1272
- * siblings. `planHash` is the second idempotency dimension of `actionParams`.
1276
+ * A parent-run atomic lease is acquired before `OperationStore.create`, then
1277
+ * its operation id is persisted into that lease before the controller may run.
1278
+ * `clientRequestId` remains a useful store-level replay key, but is not a
1279
+ * cross-process recovery admission boundary. The continuation DagSpec/reset
1280
+ * partition is frozen via `computeFrontendRecoveryPlanForSource` and
1281
+ * serialized under the run dir's bounded `.runtime/` namespace. `planHash` is
1282
+ * the second idempotency dimension of `actionParams`.
1273
1283
  */
1274
1284
  async function createRecoveryOperation(input) {
1275
1285
  const { cwd, runDir, spec, state, trigger } = input;
1286
+ const recoveryLease = await acquireDagRecoveryLease({
1287
+ cwd,
1288
+ parentRunId: state.runId,
1289
+ requestId: trigger.requestId,
1290
+ kind: "auto-frontend-recovery",
1291
+ });
1292
+ if (recoveryLease.kind === "held") {
1293
+ return { kind: "lease-held", lease: recoveryLease.lease };
1294
+ }
1276
1295
  const { computeFrontendRecoveryPlanForSource } = await import("./frontend-recovery-plan.js");
1277
1296
  const plan = computeFrontendRecoveryPlanForSource({
1278
1297
  spec,
@@ -1292,7 +1311,7 @@ async function createRecoveryOperation(input) {
1292
1311
  recoveryMode: trigger.recoveryMode,
1293
1312
  plan,
1294
1313
  });
1295
- return input.deps.store.create({
1314
+ const created = await input.deps.store.create({
1296
1315
  clientRequestId: `dag-recovery-${state.runId}`,
1297
1316
  action: "dagRunTask",
1298
1317
  actionParams: {
@@ -1314,6 +1333,17 @@ async function createRecoveryOperation(input) {
1314
1333
  "--json",
1315
1334
  ],
1316
1335
  });
1336
+ if (created.kind !== "conflict") {
1337
+ // The durable lease must name the operation before controller execution.
1338
+ // A crash in this narrow window leaves a safely blocked reservation rather
1339
+ // than a second process that can launch another recovery DAG.
1340
+ await bindDagRecoveryLeaseExecution({
1341
+ leasePath: recoveryLease.leasePath,
1342
+ lease: recoveryLease.lease,
1343
+ executionRef: created.operation.operationId,
1344
+ });
1345
+ }
1346
+ return created;
1317
1347
  }
1318
1348
  function concurrentSiblingWriteSetsForNode(rankWriterNodeIds, nodeId, tasksById) {
1319
1349
  if (rankWriterNodeIds.length <= 1)
@@ -946,6 +946,15 @@ export const dagTaskSchema = z.object({ id: z.string().regex(/^[a-z][a-z0-9-]*$/
946
946
  role: dagRoleSchema.optional(),
947
947
  skills: z.array(z.string()).optional(),
948
948
  toolProfile: dagToolProfileSchema.optional(),
949
+ /** Bounded read-only discovery budget for nodes that inspect a repository. */
950
+ readBudget: z
951
+ .object({
952
+ maxFiles: z.number().int().positive(),
953
+ maxBytes: z.number().int().positive(),
954
+ maxMs: z.number().int().positive().optional(),
955
+ })
956
+ .strict()
957
+ .optional(),
949
958
  /**
950
959
  * Explicit Pi extension allowlist (backend implementation DAGs only):
951
960
  * frozen short ids resolved at run time against the Pi settings package
@@ -1071,20 +1080,51 @@ export const dagEvaluationBindingSchema = z
1071
1080
  .strict();
1072
1081
  /** Eval Lab Campaign Budget (W3.4); enforced by runner ledger when mode=hard. */
1073
1082
  export const dagBudgetSchema = campaignBudgetSchema;
1074
- export const dagSourceBindingSchema = z.object({
1083
+ export const dagSourceBindingSourceSchema = z.object({
1084
+ kind: z.enum(["requirement", "constraint", "reference"]),
1085
+ path: z.string().min(1),
1086
+ sha256: z
1087
+ .string()
1088
+ .regex(/^[a-f0-9]{64}$/, "sha256 must be lowercase hex"),
1089
+ });
1090
+ /**
1091
+ * DagSourceBinding v1:无 ledger 字段(schemaVersion=1,.strict() 拒绝 ledger
1092
+ * 字段混入,不得以 v1+optional 冒充 v2)。非 frontend task / 无可解析引用时使用。
1093
+ */
1094
+ export const dagSourceBindingV1Schema = z
1095
+ .object({
1075
1096
  schemaVersion: z.literal(1),
1076
1097
  taskId: z.string().min(1),
1077
- sources: z
1078
- .array(z.object({
1079
- kind: z.enum(["requirement", "constraint", "reference"]),
1080
- path: z.string().min(1),
1081
- sha256: z
1082
- .string()
1083
- .regex(/^[a-f0-9]{64}$/, "sha256 must be lowercase hex"),
1084
- }))
1085
- .min(1),
1098
+ sources: z.array(dagSourceBindingSourceSchema).min(1),
1086
1099
  requirementIds: z.array(z.string().regex(/^(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/)),
1087
- });
1100
+ })
1101
+ .strict();
1102
+ /**
1103
+ * DagSourceBinding v2(20260824-frontend-source-fidelity-ledger-v1):仅
1104
+ * frontend-implementation 且存在可解析 requirement/acceptance 引用时产出;
1105
+ * ledgerPath/ledgerSha256/inputDigest/requirementToFragments 全部必填且完整,
1106
+ * 伪造或缺失任一字段被 schema/prewrite 拒绝(AC-002 / AC-FIDELITY-REPAIR-002)。
1107
+ */
1108
+ export const dagSourceBindingV2Schema = z
1109
+ .object({
1110
+ schemaVersion: z.literal(2),
1111
+ taskId: z.string().min(1),
1112
+ sources: z.array(dagSourceBindingSourceSchema).min(1),
1113
+ requirementIds: z.array(z.string().regex(/^(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/)),
1114
+ /** 受管 requirement-ledger.json 的 DAG 可解析路径(.harness/tasks/…/source/…)。 */
1115
+ ledgerPath: z.string().min(1),
1116
+ ledgerSha256: z.string().regex(/^[a-f0-9]{64}$/),
1117
+ inputDigest: z.string().regex(/^[a-f0-9]{64}$/),
1118
+ requirementToFragments: z.record(z.string(), z.array(z.string())),
1119
+ })
1120
+ .strict();
1121
+ export const dagSourceBindingSchema = z.discriminatedUnion("schemaVersion", [
1122
+ dagSourceBindingV1Schema,
1123
+ dagSourceBindingV2Schema,
1124
+ ]);
1125
+ export function isDagSourceBindingV2(binding) {
1126
+ return binding.schemaVersion === 2;
1127
+ }
1088
1128
  const dagRerunFeedbackEvidencePathSchema = z
1089
1129
  .string()
1090
1130
  .min(1)
package/docs/README.md CHANGED
@@ -27,6 +27,7 @@
27
27
  | 选择快速、标准或完整验证命令 | `governance/verification-matrix.md` |
28
28
  | 理解 CLI、runtime 和初始化能力边界 | `runtime/loop-agent-harness.md` |
29
29
  | 运行 DAG、查看报告或处理失败 | `runtime/agent-dag-runner.md`、`runtime/agent-dag-recovery-playbook.md`;Operator Chat P0 closeout 见 `reports/feature/2026-08-14-operator-chat-dag-recovery-p0.md` |
30
+ | 理解前端实现 DAG 与需求保真账本 | `runtime/frontend-implementation-workflow.md`(含 source fidelity ledger v2 绑定、顺序 reconciliation、独立 reviewer、human resolution 与 prewrite fail-closed);init-evolution 审查见 `reports/init-evolution/2026-08-24-frontend-source-fidelity-ledger-init-evolution-review.md` |
30
31
  | 按 TDD、验证和调试纪律推进修复 | `governance/harness-methodology-tdd.md`、`governance/harness-methodology-verification.md`、`governance/harness-methodology-debugging.md` |
31
32
  | 执行真实 backend-test provider 验证 | `runtime/backend-test-live-campaign.md` |
32
33
  | 判断单任务 loop-agent Production Readiness | `operations/production-readiness.md` |