@tea-agent/loop-agent 0.39.2 → 0.40.0-beta.1

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 (215) hide show
  1. package/CHANGELOG.md +133 -92
  2. package/dist/adapters/context-transfer/optional-pi-handoff.js +31 -0
  3. package/dist/adapters/context-transfer/pi-session.js +61 -0
  4. package/dist/build-stamp.json +3 -3
  5. package/dist/cli/command-definitions.js +12 -0
  6. package/dist/cli/program.js +23 -1
  7. package/dist/commands/dag-artifact.js +284 -0
  8. package/dist/commands/dag-context.js +184 -0
  9. package/dist/commands/dag-rerun.js +206 -1
  10. package/dist/executors/dag-pi-executor.js +108 -19
  11. package/dist/executors/pi-executor.js +4 -2
  12. package/dist/executors/pi-sdk-executor.js +49 -1
  13. package/dist/executors/pi-writer-tool-policy.js +57 -1
  14. package/dist/executors/shell-executor.js +42 -2
  15. package/dist/executors/workspace-write-snapshot.js +68 -0
  16. package/dist/{worker → infrastructure}/console/app-data.js +4 -0
  17. package/dist/infrastructure/console/artifact-revision-store.js +430 -0
  18. package/dist/infrastructure/console/context-export-store.js +160 -0
  19. package/dist/infrastructure/console/dir-lock.js +132 -0
  20. package/dist/infrastructure/harness/artifact-store.js +10 -1
  21. package/dist/infrastructure/harness/atomic-write.js +35 -2
  22. package/dist/shared/context-transfer/artifact-revision.js +172 -0
  23. package/dist/shared/context-transfer.js +418 -0
  24. package/dist/shared/operator/capabilities.js +14 -0
  25. package/dist/shared/operator/safe-run-summary.js +1 -0
  26. package/dist/shared/path-safety.js +93 -0
  27. package/dist/shared/preview.js +28 -4
  28. package/dist/task/config-types.js +10 -0
  29. package/dist/worker/console/chat/chat-event-store.js +77 -7
  30. package/dist/worker/console/chat/context-panel.js +7 -0
  31. package/dist/worker/console/chat/deferred-turn.js +12 -0
  32. package/dist/worker/console/chat/pi-runtime.js +331 -18
  33. package/dist/worker/console/chat/provider-error.js +98 -0
  34. package/dist/worker/console/chat/resource-preferences-store.js +1 -1
  35. package/dist/worker/console/chat/routes.js +389 -56
  36. package/dist/worker/console/chat/session-stats.js +179 -0
  37. package/dist/worker/console/chat/session-store.js +94 -64
  38. package/dist/worker/console/chat/todos.js +115 -0
  39. package/dist/worker/console/chat/tool-preview.js +42 -0
  40. package/dist/worker/console/chat/turn-process.js +20 -55
  41. package/dist/worker/console/chat/user-questions.js +266 -0
  42. package/dist/worker/console/chat/workspace-landing.js +79 -0
  43. package/dist/worker/console/console-update-runtime.js +1 -1
  44. package/dist/worker/console/context-transfer-diagnostics.js +198 -0
  45. package/dist/worker/console/dag-confirmation.js +1 -1
  46. package/dist/worker/console/dag-execution-receipt.js +1 -1
  47. package/dist/worker/console/doctor.js +1 -1
  48. package/dist/worker/console/draft-store.js +1 -1
  49. package/dist/worker/console/human-gate-token.js +1 -1
  50. package/dist/worker/console/index.js +3 -3
  51. package/dist/worker/console/interview/assessment.js +1 -1
  52. package/dist/worker/console/interview/session.js +1 -1
  53. package/dist/worker/console/native-directory-picker.js +13 -0
  54. package/dist/worker/console/operation-runner.js +45 -4
  55. package/dist/worker/console/operation-sse.js +1 -1
  56. package/dist/worker/console/operation-wait.js +1 -1
  57. package/dist/worker/console/operator-actions.js +104 -3
  58. package/dist/worker/console/operator-surface-health.js +1 -1
  59. package/dist/worker/console/operator-user-error.js +128 -0
  60. package/dist/worker/console/routes.js +927 -8
  61. package/dist/worker/console/security.js +39 -4
  62. package/dist/worker/console/server.js +532 -187
  63. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-CTx1TrNa.js → abnfDiagram-N423BO3Z-CpWvwARc.js} +1 -1
  64. package/dist/worker/console/static/assets/{arc-B4i9cVxX.js → arc-DctXgO-g.js} +1 -1
  65. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-BAXE8_Rl.js → architectureDiagram-T3A2C74G-D4mIkoQl.js} +1 -1
  66. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-C5Xh59at.js → blockDiagram-VBNYF7ZC-ClG0--KO.js} +1 -1
  67. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B_asOzAu.js → c4Diagram-5PPSVZJV-CwdLk78T.js} +1 -1
  68. package/dist/worker/console/static/assets/channel-C9VQZ-ru.js +1 -0
  69. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-wRO_iRNc.js → chunk-2GRJ4B5K-DKczcu-O.js} +1 -1
  70. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-ByOaSh_G.js → chunk-2Q5K7J3B-BV6F-vBI.js} +1 -1
  71. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DjLzudan.js → chunk-5RXB4S5H-DtojVnvA.js} +1 -1
  72. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-JCNGkXYT.js → chunk-5VM5RSS4-BjDQRGnO.js} +1 -1
  73. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BhrPXXMZ.js → chunk-6Q2QTUOP-I0fV6ngU.js} +1 -1
  74. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-TJ5JuEBT.js → chunk-GF5L2VYU-BM8C-mmC.js} +1 -1
  75. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-VVthn_mD.js → chunk-JWPE2WC7-BEK-a15e.js} +1 -1
  76. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-D60-Mny4.js → chunk-KBJHAD2P-CQsg_fSR.js} +1 -1
  77. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-zXv7aVye.js → chunk-RYQCIY6F-B-BcaUm3.js} +1 -1
  78. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-By9VQoi1.js → chunk-XXDRQBXY-D1Bu08fg.js} +1 -1
  79. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BsCkw5MF.js +1 -0
  80. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BsCkw5MF.js +1 -0
  81. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-C4zQLe-M.js → cose-bilkent-JH36ORCC-q8nwtxBR.js} +1 -1
  82. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-wh5N2ReN.js → cynefin-VYW2F7L2-CruhpURw.js} +1 -1
  83. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Dlu5wIqs.js → cynefinDiagram-MW4NZA55-CJne6iYF.js} +1 -1
  84. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CrDgthc6.js → dagre-VZM6K2ZE-CbeLvBU5.js} +1 -1
  85. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-zN1clg27.js → diagram-7IWD3JNH-DmCxH1Pe.js} +1 -1
  86. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BN-o7J7-.js → diagram-B4RE2ZJO-DJ5OYhtC.js} +1 -1
  87. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-C8SuPt0d.js → diagram-LBJQPF4R-B-UN-hln.js} +1 -1
  88. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BoizuHbZ.js → diagram-Q27KOJAE-CcEro8Ey.js} +1 -1
  89. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BzUzXixG.js → diagram-UB23O5K3-BBBzDden.js} +1 -1
  90. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Ev3lA4Cf.js → ebnfDiagram-BXEA7PRR-xKEyH-oo.js} +1 -1
  91. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-GALUzRbs.js → erDiagram-JOGREHBK-C7xl63VC.js} +1 -1
  92. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CZJN65Sb.js → flowDiagram-UKHOOZJN-nthQQ9nR.js} +1 -1
  93. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-JLB9OESl.js → ganttDiagram-PKOTCBZU-CWN5mE33.js} +1 -1
  94. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CcvCr4GD.js → gitGraphDiagram-DS77QQ5N-Wn86tQ6N.js} +1 -1
  95. package/dist/worker/console/static/assets/index-D-0NVyMk.css +1 -0
  96. package/dist/worker/console/static/assets/index-gwyOxQiS.js +407 -0
  97. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-HG5z7aVg.js → infoDiagram-6WML65LV-_WQymGtW.js} +1 -1
  98. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BHEu9chR.js → ishikawaDiagram-WSZJBQD7-COBeivBm.js} +1 -1
  99. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-Cp_Up8-a.js → journeyDiagram-NVQOT4AX-CwGOJl3Q.js} +1 -1
  100. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-DvVeY-em.js → kanban-definition-27J2QSJJ-Ci0CJ5Vi.js} +1 -1
  101. package/dist/worker/console/static/assets/{linear-aGmcjgTn.js → linear-CPjMq6vN.js} +1 -1
  102. package/dist/worker/console/static/assets/{mermaid.core-Biyo-tqA.js → mermaid.core-Z0wGoe0b.js} +5 -5
  103. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-DyxY2pIb.js → mindmap-definition-FAOFIHXS-dISked0d.js} +1 -1
  104. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CkdGuc50.js → pegDiagram-VL7TDLO6-BJxgof7Y.js} +1 -1
  105. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-NNQI9xVR.js → pieDiagram-7S7Q4E2Y-CIblU8eW.js} +1 -1
  106. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-PAJkuz_G.js → quadrantDiagram-CIZ2JOQS-vwYiqh8E.js} +1 -1
  107. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-ZwrQod0O.js → railroadDiagram-AXF67PYL-_iOsVeFm.js} +1 -1
  108. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D3ZfrzM2.js → requirementDiagram-LRYGKXZP-Dk0MLxO_.js} +1 -1
  109. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-DDGFmWk5.js → sankeyDiagram-W5VNT64P-Dx7HtLVW.js} +1 -1
  110. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DO6mmXTA.js → sequenceDiagram-SI44F4Z6-DRuWjlpL.js} +1 -1
  111. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DMbCQiJt.js → sizeCapture-X5ZJPWSS-Bhy9DQAA.js} +1 -1
  112. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Di_YUEfC.js → stateDiagram-OKZ733FA-DAx9twnU.js} +1 -1
  113. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Dmdf8cGy.js +1 -0
  114. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-Bn2PphNJ.js → swimlanes-SLNWSIFB-okMlvVrM.js} +2 -2
  115. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-W1IcAFff.js +8 -0
  116. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DPh8pRsp.js → timeline-definition-Z64GVDOM-DXqCKe37.js} +1 -1
  117. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-GMfl26pW.js → vennDiagram-T6HMQDX7-CeIUkwBh.js} +1 -1
  118. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DTlmVrmF.js → wardleyDiagram-T6FBY63Y-BP76BKTN.js} +1 -1
  119. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-YGBjKAS5.js → xychartDiagram-ELKLHX3M-BpVI0OpF.js} +1 -1
  120. package/dist/worker/console/static/index.html +7 -2
  121. package/dist/worker/console/static-src/app/console-types.js +29 -4
  122. package/dist/worker/console/static-src/app/useConsoleShell.js +30 -12
  123. package/dist/worker/console/static-src/app/useOperatorActions.js +36 -6
  124. package/dist/worker/console/static-src/app/useRecoveryConsole.js +10 -11
  125. package/dist/worker/console/static-src/app/useRunProgress.js +2 -1
  126. package/dist/worker/console/static-src/app/useTaskWizard.js +2 -2
  127. package/dist/worker/console/static-src/night/useNightBoard.js +8 -5
  128. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +141 -8
  129. package/dist/worker/console/static-src/operator-chat/compaction-message.js +79 -0
  130. package/dist/worker/console/static-src/operator-chat/details-dag-actions.js +122 -0
  131. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  132. package/dist/worker/console/static-src/operator-chat/runtime-selection-labels.js +27 -0
  133. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +1 -2
  134. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +25 -0
  135. package/dist/worker/console/static-src/operator-chat/turn-submission.js +3 -2
  136. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +181 -87
  137. package/dist/worker/console/static-src/operator-chat/useChatStream.js +159 -44
  138. package/dist/worker/console/static-src/operator-chat/useChatThread.js +177 -8
  139. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +63 -9
  140. package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
  141. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -3
  142. package/dist/worker/console/static-src/shell/useWorkspaces.js +299 -0
  143. package/dist/worker/console/static-src/shell/workspace-route.js +339 -0
  144. package/dist/worker/console/workspace-context.js +260 -0
  145. package/dist/worker/console/workspace-registry.js +214 -0
  146. package/dist/worker/continuation/worker-continuation.js +278 -0
  147. package/dist/worker/observe/health.js +1 -1
  148. package/dist/worker/observe/node-transparency.js +572 -0
  149. package/dist/worker/observe/routes.js +51 -1
  150. package/dist/worker/observe/server.js +4 -0
  151. package/dist/worker/observe/static/api.js +94 -10
  152. package/dist/worker/observe/static/app.js +18 -70
  153. package/dist/worker/observe/static/dag-context-reason-labels.d.ts +9 -0
  154. package/dist/worker/observe/static/dag-context-reason-labels.js +120 -0
  155. package/dist/worker/observe/static/index.html +1 -1
  156. package/dist/worker/observe/static/inspect-workspace.js +307 -0
  157. package/dist/worker/observe/static/operator-chrome.css +145 -20
  158. package/dist/worker/observe/static/operator-chrome.d.ts +20 -3
  159. package/dist/worker/observe/static/operator-chrome.js +330 -100
  160. package/dist/worker/observe/static/router.d.ts +46 -0
  161. package/dist/worker/observe/static/router.js +25 -1
  162. package/dist/worker/observe/static/state.d.ts +50 -0
  163. package/dist/worker/observe/static/state.js +175 -1
  164. package/dist/worker/observe/static/styles.css +1039 -128
  165. package/dist/worker/observe/static/views/dag-inspector.js +1218 -60
  166. package/dist/worker/observe/static/views/dag.js +2 -1
  167. package/dist/worker/observe/static/views/dashboard.js +6 -4
  168. package/dist/worker/observe/static/views/night.js +2 -2
  169. package/dist/worker/observe/static/views/pool.js +1 -0
  170. package/dist/worker/observe/static/views/run.js +3 -1
  171. package/dist/worker/observe/static/views/session-timeline.js +75 -10
  172. package/dist/worker/observe/static/views/task.js +1 -1
  173. package/dist/worker/pool/attempt-lease.js +97 -66
  174. package/dist/worker/pool/begin-attempt-with-lease.js +1 -0
  175. package/dist/worker/pool/reconcile.js +46 -1
  176. package/dist/worker/pool/run-store.js +2 -0
  177. package/dist/worker/pool/state-projection.js +2 -0
  178. package/dist/workflows/dag/artifact-bindings.js +149 -0
  179. package/dist/workflows/dag/artifact-revision-schema-registry.js +31 -0
  180. package/dist/workflows/dag/backend-test-pytest-collection.js +11 -10
  181. package/dist/workflows/dag/backend-test-writer-completeness.js +7 -12
  182. package/dist/workflows/dag/context-receipt.js +305 -0
  183. package/dist/workflows/dag/context-transfer/context-bundle.js +423 -0
  184. package/dist/workflows/dag/context-transfer/operator-actions.js +61 -0
  185. package/dist/workflows/dag/context-transfer/renderers.js +93 -0
  186. package/dist/workflows/dag/dynamic-runtime/map.js +1 -0
  187. package/dist/workflows/dag/init-hybrid.js +178 -47
  188. package/dist/workflows/dag/interrupt-request.js +3 -3
  189. package/dist/workflows/dag/lifecycle.js +3 -3
  190. package/dist/workflows/dag/node-execution.js +112 -27
  191. package/dist/workflows/dag/path-safety.js +1 -0
  192. package/dist/workflows/dag/prompt.js +29 -4
  193. package/dist/workflows/dag/rerun-plan.js +272 -0
  194. package/dist/workflows/dag/rerun-run.js +390 -12
  195. package/dist/workflows/dag/skill-snapshot.js +1 -0
  196. package/dist/workflows/dag/types.js +203 -8
  197. package/docs/README.md +1 -0
  198. package/docs/architecture/runtime-boundaries.md +6 -6
  199. package/docs/operations/local-development-environment.md +21 -0
  200. package/docs/templates/agent-dag.schema.json +50 -0
  201. package/docs/templates/backend-test-dag.json +265 -237
  202. package/harness.json +3 -3
  203. package/package.json +5 -1
  204. package/skills/loop-agent/references/command-reference.md +6 -0
  205. package/dist/worker/console/static/assets/channel-Dj_4Moei.js +0 -1
  206. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C0YYAyCa.js +0 -1
  207. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C0YYAyCa.js +0 -1
  208. package/dist/worker/console/static/assets/index-CW88Fjbw.js +0 -406
  209. package/dist/worker/console/static/assets/index-DP0ZJl39.css +0 -1
  210. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DrdDvela.js +0 -1
  211. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DPkEAULl.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
  214. /package/dist/{worker → infrastructure}/console/operation-store.js +0 -0
  215. /package/dist/{worker → infrastructure}/console/repo-fingerprint.js +0 -0
@@ -4,16 +4,18 @@ import { readFile } from "node:fs/promises";
4
4
  import { writeDagNodeJsonArtifact, writeTextArtifactFile, } from "../infrastructure/harness/artifact-store.js";
5
5
  import { executePiStep, resolvePiBackend, } from "./pi-executor.js";
6
6
  import { resolveDagPiExtensions, } from "./pi-extension-resolver.js";
7
- import { buildPiWriterToolPolicyContext, createPiWriterCustomTools, } from "./pi-writer-tool-policy.js";
7
+ import { buildPiWriterToolPolicyContext, createPiReaderCustomTools, createPiWriterCustomTools, } from "./pi-writer-tool-policy.js";
8
8
  import { cleanupPlaywrightCliDefaultSession, createPlaywrightCliTool, PI_COMMAND_CAPABILITY_REGISTRY, resolveCaseIdFromWriteSet, resolveEvidenceDirFromWriteSet, } from "./pi-playwright-cli-tool.js";
9
9
  import { dagCommandPolicyAllows, resolveDagCommandPolicy, } from "../workflows/dag/types.js";
10
10
  import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
11
11
  import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, recoverRootNulArtifact, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
12
+ import { captureWorkspaceWriteSnapshot, diffWorkspaceWriteSnapshots, } from "./workspace-write-snapshot.js";
12
13
  import { isTargetTemplateTransientRetryNode, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "../workflows/dag/retry-policy.js";
13
14
  import { assessBackendTestMdPlanCompleteness, assessBackendTestMdWriterCompleteness, assessBackendTestPytestPlanCompleteness, assessBackendTestPytestWriterCompleteness, assessBackendTestShardChildCompleteness, backendTestWriterProgressRoleForTask, classifyBackendTestWriterCompletenessFailure, isBackendTestCompletenessRetryCandidate, isBackendTestMdPlanTask, isBackendTestPytestPlanTask, isBackendTestShardChildTask, writeBackendTestWriterProgressArtifacts, } from "../workflows/dag/backend-test-writer-completeness.js";
14
15
  import { resolveBackendTestLayout } from "../workflows/dag/backend-test-layout.js";
15
- import { frontendTestLayoutFromSpec, } from "../workflows/dag/frontend-test-layout.js";
16
+ import { frontendTestLayoutFromSpec } from "../workflows/dag/frontend-test-layout.js";
16
17
  import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
18
+ import { writeEffectiveContextReceipt } from "../workflows/dag/context-receipt.js";
17
19
  /**
18
20
  * Writer classification for a length-stopped thinking-only attempt: the model
19
21
  * exhausted its output budget thinking but never issued a write/edit tool call
@@ -181,6 +183,9 @@ export function resolveDagPiStepName(task) {
181
183
  return resolvePiExecutorStep(resolveDagPiPersona(task));
182
184
  }
183
185
  export function resolveDagPiToolNames(task) {
186
+ if ((task.readSet?.length ?? 0) > 0) {
187
+ return isDagPiWriteTask(task) ? ["read", "edit", "write"] : ["read"];
188
+ }
184
189
  if (!isDagPiWriteTask(task))
185
190
  return [...DAG_PI_READONLY_TOOLS];
186
191
  const tools = [...DAG_PI_WRITE_TOOLS];
@@ -237,6 +242,9 @@ export function buildDagPiUserMessage(task, persona, step) {
237
242
  `You are executing hybrid DAG node "${task.id}" (role=${role}, piPersona=${persona}, piStep=${step}, writePolicy=${writePolicy}).`,
238
243
  "The system prompt contains the full DAG envelope: objective, constraints, upstream context, and task.",
239
244
  "Use read-only tools only. Do not edit, write, or commit repository files.",
245
+ (task.readSet?.length ?? 0) > 0
246
+ ? `Strict read set: ${task.readSet.join(", ")}. The read tool rejects every other repository path; do not search for substitutes.`
247
+ : undefined,
240
248
  "Return your conclusion as plain Markdown text suitable for downstream DAG nodes.",
241
249
  "Do not wrap the output in code fences and do not add conversational preamble.",
242
250
  ].join(" ");
@@ -446,9 +454,28 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
446
454
  };
447
455
  }
448
456
  let beforeStatus;
457
+ let beforeWorkspaceSnapshot;
449
458
  let beforePathFingerprints;
450
- /** tools-only: keep SDK path sandbox; skip git baseline + post-diff write-guard hard fail. */
451
- const skipGitWriteGuard = isWriteTask && input.task.writeGuardPolicy === "tools-only";
459
+ /** tools-only and filesystem-only keep the tool sandbox but skip Git baseline. */
460
+ const filesystemOnly = isWriteTask && meta.spec.backendTestWorkspaceControl === "filesystem-only";
461
+ const skipGitWriteGuard = isWriteTask && (input.task.writeGuardPolicy === "tools-only" || filesystemOnly);
462
+ if (isWriteTask && filesystemOnly) {
463
+ try {
464
+ beforeWorkspaceSnapshot = await captureWorkspaceWriteSnapshot({
465
+ rootCwd: input.cwd,
466
+ paths: input.task.writeSet ?? input.task.allowedPaths,
467
+ });
468
+ }
469
+ catch (error) {
470
+ return {
471
+ ok: false,
472
+ stdout: "",
473
+ stderr: `workspace filesystem baseline unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
474
+ failureCategory: "write-guard",
475
+ durationMs: Date.now() - started,
476
+ };
477
+ }
478
+ }
452
479
  if (isWriteTask && !skipGitWriteGuard) {
453
480
  try {
454
481
  beforeStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, { phase: "pi-writer-before" });
@@ -490,12 +517,24 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
490
517
  let playwrightToolContext;
491
518
  if (allowsPlaywrightCli && !isWriteTask) {
492
519
  return {
493
- ok: false, stdout: "", stderr: "pi command capability requires an explicit bounded write tool profile", failureCategory: "tool-policy", durationMs: Date.now() - started,
520
+ ok: false,
521
+ stdout: "",
522
+ stderr: "pi command capability requires an explicit bounded write tool profile",
523
+ failureCategory: "tool-policy",
524
+ durationMs: Date.now() - started,
494
525
  };
495
526
  }
496
- if (isWriteTask || allowsPlaywrightCli) {
527
+ const strictReadSet = (input.task.readSet?.length ?? 0) > 0;
528
+ if (isWriteTask || allowsPlaywrightCli || strictReadSet) {
497
529
  try {
498
530
  const customTools = [];
531
+ if (strictReadSet) {
532
+ const readContext = buildPiWriterToolPolicyContext({
533
+ repoRoot: input.cwd,
534
+ readSet: input.task.readSet,
535
+ });
536
+ customTools.push(...(await createPiReaderCustomTools(readContext)));
537
+ }
499
538
  if (isWriteTask) {
500
539
  const policyContext = buildPiWriterToolPolicyContext({
501
540
  repoRoot: input.cwd,
@@ -503,6 +542,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
503
542
  writeSet: input.task.writeSet,
504
543
  forbiddenPaths: input.task.forbiddenPaths,
505
544
  writePolicy: input.task.writePolicy,
545
+ readSet: input.task.readSet,
506
546
  });
507
547
  customTools.push(...(await createPiWriterCustomTools(policyContext)));
508
548
  }
@@ -607,19 +647,42 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
607
647
  const piExtensionPaths = piExtensionsResolution && resolvePiBackend() !== "cli-only"
608
648
  ? piExtensionsResolution.resolved.flatMap((entry) => entry.entryPaths)
609
649
  : undefined;
650
+ const modelConfig = resolveDagPiModelConfig(input.model, input.thinking ? { thinking: input.thinking } : undefined);
651
+ const toolNames = resolveDagPiToolNames(input.task);
652
+ const userMessage = buildDagPiUserMessage(input.task, persona, step);
653
+ await writeEffectiveContextReceipt({
654
+ runDir: meta.runDir,
655
+ runId: meta.runId,
656
+ nodeId: input.task.id,
657
+ attempt,
658
+ task: input.task,
659
+ spec: meta.spec,
660
+ repoRoot: input.cwd,
661
+ systemPrompt: input.prompt,
662
+ userMessage,
663
+ resolvedModel: `${modelConfig.provider}/${modelConfig.model}`,
664
+ resolvedSkills: input.resolvedSkills ?? [],
665
+ resolvedExtensions: piExtensionsResolution && resolvePiBackend() !== "cli-only"
666
+ ? piExtensionsResolution.resolved.map((entry) => entry.id)
667
+ : [],
668
+ activeTools: toolNames,
669
+ });
610
670
  result = await piStepFn({
611
671
  attachedFiles: [],
612
- modelConfig: resolveDagPiModelConfig(input.model, input.thinking ? { thinking: input.thinking } : undefined),
672
+ modelConfig,
613
673
  prompt: input.prompt,
614
674
  repoRoot: input.cwd,
615
675
  step,
616
- toolNames: resolveDagPiToolNames(input.task),
617
- userMessage: buildDagPiUserMessage(input.task, persona, step),
676
+ toolNames,
677
+ userMessage,
618
678
  sessionEventsPath: path.join(meta.runDir, input.task.id, "session-events.jsonl"),
619
679
  // Absolute max wall clock from adaptive liveness policy (defaults to 4h).
620
680
  timeoutMs: input.timeoutMs,
621
681
  stallTimeoutMs: input.stallTimeoutMs,
622
682
  abortGraceMs: input.abortGraceMs,
683
+ ...(input.task.contextBudget
684
+ ? { contextBudget: input.task.contextBudget }
685
+ : {}),
623
686
  ...(writerToolPolicy ? { writerToolPolicy } : {}),
624
687
  ...(piExtensionPaths && piExtensionPaths.length > 0
625
688
  ? { piExtensionPaths }
@@ -668,6 +731,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
668
731
  persona,
669
732
  step,
670
733
  });
734
+ if (input.task.id === "generate-backend-md-plan-pi" && result.assistantText?.trim()) {
735
+ await writeTextArtifactFile(path.join(meta.runDir, input.task.id, "plan.md"), result.assistantText.trim() + "\n");
736
+ }
671
737
  const mapped = mapPiResultToDagNodeResult(result, input.task.writerOutcomePolicy
672
738
  ? WRITER_OUTCOME_PROTOCOL_LINE
673
739
  : input.task.firstProtocolLine);
@@ -678,6 +744,27 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
678
744
  let writeGuardViolations = [];
679
745
  let changeManifestAfterStatus;
680
746
  let changeManifestChangedFiles;
747
+ let afterWorkspaceSnapshot;
748
+ if (filesystemOnly && beforeWorkspaceSnapshot) {
749
+ try {
750
+ afterWorkspaceSnapshot = await captureWorkspaceWriteSnapshot({
751
+ rootCwd: input.cwd,
752
+ paths: input.task.writeSet ?? input.task.allowedPaths,
753
+ });
754
+ changeManifestChangedFiles = diffWorkspaceWriteSnapshots(beforeWorkspaceSnapshot, afterWorkspaceSnapshot);
755
+ const guard = validateShellWriteGuardFromDiff({
756
+ changedFiles: changeManifestChangedFiles,
757
+ task: input.task,
758
+ concurrentSiblingWriteSets: meta.concurrentSiblingWriteSets,
759
+ });
760
+ writeGuardOk = guard.ok;
761
+ writeGuardViolations = guard.violations;
762
+ }
763
+ catch (error) {
764
+ writeGuardOk = false;
765
+ writeGuardViolations = [`workspace snapshot unavailable: ${error instanceof Error ? error.message : String(error)}`];
766
+ }
767
+ }
681
768
  if (beforeStatus !== undefined) {
682
769
  let recoveryEvidence;
683
770
  let removalPendingRecheck;
@@ -850,7 +937,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
850
937
  }
851
938
  }
852
939
  if (writeGuardOk &&
853
- beforeStatus !== undefined &&
940
+ (beforeStatus !== undefined || beforeWorkspaceSnapshot !== undefined) &&
854
941
  changeManifestChangedFiles !== undefined) {
855
942
  await persistWriterChangeManifest({
856
943
  runDir: meta.runDir,
@@ -860,13 +947,12 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
860
947
  approvalSourceNodeId: input.task.resolvedFinalWriteSetApproval?.approvalSourceNodeId,
861
948
  approvalDigest: input.task.resolvedFinalWriteSetApproval?.approvalDigest,
862
949
  changedFiles: changeManifestChangedFiles,
863
- beforeStatus,
864
- afterStatus: changeManifestAfterStatus ?? "",
950
+ beforeStatus: beforeStatus ?? JSON.stringify(beforeWorkspaceSnapshot),
951
+ afterStatus: changeManifestAfterStatus ?? JSON.stringify(afterWorkspaceSnapshot),
865
952
  });
866
953
  }
867
954
  let completenessFailure;
868
- if (writeGuardOk &&
869
- isBackendTestCompletenessRetryCandidate(input.task)) {
955
+ if (writeGuardOk && isBackendTestCompletenessRetryCandidate(input.task)) {
870
956
  try {
871
957
  const writerRole = backendTestWriterProgressRoleForTask(input.task.id);
872
958
  const backendLayout = resolveBackendTestLayout(meta.spec.backendTestLayout
@@ -879,7 +965,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
879
965
  const progress = isBackendTestShardChildTask(input.task)
880
966
  ? await assessBackendTestShardChildCompleteness({
881
967
  workspaceRoot: input.cwd,
882
- role: writerRole === "pytest-generate" ? "pytest-generate" : "md-generate",
968
+ role: writerRole === "pytest-generate"
969
+ ? "pytest-generate"
970
+ : "md-generate",
883
971
  writeSet: input.task.writeSet ?? [],
884
972
  })
885
973
  : isBackendTestMdPlanTask(input.task)
@@ -925,7 +1013,10 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
925
1013
  };
926
1014
  }
927
1015
  }
928
- if (mapped.ok && writeGuardOk && !writerOutcomeViolation && !completenessFailure) {
1016
+ if (mapped.ok &&
1017
+ writeGuardOk &&
1018
+ !writerOutcomeViolation &&
1019
+ !completenessFailure) {
929
1020
  return mapped;
930
1021
  }
931
1022
  const writerThinkingExhausted = isWriterThinkingExhausted(result, mapped, changeManifestChangedFiles);
@@ -1331,9 +1422,7 @@ async function persistWriterChangeManifest(input) {
1331
1422
  ...(input.approvalSourceNodeId
1332
1423
  ? { approvalSourceNodeId: input.approvalSourceNodeId }
1333
1424
  : {}),
1334
- ...(input.approvalDigest
1335
- ? { approvalDigest: input.approvalDigest }
1336
- : {}),
1425
+ ...(input.approvalDigest ? { approvalDigest: input.approvalDigest } : {}),
1337
1426
  changedFiles: input.changedFiles,
1338
1427
  beforeStatusSha256: createHash("sha256")
1339
1428
  .update(input.beforeStatus)
@@ -1005,12 +1005,14 @@ export function shouldRetryWithFallback(stderr, stdout) {
1005
1005
  return ["quota", "rate-limit", "unavailable", "network"].includes(category);
1006
1006
  }
1007
1007
  export function classifyPiFailure(input) {
1008
+ if (input.exitCode === 0 && input.assistantText.trim())
1009
+ return "success";
1010
+ if (input.stderr.toLowerCase().includes("context-budget-exhausted:"))
1011
+ return "context-budget-exhausted";
1008
1012
  if (input.timedOut)
1009
1013
  return "timeout";
1010
1014
  if (input.outputTooLarge)
1011
1015
  return "output-too-large";
1012
- if (input.exitCode === 0 && input.assistantText.trim())
1013
- return "success";
1014
1016
  const combined = `${input.stderr}\n${input.stdout}`.toLowerCase();
1015
1017
  // Prefer explicit rate-limit / HTTP 429 before generic "limit" quota phrases.
1016
1018
  // Chinese gateways often say "请求频率已达到限制" without English "rate limit".
@@ -212,6 +212,9 @@ async function createSdkSession(sdk, input, shared) {
212
212
  const model = input.provider && input.model
213
213
  ? await resolveModel(modelRuntime, input.provider, input.model)
214
214
  : undefined;
215
+ if (input.provider && input.model && !model) {
216
+ throw new Error(`pi SDK model unavailable: ${input.provider}/${input.model}; refusing to fall back to the session default`);
217
+ }
215
218
  // Inject rate-limit-tolerant auto-retry settings for the session lifecycle.
216
219
  // Prefer SettingsManager.inMemory so harness runs do not depend on ~/.pi or
217
220
  // project .pi/settings.json being present/trusted.
@@ -485,6 +488,9 @@ function extractSdkUsageSample(event) {
485
488
  const responseKey = responseKeyCandidates.find((value) => typeof value === "string" && value.length > 0);
486
489
  return responseKey ? { responseKey, tokens } : { tokens };
487
490
  }
491
+ function maxSdkContextTokens(samples) {
492
+ return samples.reduce((maximum, sample) => Math.max(maximum, sample.tokens), 0);
493
+ }
488
494
  function aggregateSdkTokenUsage(samples) {
489
495
  const identified = new Map();
490
496
  let anonymousMaximum = 0;
@@ -537,6 +543,9 @@ export async function executeSingleSdkAttempt(options) {
537
543
  const stdoutPreview = new BoundedTextPreview("stdout");
538
544
  const stdoutCollector = createPiJsonlStreamCollector();
539
545
  const usageSamples = [];
546
+ let observedTurns = 0;
547
+ let contextBudgetExhausted = false;
548
+ let contextBudgetDetail = "";
540
549
  // Writer-thinking-exhausted diagnosis evidence (AC-002). Collected in the
541
550
  // subscribe callback regardless of shouldPersistSessionEvent filtering, so a
542
551
  // length-stopped thinking-only attempt with no persisted write tool calls is
@@ -625,6 +634,7 @@ export async function executeSingleSdkAttempt(options) {
625
634
  });
626
635
  let resolveStall;
627
636
  let resolveSettlement;
637
+ let resolveContextBudget;
628
638
  let settled = false;
629
639
  let abortConfirmed = true;
630
640
  const persistRuntimeNote = (event) => {
@@ -655,6 +665,30 @@ export async function executeSingleSdkAttempt(options) {
655
665
  clearTimeout(stallHandle);
656
666
  stallHandle = setTimeout(() => resolveStall?.("stall"), stallTimeoutMs);
657
667
  };
668
+ const checkContextBudget = () => {
669
+ if (contextBudgetExhausted || !options.contextBudget)
670
+ return;
671
+ // Context protection follows the largest provider-reported response
672
+ // snapshot, not cumulative billing usage across turns. Summing repeated
673
+ // input contexts would abort healthy multi-turn sessions far below the
674
+ // model's actual context window.
675
+ const observedTokens = maxSdkContextTokens(usageSamples);
676
+ const turnExceeded = options.contextBudget.maxTurns !== undefined &&
677
+ observedTurns >= options.contextBudget.maxTurns;
678
+ const tokenExceeded = options.contextBudget.maxTokens !== undefined &&
679
+ observedTokens >= options.contextBudget.maxTokens;
680
+ if (!turnExceeded && !tokenExceeded)
681
+ return;
682
+ contextBudgetExhausted = true;
683
+ contextBudgetDetail = [
684
+ "context-budget-exhausted:",
685
+ `observedTurns=${observedTurns}`,
686
+ `observedTokens=${observedTokens}`,
687
+ `maxTurns=${options.contextBudget.maxTurns ?? "unbounded"}`,
688
+ `maxTokens=${options.contextBudget.maxTokens ?? "unbounded"}`,
689
+ ].join(" ");
690
+ resolveContextBudget?.("context-budget");
691
+ };
658
692
  unsubscribe = session.subscribe((event) => {
659
693
  if (event.type === "agent_settled" && !settled) {
660
694
  settled = true;
@@ -678,6 +712,9 @@ export async function executeSingleSdkAttempt(options) {
678
712
  const usageSample = extractSdkUsageSample(event);
679
713
  if (usageSample)
680
714
  usageSamples.push(usageSample);
715
+ if (event.type === "turn_end")
716
+ observedTurns += 1;
717
+ checkContextBudget();
681
718
  // Capture writer-thinking-exhausted evidence BEFORE the
682
719
  // shouldPersistSessionEvent filter: thinking_delta is intentionally
683
720
  // excluded from persisted JSONL, but must still count toward the
@@ -700,6 +737,11 @@ export async function executeSingleSdkAttempt(options) {
700
737
  const runTurn = async (userMessage) => {
701
738
  const settlementPromise = resetSettlement();
702
739
  const stallPromise = resetStallPromise();
740
+ const contextBudgetPromise = options.contextBudget
741
+ ? new Promise((resolve) => {
742
+ resolveContextBudget = resolve;
743
+ })
744
+ : null;
703
745
  const promptPromise = session.prompt(userMessage);
704
746
  // A settlement event is permitted to win the race while prompt() remains
705
747
  // pending. Observe a later rejection so it never becomes unhandled.
@@ -710,6 +752,7 @@ export async function executeSingleSdkAttempt(options) {
710
752
  settlementPromise,
711
753
  ...(timeoutPromise ? [timeoutPromise] : []),
712
754
  ...(stallPromise ? [stallPromise] : []),
755
+ ...(contextBudgetPromise ? [contextBudgetPromise] : []),
713
756
  ]);
714
757
  };
715
758
  const disposeSession = async () => {
@@ -737,7 +780,12 @@ export async function executeSingleSdkAttempt(options) {
737
780
  : options.userMessage;
738
781
  let continuesSent = 0;
739
782
  let raced = await runTurn(promptMessage);
740
- if (raced === "stall") {
783
+ if (raced === "context-budget") {
784
+ appendStderr(contextBudgetDetail);
785
+ abortConfirmed = await runSessionActionWithGrace("abort", () => session.abort());
786
+ await disposeSession();
787
+ }
788
+ else if (raced === "stall") {
741
789
  abortConfirmed = await runSessionActionWithGrace("abort", () => session.abort());
742
790
  const withinAbsoluteTimeout = timeoutMs <= 0 || Date.now() - startedAt < timeoutMs;
743
791
  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,
@@ -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
+ }
@@ -38,6 +38,8 @@ export function openConsoleAppData(options) {
38
38
  confirmations: path.join(repoNamespace, "confirmations"),
39
39
  dagReceipts: path.join(repoNamespace, "dag-receipts"),
40
40
  staged: path.join(repoNamespace, "staged"),
41
+ contextExports: path.join(repoNamespace, "context-exports"),
42
+ artifactRevisions: path.join(repoNamespace, "artifact-revisions"),
41
43
  assessments: path.join(repoNamespace, "assessments"),
42
44
  interviews: path.join(repoNamespace, "interviews"),
43
45
  chats: path.join(repoNamespace, "chats"),
@@ -51,6 +53,8 @@ export function openConsoleAppData(options) {
51
53
  paths.confirmations,
52
54
  paths.dagReceipts,
53
55
  paths.staged,
56
+ paths.contextExports,
57
+ paths.artifactRevisions,
54
58
  paths.assessments,
55
59
  paths.interviews,
56
60
  paths.chats,