@tea-agent/loop-agent 0.40.0-next.1 → 0.40.0-next.10

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 (208) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +106 -109
  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/executors/dag-pi-executor.js +69 -7
  9. package/dist/executors/pi-executor.js +4 -2
  10. package/dist/executors/pi-sdk-executor.js +46 -1
  11. package/dist/executors/pi-writer-tool-policy.js +57 -1
  12. package/dist/executors/shell-executor.js +43 -3
  13. package/dist/executors/workspace-write-snapshot.js +68 -0
  14. package/dist/infrastructure/harness/atomic-write.js +23 -0
  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 +10 -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/source-prepare/completeness.js +188 -0
  27. package/dist/task/source-prepare/fragment-inventory.js +474 -0
  28. package/dist/task/source-prepare/index.js +3 -0
  29. package/dist/task/source-prepare/ledger-reconciliation.js +127 -0
  30. package/dist/task/source-prepare/ledger-review.js +214 -0
  31. package/dist/task/source-prepare/ledger.js +545 -0
  32. package/dist/task/source-prepare/prepare.js +262 -1
  33. package/dist/task/source-prepare/semantic-intake.js +19 -3
  34. package/dist/task/source-prepare/source-fidelity-pi.js +384 -0
  35. package/dist/task/source-prepare/types.js +22 -0
  36. package/dist/worker/cli.js +12 -26
  37. package/dist/worker/console/chat/chat-event-store.js +75 -5
  38. package/dist/worker/console/chat/context-panel.js +7 -0
  39. package/dist/worker/console/chat/deferred-turn.js +12 -0
  40. package/dist/worker/console/chat/pi-runtime.js +331 -18
  41. package/dist/worker/console/chat/provider-error.js +98 -0
  42. package/dist/worker/console/chat/routes.js +389 -56
  43. package/dist/worker/console/chat/session-stats.js +179 -0
  44. package/dist/worker/console/chat/session-store.js +93 -63
  45. package/dist/worker/console/chat/todos.js +115 -0
  46. package/dist/worker/console/chat/tool-preview.js +42 -0
  47. package/dist/worker/console/chat/turn-process.js +20 -55
  48. package/dist/worker/console/chat/user-questions.js +266 -0
  49. package/dist/worker/console/chat/workspace-landing.js +79 -0
  50. package/dist/worker/console/console-handoff.js +82 -0
  51. package/dist/worker/console/console-update-and-init.js +115 -0
  52. package/dist/worker/console/console-update-runtime.js +84 -0
  53. package/dist/worker/console/draft-store.js +49 -0
  54. package/dist/worker/console/native-directory-picker.js +13 -0
  55. package/dist/worker/console/operation-store.js +1 -1
  56. package/dist/worker/console/operator-actions.js +129 -4
  57. package/dist/worker/console/operator-surface-health.js +1 -0
  58. package/dist/worker/console/prd-intake-bridge.js +13 -0
  59. package/dist/worker/console/routes.js +312 -1
  60. package/dist/worker/console/security.js +4 -4
  61. package/dist/worker/console/server.js +583 -172
  62. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Br92EGb7.js → abnfDiagram-N423BO3Z-Bs-CDAXM.js} +1 -1
  63. package/dist/worker/console/static/assets/{arc-oulImtQq.js → arc-CAkA3We3.js} +1 -1
  64. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-tyHc6aqO.js → architectureDiagram-T3A2C74G-DEmI_zqr.js} +1 -1
  65. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CY86BSpT.js → blockDiagram-VBNYF7ZC-uDHANebE.js} +1 -1
  66. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BKKsDmNs.js → c4Diagram-5PPSVZJV-Bv97b4_B.js} +1 -1
  67. package/dist/worker/console/static/assets/channel-amxUpk7o.js +1 -0
  68. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-CXZdjl3B.js → chunk-2GRJ4B5K-shmQKsFz.js} +1 -1
  69. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B-aIFDQ5.js → chunk-2Q5K7J3B-BAraNvcN.js} +1 -1
  70. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BDgAW_B2.js → chunk-5RXB4S5H-BirhxUop.js} +1 -1
  71. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-DABa4UPR.js → chunk-5VM5RSS4-C9LgUJpR.js} +1 -1
  72. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-aGbysVOx.js → chunk-6Q2QTUOP-DrU-s-t7.js} +1 -1
  73. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B8MSZDVs.js → chunk-GF5L2VYU-Tx-H_FA2.js} +1 -1
  74. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CpelV73g.js → chunk-JWPE2WC7-BjLtNFrR.js} +1 -1
  75. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BE04ty3z.js → chunk-KBJHAD2P-UNLVwSsy.js} +1 -1
  76. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BUxTkaaR.js → chunk-RYQCIY6F-CBEUX8XD.js} +1 -1
  77. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CYYBVKfX.js → chunk-XXDRQBXY-WTL7IZOq.js} +1 -1
  78. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-bejwBVIz.js +1 -0
  79. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-bejwBVIz.js +1 -0
  80. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CEQF_xmQ.js → cose-bilkent-JH36ORCC-CkHG6W3s.js} +1 -1
  81. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-CqpxN5IT.js → cynefin-VYW2F7L2-BRzFqTaL.js} +1 -1
  82. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-B3PzanVi.js → cynefinDiagram-MW4NZA55-2_CFp_i7.js} +1 -1
  83. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BSUGCNx1.js → dagre-VZM6K2ZE-BJtsmdkE.js} +1 -1
  84. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CdLevU5T.js → diagram-7IWD3JNH-DNEFFo6w.js} +1 -1
  85. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BjpF_Kok.js → diagram-B4RE2ZJO-B_IWV7eo.js} +1 -1
  86. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-C4I6NJny.js → diagram-LBJQPF4R-DvvRx2dz.js} +1 -1
  87. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-C8hecCr5.js → diagram-Q27KOJAE-CsibsWLg.js} +1 -1
  88. package/dist/worker/console/static/assets/{diagram-UB23O5K3-Daod6tOS.js → diagram-UB23O5K3-les_djBQ.js} +1 -1
  89. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BvvmazGv.js → ebnfDiagram-BXEA7PRR-BEm2n7qJ.js} +1 -1
  90. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DeN_vfV4.js → erDiagram-JOGREHBK-BI7vHHVm.js} +1 -1
  91. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-D6dq1t9z.js → flowDiagram-UKHOOZJN-CUFCERrB.js} +1 -1
  92. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DziIdNY4.js → ganttDiagram-PKOTCBZU-wxyLiGWt.js} +1 -1
  93. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Dy4s5CXr.js → gitGraphDiagram-DS77QQ5N-Bt897K8Z.js} +1 -1
  94. package/dist/worker/console/static/assets/index-BHhUOTri.css +1 -0
  95. package/dist/worker/console/static/assets/index-DqJbO3-p.js +407 -0
  96. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-Czv5eYx2.js → infoDiagram-6WML65LV-DYNx_IPv.js} +1 -1
  97. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-Bsk3nFRT.js → ishikawaDiagram-WSZJBQD7-CzpLNEu6.js} +1 -1
  98. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DxGmNj5K.js → journeyDiagram-NVQOT4AX-97f9owNU.js} +1 -1
  99. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CYCFqkO_.js → kanban-definition-27J2QSJJ-BxM9hyBn.js} +1 -1
  100. package/dist/worker/console/static/assets/{linear-B0YzRwrd.js → linear-ByuxcHvp.js} +1 -1
  101. package/dist/worker/console/static/assets/{mermaid.core-t3EZtIGh.js → mermaid.core-DwPGvpCv.js} +5 -5
  102. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-BTXpTv0T.js → mindmap-definition-FAOFIHXS-DCoqG9RT.js} +1 -1
  103. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DU8UkKqA.js → pegDiagram-VL7TDLO6-BLSBmBMx.js} +1 -1
  104. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DP4y8ohp.js → pieDiagram-7S7Q4E2Y-B0t_0oJW.js} +1 -1
  105. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-B07vNaYR.js → quadrantDiagram-CIZ2JOQS-sTzDk7Nt.js} +1 -1
  106. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-xDWktb2i.js → railroadDiagram-AXF67PYL-B3jRdATC.js} +1 -1
  107. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BLNMKqeS.js → requirementDiagram-LRYGKXZP-D-cTtXEN.js} +1 -1
  108. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-n0adalxH.js → sankeyDiagram-W5VNT64P-B25igJRE.js} +1 -1
  109. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BbnS-2DL.js → sequenceDiagram-SI44F4Z6-jW_lM1_D.js} +1 -1
  110. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B4uPpCLu.js → sizeCapture-X5ZJPWSS-CrzTgsHE.js} +1 -1
  111. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA--MMbB0fX.js → stateDiagram-OKZ733FA-B01MvlqE.js} +1 -1
  112. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BvFKEtm2.js +1 -0
  113. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BXUgp2QY.js → swimlanes-SLNWSIFB-YcF12FiU.js} +2 -2
  114. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DIB2eVg-.js +8 -0
  115. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CsaMxki7.js → timeline-definition-Z64GVDOM-ClnRcg2s.js} +1 -1
  116. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-C2IuG9iH.js → vennDiagram-T6HMQDX7-B6gr_z-q.js} +1 -1
  117. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CmEWOXCW.js → wardleyDiagram-T6FBY63Y-CvSgguGH.js} +1 -1
  118. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-D_9LE0MG.js → xychartDiagram-ELKLHX3M-23OtSZCb.js} +1 -1
  119. package/dist/worker/console/static/index.html +2 -2
  120. package/dist/worker/console/static-src/app/console-types.js +68 -4
  121. package/dist/worker/console/static-src/app/useConsoleShell.js +30 -12
  122. package/dist/worker/console/static-src/app/useOperatorActions.js +41 -6
  123. package/dist/worker/console/static-src/app/useRecoveryConsole.js +10 -11
  124. package/dist/worker/console/static-src/app/useRunProgress.js +2 -1
  125. package/dist/worker/console/static-src/app/useTaskWizard.js +74 -2
  126. package/dist/worker/console/static-src/night/useNightBoard.js +8 -5
  127. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +141 -8
  128. package/dist/worker/console/static-src/operator-chat/compaction-message.js +79 -0
  129. package/dist/worker/console/static-src/operator-chat/details-dag-actions.js +122 -0
  130. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  131. package/dist/worker/console/static-src/operator-chat/runtime-selection-labels.js +27 -0
  132. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +1 -2
  133. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +25 -0
  134. package/dist/worker/console/static-src/operator-chat/turn-submission.js +3 -2
  135. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +181 -87
  136. package/dist/worker/console/static-src/operator-chat/useChatStream.js +159 -44
  137. package/dist/worker/console/static-src/operator-chat/useChatThread.js +177 -8
  138. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +63 -9
  139. package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
  140. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -3
  141. package/dist/worker/console/static-src/shell/useWorkspaces.js +299 -0
  142. package/dist/worker/console/static-src/shell/workspace-route.js +339 -0
  143. package/dist/worker/console/workspace-context.js +234 -0
  144. package/dist/worker/console/workspace-registry.js +214 -0
  145. package/dist/worker/observability/init-runtime-activity.js +26 -0
  146. package/dist/worker/observe/node-input.js +88 -2
  147. package/dist/worker/observe/routes.js +188 -0
  148. package/dist/worker/observe/server.js +4 -0
  149. package/dist/worker/observe/static/api.js +29 -9
  150. package/dist/worker/observe/static/app.js +18 -70
  151. package/dist/worker/observe/static/dag-node-purpose.d.ts +6 -0
  152. package/dist/worker/observe/static/dag-node-purpose.js +203 -0
  153. package/dist/worker/observe/static/format.js +37 -0
  154. package/dist/worker/observe/static/index.html +6 -1
  155. package/dist/worker/observe/static/inspect-workspace.js +307 -0
  156. package/dist/worker/observe/static/operator-chrome.css +145 -20
  157. package/dist/worker/observe/static/operator-chrome.d.ts +20 -3
  158. package/dist/worker/observe/static/operator-chrome.js +330 -100
  159. package/dist/worker/observe/static/prompt-restart-candidates.d.ts +18 -0
  160. package/dist/worker/observe/static/prompt-restart-candidates.js +36 -0
  161. package/dist/worker/observe/static/router.d.ts +46 -0
  162. package/dist/worker/observe/static/router.js +25 -1
  163. package/dist/worker/observe/static/state.d.ts +50 -0
  164. package/dist/worker/observe/static/state.js +174 -2
  165. package/dist/worker/observe/static/styles.css +357 -35
  166. package/dist/worker/observe/static/views/dag-graph.js +4 -2
  167. package/dist/worker/observe/static/views/dag-inspector.js +533 -79
  168. package/dist/worker/observe/static/views/dag.d.ts +13 -0
  169. package/dist/worker/observe/static/views/dag.js +6 -4
  170. package/dist/worker/observe/static/views/dashboard.js +6 -4
  171. package/dist/worker/observe/static/views/night.js +2 -2
  172. package/dist/worker/observe/static/views/pool.js +1 -0
  173. package/dist/worker/observe/static/views/run.js +3 -1
  174. package/dist/worker/observe/static/views/task.js +56 -1
  175. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +29 -1
  176. package/dist/workflows/dag/backend-test-markdown-workflow.js +176 -1
  177. package/dist/workflows/dag/backend-test-pytest-collection.js +98 -14
  178. package/dist/workflows/dag/backend-test-writer-completeness.js +17 -17
  179. package/dist/workflows/dag/dynamic-runtime/map.js +1 -0
  180. package/dist/workflows/dag/frontend-implementation-contract.js +88 -2
  181. package/dist/workflows/dag/frontend-plan-render.js +24 -0
  182. package/dist/workflows/dag/frontend-prewrite-gate.js +197 -0
  183. package/dist/workflows/dag/frontend-review-context.js +92 -10
  184. package/dist/workflows/dag/init-hybrid.js +238 -51
  185. package/dist/workflows/dag/interrupt-request.js +3 -3
  186. package/dist/workflows/dag/lifecycle.js +3 -3
  187. package/dist/workflows/dag/node-execution.js +45 -0
  188. package/dist/workflows/dag/types.js +62 -12
  189. package/docs/README.md +2 -0
  190. package/docs/architecture/evolution.md +45 -0
  191. package/docs/init-surface.manifest.json +1 -0
  192. package/docs/operations/local-development-environment.md +21 -0
  193. package/docs/templates/README.md +1 -1
  194. package/docs/templates/agent-dag.schema.json +18 -0
  195. package/docs/templates/backend-test-dag.json +265 -237
  196. package/docs/templates/frontend-implementation-contract.schema.json +681 -24
  197. package/harness.json +3 -3
  198. package/package.json +3 -1
  199. package/skills/loop-agent/references/hybrid-dag.md +7 -0
  200. package/dist/worker/console/static/assets/channel-TY36zt-i.js +0 -1
  201. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Dk7QCChM.js +0 -1
  202. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Dk7QCChM.js +0 -1
  203. package/dist/worker/console/static/assets/index-C3GgHg-g.css +0 -1
  204. package/dist/worker/console/static/assets/index-Ci8ksvBm.js +0 -406
  205. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BqOigMQ4.js +0 -1
  206. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Caxz9UA2.js +0 -8
  207. package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +0 -73
  208. package/dist/worker/console/static-src/operator-chat/useActivityRailTransition.js +0 -59
@@ -485,6 +485,9 @@ function extractSdkUsageSample(event) {
485
485
  const responseKey = responseKeyCandidates.find((value) => typeof value === "string" && value.length > 0);
486
486
  return responseKey ? { responseKey, tokens } : { tokens };
487
487
  }
488
+ function maxSdkContextTokens(samples) {
489
+ return samples.reduce((maximum, sample) => Math.max(maximum, sample.tokens), 0);
490
+ }
488
491
  function aggregateSdkTokenUsage(samples) {
489
492
  const identified = new Map();
490
493
  let anonymousMaximum = 0;
@@ -537,6 +540,9 @@ export async function executeSingleSdkAttempt(options) {
537
540
  const stdoutPreview = new BoundedTextPreview("stdout");
538
541
  const stdoutCollector = createPiJsonlStreamCollector();
539
542
  const usageSamples = [];
543
+ let observedTurns = 0;
544
+ let contextBudgetExhausted = false;
545
+ let contextBudgetDetail = "";
540
546
  // Writer-thinking-exhausted diagnosis evidence (AC-002). Collected in the
541
547
  // subscribe callback regardless of shouldPersistSessionEvent filtering, so a
542
548
  // length-stopped thinking-only attempt with no persisted write tool calls is
@@ -625,6 +631,7 @@ export async function executeSingleSdkAttempt(options) {
625
631
  });
626
632
  let resolveStall;
627
633
  let resolveSettlement;
634
+ let resolveContextBudget;
628
635
  let settled = false;
629
636
  let abortConfirmed = true;
630
637
  const persistRuntimeNote = (event) => {
@@ -655,6 +662,30 @@ export async function executeSingleSdkAttempt(options) {
655
662
  clearTimeout(stallHandle);
656
663
  stallHandle = setTimeout(() => resolveStall?.("stall"), stallTimeoutMs);
657
664
  };
665
+ const checkContextBudget = () => {
666
+ if (contextBudgetExhausted || !options.contextBudget)
667
+ return;
668
+ // Context protection follows the largest provider-reported response
669
+ // snapshot, not cumulative billing usage across turns. Summing repeated
670
+ // input contexts would abort healthy multi-turn sessions far below the
671
+ // model's actual context window.
672
+ const observedTokens = maxSdkContextTokens(usageSamples);
673
+ const turnExceeded = options.contextBudget.maxTurns !== undefined &&
674
+ observedTurns >= options.contextBudget.maxTurns;
675
+ const tokenExceeded = options.contextBudget.maxTokens !== undefined &&
676
+ observedTokens >= options.contextBudget.maxTokens;
677
+ if (!turnExceeded && !tokenExceeded)
678
+ return;
679
+ contextBudgetExhausted = true;
680
+ contextBudgetDetail = [
681
+ "context-budget-exhausted:",
682
+ `observedTurns=${observedTurns}`,
683
+ `observedTokens=${observedTokens}`,
684
+ `maxTurns=${options.contextBudget.maxTurns ?? "unbounded"}`,
685
+ `maxTokens=${options.contextBudget.maxTokens ?? "unbounded"}`,
686
+ ].join(" ");
687
+ resolveContextBudget?.("context-budget");
688
+ };
658
689
  unsubscribe = session.subscribe((event) => {
659
690
  if (event.type === "agent_settled" && !settled) {
660
691
  settled = true;
@@ -678,6 +709,9 @@ export async function executeSingleSdkAttempt(options) {
678
709
  const usageSample = extractSdkUsageSample(event);
679
710
  if (usageSample)
680
711
  usageSamples.push(usageSample);
712
+ if (event.type === "turn_end")
713
+ observedTurns += 1;
714
+ checkContextBudget();
681
715
  // Capture writer-thinking-exhausted evidence BEFORE the
682
716
  // shouldPersistSessionEvent filter: thinking_delta is intentionally
683
717
  // excluded from persisted JSONL, but must still count toward the
@@ -700,6 +734,11 @@ export async function executeSingleSdkAttempt(options) {
700
734
  const runTurn = async (userMessage) => {
701
735
  const settlementPromise = resetSettlement();
702
736
  const stallPromise = resetStallPromise();
737
+ const contextBudgetPromise = options.contextBudget
738
+ ? new Promise((resolve) => {
739
+ resolveContextBudget = resolve;
740
+ })
741
+ : null;
703
742
  const promptPromise = session.prompt(userMessage);
704
743
  // A settlement event is permitted to win the race while prompt() remains
705
744
  // pending. Observe a later rejection so it never becomes unhandled.
@@ -710,6 +749,7 @@ export async function executeSingleSdkAttempt(options) {
710
749
  settlementPromise,
711
750
  ...(timeoutPromise ? [timeoutPromise] : []),
712
751
  ...(stallPromise ? [stallPromise] : []),
752
+ ...(contextBudgetPromise ? [contextBudgetPromise] : []),
713
753
  ]);
714
754
  };
715
755
  const disposeSession = async () => {
@@ -737,7 +777,12 @@ export async function executeSingleSdkAttempt(options) {
737
777
  : options.userMessage;
738
778
  let continuesSent = 0;
739
779
  let raced = await runTurn(promptMessage);
740
- if (raced === "stall") {
780
+ if (raced === "context-budget") {
781
+ appendStderr(contextBudgetDetail);
782
+ abortConfirmed = await runSessionActionWithGrace("abort", () => session.abort());
783
+ await disposeSession();
784
+ }
785
+ else if (raced === "stall") {
741
786
  abortConfirmed = await runSessionActionWithGrace("abort", () => session.abort());
742
787
  const withinAbsoluteTimeout = timeoutMs <= 0 || Date.now() - startedAt < timeoutMs;
743
788
  const canContinue = abortConfirmed &&
@@ -154,6 +154,28 @@ function assertMkdirRelative(relative, ctx) {
154
154
  throw new PiWriterPathDeniedError("forbidden", relative || ".");
155
155
  }
156
156
  }
157
+ export async function assertReadablePath(targetPath, ctx) {
158
+ const raw = String(targetPath ?? "").trim();
159
+ if (!raw)
160
+ throw new PiWriterPathDeniedError("empty-path", targetPath ?? "");
161
+ const repoRoot = path.resolve(ctx.repoRoot);
162
+ const absoluteTarget = path.isAbsolute(raw)
163
+ ? path.resolve(raw)
164
+ : path.resolve(repoRoot, raw);
165
+ if (!isPathInside(repoRoot, absoluteTarget)) {
166
+ throw new PiWriterPathDeniedError("outside-repo", raw);
167
+ }
168
+ const effective = await resolveEffectiveMutationTarget(repoRoot, absoluteTarget);
169
+ const relative = repoRelativePath(repoRoot, absoluteTarget, raw, false);
170
+ const effectiveRelative = repoRelativePath(effective.realRoot, effective.absoluteTarget, raw, false);
171
+ const readSet = ctx.readSet ?? [];
172
+ for (const candidate of [relative, effectiveRelative]) {
173
+ if (readSet.length === 0 || !matchesAnyPattern(candidate, readSet)) {
174
+ throw new PiWriterPathDeniedError("read-outside-read-set", candidate, "path is outside the node readSet");
175
+ }
176
+ }
177
+ return relative;
178
+ }
157
179
  /**
158
180
  * Assert a mutation target is writable under the DAG writer policy.
159
181
  * Returns the normalized lexical repo-relative path on success.
@@ -201,11 +223,30 @@ export async function assertMkdirPath(dirPath, ctx) {
201
223
  assertMkdirRelative(effectiveRelative, ctx);
202
224
  return lexicalRelative;
203
225
  }
226
+ export function createPiReaderFsOperations(ctx) {
227
+ return {
228
+ readFile: async (absolutePath) => {
229
+ await assertReadablePath(absolutePath, ctx);
230
+ return fsReadFile(absolutePath);
231
+ },
232
+ access: async (absolutePath) => {
233
+ await assertReadablePath(absolutePath, ctx);
234
+ await fsAccess(absolutePath);
235
+ },
236
+ };
237
+ }
204
238
  /** Build FS operations that enforce writer policy before mutations. */
205
239
  export function createPiWriterFsOperations(ctx) {
240
+ const strictReadSet = (ctx.readSet?.length ?? 0) > 0;
206
241
  return {
207
- readFile: (absolutePath) => fsReadFile(absolutePath),
242
+ readFile: async (absolutePath) => {
243
+ if (strictReadSet)
244
+ await assertReadablePath(absolutePath, ctx);
245
+ return fsReadFile(absolutePath);
246
+ },
208
247
  access: async (absolutePath) => {
248
+ if (strictReadSet)
249
+ await assertReadablePath(absolutePath, ctx);
209
250
  await fsAccess(absolutePath);
210
251
  },
211
252
  writeFile: async (absolutePath, content) => {
@@ -218,6 +259,20 @@ export function createPiWriterFsOperations(ctx) {
218
259
  },
219
260
  };
220
261
  }
262
+ export async function createPiReaderCustomTools(ctx) {
263
+ const sdk = (await import("@earendil-works/pi-coding-agent"));
264
+ const createReadToolDefinition = sdk.createReadToolDefinition;
265
+ if (typeof createReadToolDefinition !== "function") {
266
+ throw new Error("pi read tool policy unavailable: createReadToolDefinition missing from SDK");
267
+ }
268
+ const read = createReadToolDefinition(path.resolve(ctx.repoRoot), {
269
+ operations: createPiReaderFsOperations(ctx),
270
+ });
271
+ if (read?.name !== "read") {
272
+ throw new Error("pi read tool policy unavailable: SDK tool must be named read");
273
+ }
274
+ return [read];
275
+ }
221
276
  /**
222
277
  * Create same-name custom edit/write tool definitions that override built-ins
223
278
  * via Pi SDK customTools registration. Fails closed when SDK factories are missing.
@@ -262,5 +317,6 @@ export function buildPiWriterToolPolicyContext(input) {
262
317
  writeSet: input.writeSet ?? [],
263
318
  forbiddenPaths: input.forbiddenPaths ?? [],
264
319
  writePolicy: input.writePolicy ?? "exclusive",
320
+ readSet: input.readSet ?? [],
265
321
  };
266
322
  }
@@ -41,6 +41,7 @@ import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
41
41
  import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
42
42
  import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
43
43
  import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
44
+ import { captureWorkspaceWriteSnapshot, diffWorkspaceWriteSnapshots, } from "./workspace-write-snapshot.js";
44
45
  import { parseAndValidateFinalWriteSetApproval, } from "../workflows/dag/node-execution.js";
45
46
  import { buildShellProcessEnv } from "./shell-verification.js";
46
47
  import { readRunState } from "../workflows/dag/run-store.js";
@@ -539,13 +540,22 @@ async function executeBackendTestPipeline(input, meta) {
539
540
  const started = Date.now();
540
541
  // Plan A: prefer the frozen layout carried by the compiled spec; fall back
541
542
  // to the historical default for hand-written legacy DAGs.
542
- const backendLayout = resolveBackendTestLayout(meta.spec.backendTestLayout
543
+ const baseBackendLayout = resolveBackendTestLayout(meta.spec.backendTestLayout
543
544
  ? {
544
545
  testRoot: meta.spec.backendTestLayout.testRoot,
545
546
  markdownRel: meta.spec.backendTestLayout.markdownRel,
546
547
  scriptRel: meta.spec.backendTestLayout.scriptRel,
547
548
  }
548
549
  : undefined);
550
+ const markdownPlanArtifact = path.join(meta.runDir, "generate-backend-md-plan-pi", "plan.md");
551
+ const backendLayout = existsSync(markdownPlanArtifact)
552
+ ? {
553
+ ...baseBackendLayout,
554
+ readmePath: path
555
+ .relative(input.cwd, markdownPlanArtifact)
556
+ .replaceAll(path.sep, "/"),
557
+ }
558
+ : baseBackendLayout;
549
559
  try {
550
560
  if (!pipeline)
551
561
  throw new Error("missing backend-test pipeline id");
@@ -915,6 +925,7 @@ async function executeBackendTestPipeline(input, meta) {
915
925
  effective = await materializeEffectiveBackendPytestCollection({
916
926
  workspaceRoot: input.cwd,
917
927
  initial,
928
+ layout: backendLayout,
918
929
  });
919
930
  }
920
931
  else if (initial.status === "REPAIRABLE") {
@@ -974,6 +985,7 @@ async function executeBackendTestPipeline(input, meta) {
974
985
  workspaceRoot: input.cwd,
975
986
  initial,
976
987
  final: finalFacts,
988
+ layout: backendLayout,
977
989
  });
978
990
  }
979
991
  else {
@@ -1036,6 +1048,7 @@ async function executeBackendTestPipeline(input, meta) {
1036
1048
  scenarioParamRepairAttempt: scenarioFacts.repairAttempt,
1037
1049
  eligibility,
1038
1050
  eligibilityInputHashes,
1051
+ layout: backendLayout,
1039
1052
  });
1040
1053
  return {
1041
1054
  ok: true,
@@ -1059,7 +1072,7 @@ async function executeBackendTestPipeline(input, meta) {
1059
1072
  else if (pipeline === "markdown-traceability") {
1060
1073
  let report;
1061
1074
  try {
1062
- report = await validateBackendMarkdownTraceability(input.cwd);
1075
+ report = await validateBackendMarkdownTraceability(input.cwd, backendLayout);
1063
1076
  }
1064
1077
  catch (error) {
1065
1078
  report = `# Backend Test Traceability\n\n## Status\n\nFAIL\n\n## Findings\n\n- Traceability validation could not complete: ${error instanceof Error ? error.message : String(error)}\n`;
@@ -1144,7 +1157,7 @@ async function executeBackendTestPipeline(input, meta) {
1144
1157
  }
1145
1158
  else if (pipeline === "markdown-execute-html") {
1146
1159
  const executionReadiness = await readBackendTestExecutionReadiness(path.join(meta.runDir, "contracts", "backend-test-execution-readiness.json"));
1147
- await assertBackendTestExecutionReadinessFresh(input.cwd, executionReadiness);
1160
+ await assertBackendTestExecutionReadinessFresh(input.cwd, executionReadiness, backendLayout);
1148
1161
  const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd, backendLayout);
1149
1162
  let eligibleItemIds = executionReadiness.eligibleItemIds;
1150
1163
  // Plan D gap-fill executeScope=affected: when a gap plan exists in
@@ -1719,6 +1732,33 @@ async function executeBackendTestPipeline(input, meta) {
1719
1732
  }
1720
1733
  }
1721
1734
  async function executeBackendTestPipelineWithWriteGuard(input, meta) {
1735
+ if (meta.spec.backendTestWorkspaceControl === "filesystem-only") {
1736
+ const paths = input.task.writeSet ?? input.task.allowedPaths;
1737
+ let beforeSnapshot;
1738
+ try {
1739
+ beforeSnapshot = await captureWorkspaceWriteSnapshot({ rootCwd: input.cwd, paths });
1740
+ }
1741
+ catch (error) {
1742
+ return { ok: false, stdout: "", stderr: `workspace filesystem baseline unavailable: ${error instanceof Error ? error.message : String(error)}`, failureCategory: "write-guard", durationMs: 0 };
1743
+ }
1744
+ const result = await executeBackendTestPipeline(input, meta);
1745
+ try {
1746
+ const afterSnapshot = await captureWorkspaceWriteSnapshot({ rootCwd: input.cwd, paths });
1747
+ const guard = validateShellWriteGuard({
1748
+ changedPaths: diffWorkspaceWriteSnapshots(beforeSnapshot, afterSnapshot),
1749
+ writePolicy: input.task.writePolicy,
1750
+ writeSet: input.task.writeSet,
1751
+ allowedPaths: input.task.allowedPaths,
1752
+ forbiddenPaths: input.task.forbiddenPaths,
1753
+ });
1754
+ if (!guard.ok)
1755
+ return { ...result, ok: false, stderr: [result.stderr, `filesystem write guard failed: ${guard.violations.join(", ")}`].filter(Boolean).join("\n\n"), failureCategory: "write-guard" };
1756
+ return result;
1757
+ }
1758
+ catch (error) {
1759
+ return { ...result, ok: false, stderr: [result.stderr, `filesystem write guard failed: ${error instanceof Error ? error.message : String(error)}`].filter(Boolean).join("\n\n"), failureCategory: "write-guard" };
1760
+ }
1761
+ }
1722
1762
  let beforeStatus;
1723
1763
  try {
1724
1764
  beforeStatus = await readGitStatusPorcelain(input.cwd);
@@ -0,0 +1,68 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readdir, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { pathMatchesPattern } from "../shared/git-progress.js";
5
+ const normalize = (value) => value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
6
+ function rootBeforeWildcard(pattern) {
7
+ const value = normalize(pattern);
8
+ const wildcard = value.search(/[?*]/);
9
+ if (wildcard < 0)
10
+ return path.posix.dirname(value);
11
+ const prefix = value.slice(0, wildcard);
12
+ return prefix.slice(0, prefix.lastIndexOf("/")) || ".";
13
+ }
14
+ async function walk(rootCwd, relative, files) {
15
+ let entries;
16
+ try {
17
+ entries = await readdir(path.resolve(rootCwd, relative), { withFileTypes: true });
18
+ }
19
+ catch {
20
+ return;
21
+ }
22
+ for (const entry of entries) {
23
+ if ([".git", ".harness", "node_modules"].includes(entry.name))
24
+ continue;
25
+ const child = normalize(path.posix.join(relative, entry.name));
26
+ if (entry.isDirectory())
27
+ await walk(rootCwd, child, files);
28
+ else if (entry.isFile())
29
+ files.add(child);
30
+ }
31
+ }
32
+ export async function captureWorkspaceWriteSnapshot(input) {
33
+ const candidates = new Set();
34
+ for (const pattern of input.paths) {
35
+ await walk(input.rootCwd, rootBeforeWildcard(pattern), candidates);
36
+ if (!/[?*]/.test(pattern)) {
37
+ try {
38
+ if ((await lstat(path.resolve(input.rootCwd, pattern))).isFile())
39
+ candidates.add(normalize(pattern));
40
+ }
41
+ catch { /* absent target */ }
42
+ }
43
+ }
44
+ const files = {};
45
+ let totalBytes = 0;
46
+ for (const relative of [...candidates].filter((file) => input.paths.some((pattern) => pathMatchesPattern(file, pattern))).sort()) {
47
+ if (Object.keys(files).length >= 20_000)
48
+ throw new Error("workspace snapshot file limit exceeded");
49
+ const absolute = path.resolve(input.rootCwd, relative);
50
+ const stat = await lstat(absolute);
51
+ if (!stat.isFile())
52
+ throw new Error(`workspace snapshot encountered non-regular file: ${relative}`);
53
+ totalBytes += stat.size;
54
+ if (totalBytes > 64 * 1024 * 1024)
55
+ throw new Error("workspace snapshot byte limit exceeded");
56
+ files[relative] = { size: stat.size, sha256: createHash("sha256").update(await readFile(absolute)).digest("hex") };
57
+ }
58
+ const body = JSON.stringify(files);
59
+ return { schemaVersion: 1, controlMode: "filesystem-only", files, sha256: createHash("sha256").update(body).digest("hex") };
60
+ }
61
+ export function diffWorkspaceWriteSnapshots(before, after) {
62
+ const paths = new Set([...Object.keys(before.files), ...Object.keys(after.files)]);
63
+ return [...paths].sort().filter((file) => {
64
+ const left = before.files[file];
65
+ const right = after.files[file];
66
+ return !left || !right || left.size !== right.size || left.sha256 !== right.sha256;
67
+ });
68
+ }
@@ -2,6 +2,29 @@ import { copyFile, mkdir, readFile, rename, unlink, writeFile } from "node:fs/pr
2
2
  import path from "node:path";
3
3
  import { assertHarnessWriteAllowed, } from "./completed-facts-guard.js";
4
4
  let atomicWriteSequence = 0;
5
+ function isTransientJsonReadError(error) {
6
+ if (error instanceof SyntaxError)
7
+ return true;
8
+ if (!error || typeof error !== "object" || !("code" in error))
9
+ return false;
10
+ const code = String(error.code ?? "");
11
+ return code === "EBUSY" || code === "EPERM" || code === "EACCES";
12
+ }
13
+ export async function readJsonWithTransientRetry(targetPath, options = {}) {
14
+ const attempts = Math.max(1, Math.floor(options.attempts ?? 6));
15
+ const delayMs = Math.max(0, Math.floor(options.delayMs ?? 20));
16
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
17
+ try {
18
+ return JSON.parse(await readFile(targetPath, "utf-8"));
19
+ }
20
+ catch (error) {
21
+ if (attempt === attempts || !isTransientJsonReadError(error))
22
+ throw error;
23
+ await new Promise((resolve) => setTimeout(resolve, delayMs * attempt));
24
+ }
25
+ }
26
+ throw new Error("unreachable transient JSON read state");
27
+ }
5
28
  async function writeAtomic(targetPath, content, options) {
6
29
  assertHarnessWriteAllowed(targetPath, options);
7
30
  const resolved = options?.repoRoot && !path.isAbsolute(targetPath)
@@ -1,15 +1,14 @@
1
1
  /**
2
2
  * Internal switch for the experimental "rerun failed node with an edited
3
- * prompt" capability. Default is OFF; nothing may depend on it being enabled.
4
- * The switch is deliberately env-based (no harness.json surface) while the
5
- * feature is internal.
3
+ * prompt" capability. The feature is enabled by default; the environment
4
+ * variable remains as an emergency kill switch while the feature is internal.
6
5
  */
7
6
  export const DAG_RERUN_PROMPT_OVERRIDE_ENV = "HARNESS_DAG_RERUN_PROMPT_OVERRIDE";
8
7
  /** Upper bound for an override input body (characters). */
9
8
  export const MAX_DAG_RERUN_PROMPT_OVERRIDE_CHARS = 65_536;
10
9
  export function isDagRerunPromptOverrideEnabled(env = process.env) {
11
- const value = env[DAG_RERUN_PROMPT_OVERRIDE_ENV];
12
- return value === "1" || value === "true";
10
+ const value = env[DAG_RERUN_PROMPT_OVERRIDE_ENV]?.trim().toLowerCase();
11
+ return value !== "0" && value !== "false" && value !== "off";
13
12
  }
14
13
  export const DAG_RERUN_PROMPT_ADDENDUM_TAG = "operator_addendum";
15
14
  /**
@@ -11,10 +11,10 @@ export function createNpmUpdateClient(options) {
11
11
  const timeoutMs = options.timeoutMs ?? DEFAULT_NPM_QUERY_TIMEOUT_MS;
12
12
  const cwd = options.cwd;
13
13
  const env = options.env;
14
- const latestVersionCheck = async () => {
14
+ const distTagVersionCheck = async (channel) => {
15
15
  let result;
16
16
  try {
17
- result = await runner(npmCommand, ["view", PACKAGE_NAME, "dist-tags.latest", "--json"], { timeoutMs, cwd, env });
17
+ result = await runner(npmCommand, ["view", PACKAGE_NAME, `dist-tags.${channel}`, "--json"], { timeoutMs, cwd, env });
18
18
  }
19
19
  catch {
20
20
  return { ok: false, reason: "spawn-failure" };
@@ -27,17 +27,19 @@ export function createNpmUpdateClient(options) {
27
27
  if (result.exitCode !== 0) {
28
28
  return { ok: false, reason: "command-failed" };
29
29
  }
30
- const version = parseNpmViewLatest(result.stdout);
30
+ const version = parseNpmViewDistTag(result.stdout, channel);
31
31
  return version
32
32
  ? { ok: true, version }
33
33
  : { ok: false, reason: "invalid-response" };
34
34
  };
35
+ const latestVersionCheck = () => distTagVersionCheck("latest");
35
36
  return {
36
37
  async latestVersion() {
37
38
  const result = await latestVersionCheck();
38
39
  return result.ok ? result.version : undefined;
39
40
  },
40
41
  latestVersionCheck,
42
+ distTagVersionCheck,
41
43
  async canAutoInstall() {
42
44
  return proveGlobalNpmInstall({
43
45
  currentVersion: options.currentVersion,
@@ -49,10 +51,11 @@ export function createNpmUpdateClient(options) {
49
51
  timeoutMs,
50
52
  });
51
53
  },
52
- async installExact(version) {
53
- if (!isStrictRelease(version))
54
+ async installExact(version, channel = "latest") {
55
+ if (!isAllowedChannelVersion(version, channel)) {
54
56
  return { ok: false, reason: "invalid-version" };
55
- const result = await runner(npmCommand, buildExactInstallArgs(version), {
57
+ }
58
+ const result = await runner(npmCommand, buildExactInstallArgs(version, channel), {
56
59
  timeoutMs: Math.max(timeoutMs, 120000),
57
60
  cwd,
58
61
  env,
@@ -79,6 +82,13 @@ export function createNpmUpdateClient(options) {
79
82
  };
80
83
  }
81
84
  export function parseNpmViewLatest(stdout) {
85
+ return parseNpmViewDistTag(stdout, "latest");
86
+ }
87
+ /**
88
+ * `latest` stays stable-only. `next` is explicitly selected by a human and
89
+ * may legitimately be a SemVer prerelease, but remains an exact version.
90
+ */
91
+ export function parseNpmViewDistTag(stdout, channel) {
82
92
  const trimmed = stdout.trim();
83
93
  let value = trimmed;
84
94
  try {
@@ -89,11 +99,12 @@ export function parseNpmViewLatest(stdout) {
89
99
  }
90
100
  if (typeof value !== "string")
91
101
  return undefined;
92
- return isStrictRelease(value) ? value : undefined;
102
+ return isAllowedChannelVersion(value, channel) ? value : undefined;
93
103
  }
94
- export function buildExactInstallArgs(version) {
95
- if (!isStrictRelease(version))
96
- throw new Error(`invalid release version: ${version}`);
104
+ export function buildExactInstallArgs(version, channel = "latest") {
105
+ if (!isAllowedChannelVersion(version, channel)) {
106
+ throw new Error(`invalid ${channel} version: ${version}`);
107
+ }
97
108
  return ["install", "-g", `${PACKAGE_NAME}@${version}`, "--no-fund", "--no-audit"];
98
109
  }
99
110
  export function verifyNpmListVersion(stdout, expectedVersion) {
@@ -106,7 +117,13 @@ export function verifyNpmListVersion(stdout, expectedVersion) {
106
117
  }
107
118
  }
108
119
  export function verifyPathVersion(stdout, expectedVersion) {
109
- return stdout.trim() === expectedVersion;
120
+ // `loop-agent --version` may append a build provenance marker. The release
121
+ // version itself must still match exactly; accepting no other trailing form
122
+ // keeps PATH verification bound to the requested stable package.
123
+ return new RegExp(`^${escapeRegExp(expectedVersion)}(?: \\(build [^)]+\\))?$`).test(stdout.trim());
124
+ }
125
+ function escapeRegExp(value) {
126
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
110
127
  }
111
128
  async function proveGlobalNpmInstall(options) {
112
129
  const root = await options.runner(options.npmCommand, ["root", "-g"], {
@@ -142,6 +159,11 @@ function isStrictRelease(version) {
142
159
  const parsed = semver.parse(version);
143
160
  return Boolean(parsed && parsed.prerelease.length === 0);
144
161
  }
162
+ function isAllowedChannelVersion(version, channel) {
163
+ if (channel === "latest")
164
+ return isStrictRelease(version);
165
+ return Boolean(semver.valid(version));
166
+ }
145
167
  async function runCommand(command, args, options) {
146
168
  return new Promise((resolve) => {
147
169
  const child = spawn(command, [...args], {
@@ -147,6 +147,8 @@ export const backendTestConfigSchema = z
147
147
  gapDoc: z.string().min(1).optional(),
148
148
  /** gap-fill pytest execution scope: only affected items (default) or all eligible. */
149
149
  executeScope: z.enum(["affected", "all"]).optional(),
150
+ /** Backend-test-only workspace mutation control; omitted means Git guard. */
151
+ workspaceControl: z.enum(["git", "filesystem-only"]).optional(),
150
152
  })
151
153
  .strict()
152
154
  .optional();
@@ -237,5 +239,13 @@ export const taskConfigSchema = z.preprocess((raw) => {
237
239
  if ("executor" in obj || "cursorModel" in obj) {
238
240
  throw new Error(CURSOR_TASK_FIELD_REMOVED_ERROR);
239
241
  }
242
+ const backendTest = obj.backendTest;
243
+ if (backendTest &&
244
+ typeof backendTest === "object" &&
245
+ !Array.isArray(backendTest) &&
246
+ "workspaceControl" in backendTest &&
247
+ obj.taskKind !== "backend-test") {
248
+ throw new Error("backendTest.workspaceControl is only valid when taskKind is backend-test");
249
+ }
240
250
  return raw;
241
251
  }, taskConfigObjectSchema);
@@ -232,8 +232,11 @@ export async function applyTaskContract(input) {
232
232
  // Re-parse through schema to ensure full TaskConfig shape for hash stability
233
233
  const fullConfig = parseTaskConfigForRuntime(mergedConfig);
234
234
  const taskConfigContent = serializeTaskConfigForDisk(fullConfig);
235
- const requirementContent = projectRequirementMarkdown(input.draft);
235
+ const requirementContent = projectRequirementMarkdown(input.draft, input.requirementLedger);
236
236
  const constraintsContent = projectConstraintsMarkdown(input.draft);
237
+ const requirementLedgerContent = input.requirementLedger !== undefined
238
+ ? `${JSON.stringify(input.requirementLedger, null, 2)}\n`
239
+ : undefined;
237
240
  // Validate references manifest hash if present (do not modify)
238
241
  const paths = getTaskPaths(input.repoRoot, input.taskId);
239
242
  const manifestText = await readFile(getSourceManifestPath(paths.sourceDir), "utf-8").catch(() => null);
@@ -241,6 +244,7 @@ export async function applyTaskContract(input) {
241
244
  requirementBytes: requirementContent,
242
245
  constraintsBytes: constraintsContent,
243
246
  referenceManifestBytes: manifestText,
247
+ requirementLedgerBytes: requirementLedgerContent,
244
248
  });
245
249
  const taskConfigSha256 = computeTaskConfigSha256(fullConfig);
246
250
  const canonicalHash = computeCanonicalHash({
@@ -277,6 +281,9 @@ export async function applyTaskContract(input) {
277
281
  constraintsContent,
278
282
  taskConfigContent,
279
283
  contractRef: nextRef,
284
+ ...(requirementLedgerContent !== undefined
285
+ ? { requirementLedgerContent }
286
+ : {}),
280
287
  },
281
288
  });
282
289
  await appendRequestLedgerEntry({
@@ -312,6 +319,9 @@ export async function applyTaskContract(input) {
312
319
  changedFiles: [
313
320
  "source/需求.md",
314
321
  "source/执行约束.md",
322
+ ...(requirementLedgerContent !== undefined
323
+ ? ["source/requirement-ledger.json"]
324
+ : []),
315
325
  "task.json",
316
326
  "contract.json",
317
327
  ],
@@ -29,3 +29,5 @@ export const TRANSACTIONS_DIRNAME = TRANSACTIONS_DIR_NAME;
29
29
  export const REQUEST_LEDGER_BASENAME = REQUEST_LEDGER_FILE_NAME;
30
30
  export const REQUIREMENT_FILE_NAME = "需求.md";
31
31
  export const CONSTRAINTS_FILE_NAME = "执行约束.md";
32
+ /** Source fidelity ledger (20260824-frontend-source-fidelity-ledger-v1). */
33
+ export const REQUIREMENT_LEDGER_FILE_NAME = "requirement-ledger.json";
@@ -35,6 +35,9 @@ export function computeSourceHashes(input) {
35
35
  if (input.referenceManifestBytes) {
36
36
  sourceHashes.referenceManifestSha256 = sha256Utf8(input.referenceManifestBytes);
37
37
  }
38
+ if (input.requirementLedgerBytes) {
39
+ sourceHashes.requirementLedgerSha256 = sha256Utf8(input.requirementLedgerBytes);
40
+ }
38
41
  return sourceHashes;
39
42
  }
40
43
  export function refMatchesObserved(ref, observedCanonicalHash) {