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

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 (213) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +114 -109
  3. package/README.md +2 -2
  4. package/dist/application/dag/generate-task-dag.js +6 -2
  5. package/dist/application/task-lifecycle/advance.js +12 -0
  6. package/dist/build-stamp.json +3 -3
  7. package/dist/cli/update/runtime-activity.js +1 -29
  8. package/dist/commands/dag-rerun.js +1 -1
  9. package/dist/executors/dag-pi-executor.js +69 -7
  10. package/dist/executors/pi-executor.js +4 -2
  11. package/dist/executors/pi-sdk-executor.js +46 -1
  12. package/dist/executors/pi-writer-tool-policy.js +57 -1
  13. package/dist/executors/shell-executor.js +107 -20
  14. package/dist/executors/workspace-write-snapshot.js +68 -0
  15. package/dist/governance/checks.js +1 -0
  16. package/dist/infrastructure/harness/atomic-write.js +23 -0
  17. package/dist/shared/dag-prompt-override.js +4 -5
  18. package/dist/shared/update/npm-client.js +33 -11
  19. package/dist/task/config-types.js +10 -0
  20. package/dist/task/contract/apply.js +11 -1
  21. package/dist/task/contract/constants.js +2 -0
  22. package/dist/task/contract/hash.js +3 -0
  23. package/dist/task/contract/observe.js +25 -4
  24. package/dist/task/contract/paths.js +2 -1
  25. package/dist/task/contract/project.js +15 -1
  26. package/dist/task/contract/schema.js +2 -0
  27. package/dist/task/contract/transaction.js +12 -0
  28. package/dist/task/source-prepare/completeness.js +188 -0
  29. package/dist/task/source-prepare/fragment-inventory.js +474 -0
  30. package/dist/task/source-prepare/index.js +3 -0
  31. package/dist/task/source-prepare/ledger-reconciliation.js +127 -0
  32. package/dist/task/source-prepare/ledger-review.js +214 -0
  33. package/dist/task/source-prepare/ledger.js +545 -0
  34. package/dist/task/source-prepare/prepare.js +262 -1
  35. package/dist/task/source-prepare/semantic-intake.js +19 -3
  36. package/dist/task/source-prepare/source-fidelity-pi.js +384 -0
  37. package/dist/task/source-prepare/types.js +22 -0
  38. package/dist/worker/cli.js +12 -26
  39. package/dist/worker/console/chat/chat-event-store.js +75 -5
  40. package/dist/worker/console/chat/context-panel.js +7 -0
  41. package/dist/worker/console/chat/deferred-turn.js +12 -0
  42. package/dist/worker/console/chat/pi-runtime.js +331 -18
  43. package/dist/worker/console/chat/provider-error.js +98 -0
  44. package/dist/worker/console/chat/routes.js +389 -56
  45. package/dist/worker/console/chat/session-stats.js +179 -0
  46. package/dist/worker/console/chat/session-store.js +93 -63
  47. package/dist/worker/console/chat/todos.js +115 -0
  48. package/dist/worker/console/chat/tool-preview.js +42 -0
  49. package/dist/worker/console/chat/turn-process.js +20 -55
  50. package/dist/worker/console/chat/user-questions.js +266 -0
  51. package/dist/worker/console/chat/workspace-landing.js +79 -0
  52. package/dist/worker/console/console-handoff.js +82 -0
  53. package/dist/worker/console/console-update-and-init.js +115 -0
  54. package/dist/worker/console/console-update-runtime.js +84 -0
  55. package/dist/worker/console/dag-execution-receipt.js +20 -2
  56. package/dist/worker/console/draft-store.js +49 -0
  57. package/dist/worker/console/native-directory-picker.js +13 -0
  58. package/dist/worker/console/operation-store.js +1 -1
  59. package/dist/worker/console/operator-actions.js +133 -7
  60. package/dist/worker/console/operator-surface-health.js +1 -0
  61. package/dist/worker/console/prd-intake-bridge.js +13 -0
  62. package/dist/worker/console/routes.js +312 -1
  63. package/dist/worker/console/security.js +4 -4
  64. package/dist/worker/console/server.js +583 -172
  65. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Br92EGb7.js → abnfDiagram-N423BO3Z-Bs-CDAXM.js} +1 -1
  66. package/dist/worker/console/static/assets/{arc-oulImtQq.js → arc-CAkA3We3.js} +1 -1
  67. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-tyHc6aqO.js → architectureDiagram-T3A2C74G-DEmI_zqr.js} +1 -1
  68. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CY86BSpT.js → blockDiagram-VBNYF7ZC-uDHANebE.js} +1 -1
  69. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BKKsDmNs.js → c4Diagram-5PPSVZJV-Bv97b4_B.js} +1 -1
  70. package/dist/worker/console/static/assets/channel-amxUpk7o.js +1 -0
  71. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-CXZdjl3B.js → chunk-2GRJ4B5K-shmQKsFz.js} +1 -1
  72. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B-aIFDQ5.js → chunk-2Q5K7J3B-BAraNvcN.js} +1 -1
  73. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BDgAW_B2.js → chunk-5RXB4S5H-BirhxUop.js} +1 -1
  74. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-DABa4UPR.js → chunk-5VM5RSS4-C9LgUJpR.js} +1 -1
  75. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-aGbysVOx.js → chunk-6Q2QTUOP-DrU-s-t7.js} +1 -1
  76. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B8MSZDVs.js → chunk-GF5L2VYU-Tx-H_FA2.js} +1 -1
  77. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CpelV73g.js → chunk-JWPE2WC7-BjLtNFrR.js} +1 -1
  78. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BE04ty3z.js → chunk-KBJHAD2P-UNLVwSsy.js} +1 -1
  79. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BUxTkaaR.js → chunk-RYQCIY6F-CBEUX8XD.js} +1 -1
  80. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CYYBVKfX.js → chunk-XXDRQBXY-WTL7IZOq.js} +1 -1
  81. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-bejwBVIz.js +1 -0
  82. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-bejwBVIz.js +1 -0
  83. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CEQF_xmQ.js → cose-bilkent-JH36ORCC-CkHG6W3s.js} +1 -1
  84. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-CqpxN5IT.js → cynefin-VYW2F7L2-BRzFqTaL.js} +1 -1
  85. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-B3PzanVi.js → cynefinDiagram-MW4NZA55-2_CFp_i7.js} +1 -1
  86. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BSUGCNx1.js → dagre-VZM6K2ZE-BJtsmdkE.js} +1 -1
  87. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CdLevU5T.js → diagram-7IWD3JNH-DNEFFo6w.js} +1 -1
  88. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BjpF_Kok.js → diagram-B4RE2ZJO-B_IWV7eo.js} +1 -1
  89. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-C4I6NJny.js → diagram-LBJQPF4R-DvvRx2dz.js} +1 -1
  90. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-C8hecCr5.js → diagram-Q27KOJAE-CsibsWLg.js} +1 -1
  91. package/dist/worker/console/static/assets/{diagram-UB23O5K3-Daod6tOS.js → diagram-UB23O5K3-les_djBQ.js} +1 -1
  92. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BvvmazGv.js → ebnfDiagram-BXEA7PRR-BEm2n7qJ.js} +1 -1
  93. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DeN_vfV4.js → erDiagram-JOGREHBK-BI7vHHVm.js} +1 -1
  94. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-D6dq1t9z.js → flowDiagram-UKHOOZJN-CUFCERrB.js} +1 -1
  95. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DziIdNY4.js → ganttDiagram-PKOTCBZU-wxyLiGWt.js} +1 -1
  96. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Dy4s5CXr.js → gitGraphDiagram-DS77QQ5N-Bt897K8Z.js} +1 -1
  97. package/dist/worker/console/static/assets/index-BHhUOTri.css +1 -0
  98. package/dist/worker/console/static/assets/index-DqJbO3-p.js +407 -0
  99. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-Czv5eYx2.js → infoDiagram-6WML65LV-DYNx_IPv.js} +1 -1
  100. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-Bsk3nFRT.js → ishikawaDiagram-WSZJBQD7-CzpLNEu6.js} +1 -1
  101. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DxGmNj5K.js → journeyDiagram-NVQOT4AX-97f9owNU.js} +1 -1
  102. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CYCFqkO_.js → kanban-definition-27J2QSJJ-BxM9hyBn.js} +1 -1
  103. package/dist/worker/console/static/assets/{linear-B0YzRwrd.js → linear-ByuxcHvp.js} +1 -1
  104. package/dist/worker/console/static/assets/{mermaid.core-t3EZtIGh.js → mermaid.core-DwPGvpCv.js} +5 -5
  105. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-BTXpTv0T.js → mindmap-definition-FAOFIHXS-DCoqG9RT.js} +1 -1
  106. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DU8UkKqA.js → pegDiagram-VL7TDLO6-BLSBmBMx.js} +1 -1
  107. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DP4y8ohp.js → pieDiagram-7S7Q4E2Y-B0t_0oJW.js} +1 -1
  108. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-B07vNaYR.js → quadrantDiagram-CIZ2JOQS-sTzDk7Nt.js} +1 -1
  109. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-xDWktb2i.js → railroadDiagram-AXF67PYL-B3jRdATC.js} +1 -1
  110. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BLNMKqeS.js → requirementDiagram-LRYGKXZP-D-cTtXEN.js} +1 -1
  111. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-n0adalxH.js → sankeyDiagram-W5VNT64P-B25igJRE.js} +1 -1
  112. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BbnS-2DL.js → sequenceDiagram-SI44F4Z6-jW_lM1_D.js} +1 -1
  113. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B4uPpCLu.js → sizeCapture-X5ZJPWSS-CrzTgsHE.js} +1 -1
  114. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA--MMbB0fX.js → stateDiagram-OKZ733FA-B01MvlqE.js} +1 -1
  115. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BvFKEtm2.js +1 -0
  116. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BXUgp2QY.js → swimlanes-SLNWSIFB-YcF12FiU.js} +2 -2
  117. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DIB2eVg-.js +8 -0
  118. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CsaMxki7.js → timeline-definition-Z64GVDOM-ClnRcg2s.js} +1 -1
  119. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-C2IuG9iH.js → vennDiagram-T6HMQDX7-B6gr_z-q.js} +1 -1
  120. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CmEWOXCW.js → wardleyDiagram-T6FBY63Y-CvSgguGH.js} +1 -1
  121. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-D_9LE0MG.js → xychartDiagram-ELKLHX3M-23OtSZCb.js} +1 -1
  122. package/dist/worker/console/static/index.html +2 -2
  123. package/dist/worker/console/static-src/app/console-types.js +68 -4
  124. package/dist/worker/console/static-src/app/useConsoleShell.js +30 -12
  125. package/dist/worker/console/static-src/app/useOperatorActions.js +41 -6
  126. package/dist/worker/console/static-src/app/useRecoveryConsole.js +10 -11
  127. package/dist/worker/console/static-src/app/useRunProgress.js +2 -1
  128. package/dist/worker/console/static-src/app/useTaskWizard.js +74 -2
  129. package/dist/worker/console/static-src/night/useNightBoard.js +8 -5
  130. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +141 -8
  131. package/dist/worker/console/static-src/operator-chat/compaction-message.js +79 -0
  132. package/dist/worker/console/static-src/operator-chat/details-dag-actions.js +122 -0
  133. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  134. package/dist/worker/console/static-src/operator-chat/runtime-selection-labels.js +27 -0
  135. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +1 -2
  136. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +25 -0
  137. package/dist/worker/console/static-src/operator-chat/turn-submission.js +3 -2
  138. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +181 -87
  139. package/dist/worker/console/static-src/operator-chat/useChatStream.js +159 -44
  140. package/dist/worker/console/static-src/operator-chat/useChatThread.js +177 -8
  141. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +63 -9
  142. package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
  143. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -3
  144. package/dist/worker/console/static-src/shell/useWorkspaces.js +299 -0
  145. package/dist/worker/console/static-src/shell/workspace-route.js +339 -0
  146. package/dist/worker/console/workspace-context.js +234 -0
  147. package/dist/worker/console/workspace-registry.js +214 -0
  148. package/dist/worker/observability/init-runtime-activity.js +26 -0
  149. package/dist/worker/observe/node-input.js +88 -2
  150. package/dist/worker/observe/routes.js +188 -0
  151. package/dist/worker/observe/server.js +4 -0
  152. package/dist/worker/observe/static/api.js +29 -9
  153. package/dist/worker/observe/static/app.js +18 -70
  154. package/dist/worker/observe/static/dag-node-purpose.d.ts +6 -0
  155. package/dist/worker/observe/static/dag-node-purpose.js +203 -0
  156. package/dist/worker/observe/static/format.js +37 -0
  157. package/dist/worker/observe/static/index.html +6 -1
  158. package/dist/worker/observe/static/inspect-workspace.js +307 -0
  159. package/dist/worker/observe/static/operator-chrome.css +145 -20
  160. package/dist/worker/observe/static/operator-chrome.d.ts +20 -3
  161. package/dist/worker/observe/static/operator-chrome.js +330 -100
  162. package/dist/worker/observe/static/prompt-restart-candidates.d.ts +18 -0
  163. package/dist/worker/observe/static/prompt-restart-candidates.js +36 -0
  164. package/dist/worker/observe/static/router.d.ts +46 -0
  165. package/dist/worker/observe/static/router.js +25 -1
  166. package/dist/worker/observe/static/state.d.ts +50 -0
  167. package/dist/worker/observe/static/state.js +174 -2
  168. package/dist/worker/observe/static/styles.css +357 -35
  169. package/dist/worker/observe/static/views/dag-graph.js +4 -2
  170. package/dist/worker/observe/static/views/dag-inspector.js +533 -79
  171. package/dist/worker/observe/static/views/dag.d.ts +13 -0
  172. package/dist/worker/observe/static/views/dag.js +6 -4
  173. package/dist/worker/observe/static/views/dashboard.js +6 -4
  174. package/dist/worker/observe/static/views/night.js +2 -2
  175. package/dist/worker/observe/static/views/pool.js +1 -0
  176. package/dist/worker/observe/static/views/run.js +3 -1
  177. package/dist/worker/observe/static/views/task.js +56 -1
  178. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +186 -8
  179. package/dist/workflows/dag/backend-test-markdown-workflow.js +176 -1
  180. package/dist/workflows/dag/backend-test-pytest-collection.js +168 -16
  181. package/dist/workflows/dag/backend-test-result-contract.js +4 -0
  182. package/dist/workflows/dag/backend-test-scenario-param.js +339 -53
  183. package/dist/workflows/dag/backend-test-writer-completeness.js +28 -17
  184. package/dist/workflows/dag/dynamic-runtime/map.js +1 -0
  185. package/dist/workflows/dag/frontend-implementation-contract.js +88 -2
  186. package/dist/workflows/dag/frontend-plan-render.js +24 -0
  187. package/dist/workflows/dag/frontend-prewrite-gate.js +197 -0
  188. package/dist/workflows/dag/frontend-review-context.js +92 -10
  189. package/dist/workflows/dag/init-hybrid.js +239 -52
  190. package/dist/workflows/dag/interrupt-request.js +3 -3
  191. package/dist/workflows/dag/lifecycle.js +3 -3
  192. package/dist/workflows/dag/node-execution.js +45 -0
  193. package/dist/workflows/dag/types.js +62 -12
  194. package/docs/README.md +2 -0
  195. package/docs/architecture/evolution.md +45 -0
  196. package/docs/init-surface.manifest.json +1 -0
  197. package/docs/operations/local-development-environment.md +21 -0
  198. package/docs/templates/README.md +1 -1
  199. package/docs/templates/agent-dag.schema.json +18 -0
  200. package/docs/templates/backend-test-dag.json +267 -238
  201. package/docs/templates/frontend-implementation-contract.schema.json +681 -24
  202. package/harness.json +3 -3
  203. package/package.json +3 -1
  204. package/skills/loop-agent/references/hybrid-dag.md +7 -0
  205. package/dist/worker/console/static/assets/channel-TY36zt-i.js +0 -1
  206. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Dk7QCChM.js +0 -1
  207. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Dk7QCChM.js +0 -1
  208. package/dist/worker/console/static/assets/index-C3GgHg-g.css +0 -1
  209. package/dist/worker/console/static/assets/index-Ci8ksvBm.js +0 -406
  210. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BqOigMQ4.js +0 -1
  211. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Caxz9UA2.js +0 -8
  212. package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +0 -73
  213. 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
  }
@@ -32,15 +32,16 @@ import { materializeBackendTestExecutionContract } from "../workflows/dag/backen
32
32
  import { resolveBackendTestLayout } from "../workflows/dag/backend-test-layout.js";
33
33
  import { frontendTestLayoutFromSpec, } from "../workflows/dag/frontend-test-layout.js";
34
34
  import { backendTestGapDocSchema, ingestBackendTestGap, } from "../workflows/dag/backend-test-gap-fill.js";
35
- import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
35
+ import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, classifyBackendTestCorrespondenceRepair, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
36
36
  import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport, } from "../workflows/dag/backend-test-result-contract.js";
37
37
  import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, resolveBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, hasBlockingBackendMarkdownModuleStemFindings, inspectBackendTestEnvironment, markdownFiles, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
38
38
  import { applyDeterministicScenarioParamRepairs, assessBackendScenarioParamConsistency, classifyBackendTestFailureWithScenarioParam, readBackendScenarioParamFacts, renderBackendTestFailureAnalysis, writeBackendScenarioParamArtifacts, writeScenarioParamRepairAudit, } from "../workflows/dag/backend-test-scenario-param.js";
39
- import { assessBackendPytestCollection, assessMissingBackendPytestScripts, assessPriorityOnlyBackendPytestModules, assertBackendTestExecutionReadinessFresh, buildBackendPytestAssetInventory, buildBackendTestItemEligibility, materializeBackendTestExecutionReadiness, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, readBackendTestExecutionReadiness, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
39
+ import { assessBackendPytestCollection, assessMissingBackendPytestScripts, assessPriorityOnlyBackendPytestModules, assertBackendTestExecutionReadinessFresh, buildBackendPytestAssetInventory, buildBackendTestItemEligibility, materializeBackendTestExecutionReadiness, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, readBackendTestExecutionReadiness, summarizeBackendTestExclusions, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
40
40
  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");
@@ -860,26 +870,49 @@ async function executeBackendTestPipeline(input, meta) {
860
870
  const correspondenceFactsPath = path.join(contractsDir, "backend-test-markdown-pytest-correspondence-initial.json");
861
871
  await writeFile(correspondenceFactsPath, JSON.stringify(correspondence.facts, null, 2), "utf8");
862
872
  outputs.push(`initialCorrespondence=${correspondenceReportPath}`, `initialCorrespondenceFacts=${correspondenceFactsPath}`);
863
- if (correspondence.facts.status === "FAIL" && facts.status === "PASS") {
864
- const correspondenceRepairPaths = Array.from(new Set(correspondence.facts.entries
865
- .filter((entry) => entry.status !== "EXACT_1_TO_1")
866
- .flatMap((entry) => [entry.expectedScript, ...entry.actualScripts])
867
- .filter((candidate) => candidate.startsWith("testcase/") && candidate.endsWith(".py")))).sort();
868
- if (correspondenceRepairPaths.length > 0) {
869
- facts.status = "REPAIRABLE";
870
- facts.repairEligible = true;
871
- facts.repairPaths = Array.from(new Set([...facts.repairPaths, ...correspondenceRepairPaths])).sort();
873
+ if (correspondence.facts.status === "FAIL") {
874
+ const repair = classifyBackendTestCorrespondenceRepair(correspondence.facts, backendLayout);
875
+ if (repair.markdownBlockingEntries.length > 0) {
876
+ facts.status = "BLOCKED";
877
+ facts.repairEligible = false;
878
+ facts.repairPaths = [];
872
879
  facts.findings.push({
873
- kind: "markdown-pytest-correspondence",
880
+ kind: "markdown-correspondence-contract",
874
881
  classification: "test-asset-defect",
875
- repairability: "repairable",
876
- detail: correspondence.facts.entries
877
- .filter((entry) => entry.status !== "EXACT_1_TO_1")
878
- .flatMap((entry) => entry.findings)
882
+ repairability: "blocked",
883
+ detail: repair.markdownBlockingEntries
884
+ .map((entry) => `${entry.caseId ?? "unknown Case"}: ${entry.reasonCodes.join(", ")}`)
879
885
  .join("; ")
880
- .slice(0, 12_000) || "Markdown-to-pytest correspondence is incomplete",
886
+ .slice(0, 12_000),
881
887
  });
882
888
  }
889
+ else if (repair.pytestRepairPaths.length > 0 &&
890
+ (facts.status === "PASS" || facts.status === "REPAIRABLE")) {
891
+ facts.status = "REPAIRABLE";
892
+ facts.repairEligible = true;
893
+ facts.repairPaths = Array.from(new Set([...facts.repairPaths, ...repair.pytestRepairPaths])).sort();
894
+ const repairEntries = correspondence.facts.entries.filter((entry) => repair.pytestRepairEntries.some((candidate) => candidate.caseId === entry.caseId));
895
+ for (const entry of repairEntries.slice(0, 40)) {
896
+ const classified = repair.pytestRepairEntries.find((candidate) => candidate.caseId === entry.caseId);
897
+ facts.findings.push({
898
+ kind: "markdown-pytest-correspondence",
899
+ classification: "test-asset-defect",
900
+ repairability: "repairable",
901
+ detail: entry.findings.join("; ").slice(0, 2_000) || "Markdown-to-pytest correspondence is incomplete",
902
+ ...(entry.caseId ? { caseId: entry.caseId } : {}),
903
+ reasonCodes: classified?.reasonCodes ?? [],
904
+ repairPaths: classified?.paths ?? [],
905
+ payloadExpectedPaths: Array.from(new Set([
906
+ ...entry.payloadAssessment.requiredPaths,
907
+ ...entry.payloadAssessment.allowedPaths,
908
+ ])).sort(),
909
+ payloadObservedPaths: entry.payloadAssessment.observedPaths,
910
+ payloadMissingPaths: entry.payloadAssessment.missingPaths,
911
+ payloadUnexpectedPaths: entry.payloadAssessment.unexpectedPaths,
912
+ payloadEnumMismatches: entry.payloadAssessment.enumMismatches,
913
+ });
914
+ }
915
+ }
883
916
  }
884
917
  }
885
918
  catch (error) {
@@ -902,6 +935,19 @@ async function executeBackendTestPipeline(input, meta) {
902
935
  collectedItemCount: facts.collectedItemCount,
903
936
  missingMappedScripts: facts.missingMappedScripts,
904
937
  repairPaths: facts.repairPaths,
938
+ repairFindings: facts.findings.map((finding) => ({
939
+ kind: finding.kind,
940
+ repairability: finding.repairability,
941
+ detail: finding.detail,
942
+ ...(finding.caseId ? { caseId: finding.caseId } : {}),
943
+ ...(finding.reasonCodes ? { reasonCodes: finding.reasonCodes } : {}),
944
+ ...(finding.repairPaths ? { repairPaths: finding.repairPaths } : {}),
945
+ ...(finding.payloadExpectedPaths ? { payloadExpectedPaths: finding.payloadExpectedPaths } : {}),
946
+ ...(finding.payloadObservedPaths ? { payloadObservedPaths: finding.payloadObservedPaths } : {}),
947
+ ...(finding.payloadMissingPaths ? { payloadMissingPaths: finding.payloadMissingPaths } : {}),
948
+ ...(finding.payloadUnexpectedPaths ? { payloadUnexpectedPaths: finding.payloadUnexpectedPaths } : {}),
949
+ ...(finding.payloadEnumMismatches ? { payloadEnumMismatches: finding.payloadEnumMismatches } : {}),
950
+ })),
905
951
  factsPath: "contracts/backend-test-pytest-collection-initial.json",
906
952
  }),
907
953
  stderr: "",
@@ -915,6 +961,7 @@ async function executeBackendTestPipeline(input, meta) {
915
961
  effective = await materializeEffectiveBackendPytestCollection({
916
962
  workspaceRoot: input.cwd,
917
963
  initial,
964
+ layout: backendLayout,
918
965
  });
919
966
  }
920
967
  else if (initial.status === "REPAIRABLE") {
@@ -974,6 +1021,7 @@ async function executeBackendTestPipeline(input, meta) {
974
1021
  workspaceRoot: input.cwd,
975
1022
  initial,
976
1023
  final: finalFacts,
1024
+ layout: backendLayout,
977
1025
  });
978
1026
  }
979
1027
  else {
@@ -999,6 +1047,7 @@ async function executeBackendTestPipeline(input, meta) {
999
1047
  })
1000
1048
  : { eligibleItemIds: effective.collectedItemIds, excludedItems: [] };
1001
1049
  const eligibilityInputHashes = Object.fromEntries((correspondence?.facts.inputFiles ?? []).map((item) => [item.path, item.sha256]));
1050
+ const exclusionSummary = summarizeBackendTestExclusions(eligibility.excludedItems);
1002
1051
  const eligibilityFactsPath = path.join(meta.runDir, "contracts", "backend-test-execution-eligibility.json");
1003
1052
  await mkdir(path.dirname(eligibilityFactsPath), { recursive: true });
1004
1053
  await writeFile(eligibilityFactsPath, `${JSON.stringify({
@@ -1009,6 +1058,9 @@ async function executeBackendTestPipeline(input, meta) {
1009
1058
  excludedItemCount: eligibility.excludedItems.length,
1010
1059
  eligibleItemIds: eligibility.eligibleItemIds,
1011
1060
  excludedItems: eligibility.excludedItems,
1061
+ exclusionSummary,
1062
+ pytestSpawned: false,
1063
+ businessTestBodyExecutedCount: 0,
1012
1064
  inputHashes: eligibilityInputHashes,
1013
1065
  }, null, 2)}\n`, "utf8");
1014
1066
  const eligibilityReportPath = await writeRunReport(meta.runDir, "backend-test-execution-eligibility.md", [
@@ -1021,6 +1073,10 @@ async function executeBackendTestPipeline(input, meta) {
1021
1073
  `- Collected items: ${effective.collectedItemIds.length}`,
1022
1074
  `- Eligible items: ${eligibility.eligibleItemIds.length}`,
1023
1075
  `- Excluded items: ${eligibility.excludedItems.length}`,
1076
+ `- Excluded Cases: ${exclusionSummary.excludedCaseCount}`,
1077
+ `- Exclusion categories: ${Object.entries(exclusionSummary.excludedByReason).map(([category, count]) => `${category}=${count}`).join("; ") || "none"}`,
1078
+ "- pytest spawned: false",
1079
+ "- Business test bodies executed: 0",
1024
1080
  "",
1025
1081
  "## Excluded Items",
1026
1082
  "",
@@ -1036,6 +1092,7 @@ async function executeBackendTestPipeline(input, meta) {
1036
1092
  scenarioParamRepairAttempt: scenarioFacts.repairAttempt,
1037
1093
  eligibility,
1038
1094
  eligibilityInputHashes,
1095
+ layout: backendLayout,
1039
1096
  });
1040
1097
  return {
1041
1098
  ok: true,
@@ -1048,6 +1105,9 @@ async function executeBackendTestPipeline(input, meta) {
1048
1105
  executionReadinessStatus: readiness.status,
1049
1106
  eligibleItemCount: readiness.eligibleItemIds.length,
1050
1107
  excludedItemCount: readiness.excludedItems.length,
1108
+ exclusionSummary: readiness.exclusionSummary,
1109
+ pytestSpawned: readiness.pytestSpawned,
1110
+ businessTestBodyExecutedCount: readiness.businessTestBodyExecutedCount,
1051
1111
  factsPath: "contracts/backend-test-pytest-collection-effective.json",
1052
1112
  readinessPath: "contracts/backend-test-execution-readiness.json",
1053
1113
  reportPath: artifacts.reportPath,
@@ -1059,7 +1119,7 @@ async function executeBackendTestPipeline(input, meta) {
1059
1119
  else if (pipeline === "markdown-traceability") {
1060
1120
  let report;
1061
1121
  try {
1062
- report = await validateBackendMarkdownTraceability(input.cwd);
1122
+ report = await validateBackendMarkdownTraceability(input.cwd, backendLayout);
1063
1123
  }
1064
1124
  catch (error) {
1065
1125
  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 +1204,7 @@ async function executeBackendTestPipeline(input, meta) {
1144
1204
  }
1145
1205
  else if (pipeline === "markdown-execute-html") {
1146
1206
  const executionReadiness = await readBackendTestExecutionReadiness(path.join(meta.runDir, "contracts", "backend-test-execution-readiness.json"));
1147
- await assertBackendTestExecutionReadinessFresh(input.cwd, executionReadiness);
1207
+ await assertBackendTestExecutionReadinessFresh(input.cwd, executionReadiness, backendLayout);
1148
1208
  const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd, backendLayout);
1149
1209
  let eligibleItemIds = executionReadiness.eligibleItemIds;
1150
1210
  // Plan D gap-fill executeScope=affected: when a gap plan exists in
@@ -1719,6 +1779,33 @@ async function executeBackendTestPipeline(input, meta) {
1719
1779
  }
1720
1780
  }
1721
1781
  async function executeBackendTestPipelineWithWriteGuard(input, meta) {
1782
+ if (meta.spec.backendTestWorkspaceControl === "filesystem-only") {
1783
+ const paths = input.task.writeSet ?? input.task.allowedPaths;
1784
+ let beforeSnapshot;
1785
+ try {
1786
+ beforeSnapshot = await captureWorkspaceWriteSnapshot({ rootCwd: input.cwd, paths });
1787
+ }
1788
+ catch (error) {
1789
+ return { ok: false, stdout: "", stderr: `workspace filesystem baseline unavailable: ${error instanceof Error ? error.message : String(error)}`, failureCategory: "write-guard", durationMs: 0 };
1790
+ }
1791
+ const result = await executeBackendTestPipeline(input, meta);
1792
+ try {
1793
+ const afterSnapshot = await captureWorkspaceWriteSnapshot({ rootCwd: input.cwd, paths });
1794
+ const guard = validateShellWriteGuard({
1795
+ changedPaths: diffWorkspaceWriteSnapshots(beforeSnapshot, afterSnapshot),
1796
+ writePolicy: input.task.writePolicy,
1797
+ writeSet: input.task.writeSet,
1798
+ allowedPaths: input.task.allowedPaths,
1799
+ forbiddenPaths: input.task.forbiddenPaths,
1800
+ });
1801
+ if (!guard.ok)
1802
+ return { ...result, ok: false, stderr: [result.stderr, `filesystem write guard failed: ${guard.violations.join(", ")}`].filter(Boolean).join("\n\n"), failureCategory: "write-guard" };
1803
+ return result;
1804
+ }
1805
+ catch (error) {
1806
+ 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" };
1807
+ }
1808
+ }
1722
1809
  let beforeStatus;
1723
1810
  try {
1724
1811
  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
+ }
@@ -8,6 +8,7 @@ import { buildTaskReadModel } from '../task/read-model.js';
8
8
  const SKIP_DIRS = new Set([
9
9
  '.git',
10
10
  '.harness',
11
+ '.scratch',
11
12
  'node_modules',
12
13
  'dist',
13
14
  'coverage',
@@ -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
  /**