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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +106 -116
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/executors/dag-pi-executor.js +69 -7
  4. package/dist/executors/pi-executor.js +4 -2
  5. package/dist/executors/pi-sdk-executor.js +46 -1
  6. package/dist/executors/pi-writer-tool-policy.js +57 -1
  7. package/dist/executors/shell-executor.js +42 -2
  8. package/dist/executors/workspace-write-snapshot.js +68 -0
  9. package/dist/infrastructure/harness/atomic-write.js +23 -0
  10. package/dist/task/config-types.js +10 -0
  11. package/dist/worker/console/chat/chat-event-store.js +75 -5
  12. package/dist/worker/console/chat/context-panel.js +7 -0
  13. package/dist/worker/console/chat/deferred-turn.js +12 -0
  14. package/dist/worker/console/chat/pi-runtime.js +331 -18
  15. package/dist/worker/console/chat/provider-error.js +98 -0
  16. package/dist/worker/console/chat/routes.js +389 -56
  17. package/dist/worker/console/chat/session-stats.js +179 -0
  18. package/dist/worker/console/chat/session-store.js +93 -63
  19. package/dist/worker/console/chat/todos.js +115 -0
  20. package/dist/worker/console/chat/tool-preview.js +42 -0
  21. package/dist/worker/console/chat/turn-process.js +20 -55
  22. package/dist/worker/console/chat/user-questions.js +266 -0
  23. package/dist/worker/console/chat/workspace-landing.js +79 -0
  24. package/dist/worker/console/native-directory-picker.js +13 -0
  25. package/dist/worker/console/routes.js +309 -1
  26. package/dist/worker/console/security.js +4 -4
  27. package/dist/worker/console/server.js +529 -186
  28. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-CTx1TrNa.js → abnfDiagram-N423BO3Z-Bs-CDAXM.js} +1 -1
  29. package/dist/worker/console/static/assets/{arc-B4i9cVxX.js → arc-CAkA3We3.js} +1 -1
  30. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-BAXE8_Rl.js → architectureDiagram-T3A2C74G-DEmI_zqr.js} +1 -1
  31. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-C5Xh59at.js → blockDiagram-VBNYF7ZC-uDHANebE.js} +1 -1
  32. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B_asOzAu.js → c4Diagram-5PPSVZJV-Bv97b4_B.js} +1 -1
  33. package/dist/worker/console/static/assets/channel-amxUpk7o.js +1 -0
  34. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-wRO_iRNc.js → chunk-2GRJ4B5K-shmQKsFz.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-ByOaSh_G.js → chunk-2Q5K7J3B-BAraNvcN.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DjLzudan.js → chunk-5RXB4S5H-BirhxUop.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-JCNGkXYT.js → chunk-5VM5RSS4-C9LgUJpR.js} +1 -1
  38. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BhrPXXMZ.js → chunk-6Q2QTUOP-DrU-s-t7.js} +1 -1
  39. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-TJ5JuEBT.js → chunk-GF5L2VYU-Tx-H_FA2.js} +1 -1
  40. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-VVthn_mD.js → chunk-JWPE2WC7-BjLtNFrR.js} +1 -1
  41. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-D60-Mny4.js → chunk-KBJHAD2P-UNLVwSsy.js} +1 -1
  42. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-zXv7aVye.js → chunk-RYQCIY6F-CBEUX8XD.js} +1 -1
  43. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-By9VQoi1.js → chunk-XXDRQBXY-WTL7IZOq.js} +1 -1
  44. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-bejwBVIz.js +1 -0
  45. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-bejwBVIz.js +1 -0
  46. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-C4zQLe-M.js → cose-bilkent-JH36ORCC-CkHG6W3s.js} +1 -1
  47. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-wh5N2ReN.js → cynefin-VYW2F7L2-BRzFqTaL.js} +1 -1
  48. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Dlu5wIqs.js → cynefinDiagram-MW4NZA55-2_CFp_i7.js} +1 -1
  49. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CrDgthc6.js → dagre-VZM6K2ZE-BJtsmdkE.js} +1 -1
  50. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-zN1clg27.js → diagram-7IWD3JNH-DNEFFo6w.js} +1 -1
  51. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BN-o7J7-.js → diagram-B4RE2ZJO-B_IWV7eo.js} +1 -1
  52. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-C8SuPt0d.js → diagram-LBJQPF4R-DvvRx2dz.js} +1 -1
  53. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BoizuHbZ.js → diagram-Q27KOJAE-CsibsWLg.js} +1 -1
  54. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BzUzXixG.js → diagram-UB23O5K3-les_djBQ.js} +1 -1
  55. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Ev3lA4Cf.js → ebnfDiagram-BXEA7PRR-BEm2n7qJ.js} +1 -1
  56. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-GALUzRbs.js → erDiagram-JOGREHBK-BI7vHHVm.js} +1 -1
  57. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CZJN65Sb.js → flowDiagram-UKHOOZJN-CUFCERrB.js} +1 -1
  58. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-JLB9OESl.js → ganttDiagram-PKOTCBZU-wxyLiGWt.js} +1 -1
  59. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CcvCr4GD.js → gitGraphDiagram-DS77QQ5N-Bt897K8Z.js} +1 -1
  60. package/dist/worker/console/static/assets/index-BHhUOTri.css +1 -0
  61. package/dist/worker/console/static/assets/index-DqJbO3-p.js +407 -0
  62. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-HG5z7aVg.js → infoDiagram-6WML65LV-DYNx_IPv.js} +1 -1
  63. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BHEu9chR.js → ishikawaDiagram-WSZJBQD7-CzpLNEu6.js} +1 -1
  64. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-Cp_Up8-a.js → journeyDiagram-NVQOT4AX-97f9owNU.js} +1 -1
  65. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-DvVeY-em.js → kanban-definition-27J2QSJJ-BxM9hyBn.js} +1 -1
  66. package/dist/worker/console/static/assets/{linear-aGmcjgTn.js → linear-ByuxcHvp.js} +1 -1
  67. package/dist/worker/console/static/assets/{mermaid.core-Biyo-tqA.js → mermaid.core-DwPGvpCv.js} +5 -5
  68. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-DyxY2pIb.js → mindmap-definition-FAOFIHXS-DCoqG9RT.js} +1 -1
  69. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CkdGuc50.js → pegDiagram-VL7TDLO6-BLSBmBMx.js} +1 -1
  70. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-NNQI9xVR.js → pieDiagram-7S7Q4E2Y-B0t_0oJW.js} +1 -1
  71. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-PAJkuz_G.js → quadrantDiagram-CIZ2JOQS-sTzDk7Nt.js} +1 -1
  72. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-ZwrQod0O.js → railroadDiagram-AXF67PYL-B3jRdATC.js} +1 -1
  73. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D3ZfrzM2.js → requirementDiagram-LRYGKXZP-D-cTtXEN.js} +1 -1
  74. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-DDGFmWk5.js → sankeyDiagram-W5VNT64P-B25igJRE.js} +1 -1
  75. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DO6mmXTA.js → sequenceDiagram-SI44F4Z6-jW_lM1_D.js} +1 -1
  76. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DMbCQiJt.js → sizeCapture-X5ZJPWSS-CrzTgsHE.js} +1 -1
  77. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Di_YUEfC.js → stateDiagram-OKZ733FA-B01MvlqE.js} +1 -1
  78. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BvFKEtm2.js +1 -0
  79. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-Bn2PphNJ.js → swimlanes-SLNWSIFB-YcF12FiU.js} +2 -2
  80. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DIB2eVg-.js +8 -0
  81. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DPh8pRsp.js → timeline-definition-Z64GVDOM-ClnRcg2s.js} +1 -1
  82. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-GMfl26pW.js → vennDiagram-T6HMQDX7-B6gr_z-q.js} +1 -1
  83. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DTlmVrmF.js → wardleyDiagram-T6FBY63Y-CvSgguGH.js} +1 -1
  84. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-YGBjKAS5.js → xychartDiagram-ELKLHX3M-23OtSZCb.js} +1 -1
  85. package/dist/worker/console/static/index.html +2 -2
  86. package/dist/worker/console/static-src/app/console-types.js +29 -4
  87. package/dist/worker/console/static-src/app/useConsoleShell.js +30 -12
  88. package/dist/worker/console/static-src/app/useOperatorActions.js +36 -6
  89. package/dist/worker/console/static-src/app/useRecoveryConsole.js +10 -11
  90. package/dist/worker/console/static-src/app/useRunProgress.js +2 -1
  91. package/dist/worker/console/static-src/app/useTaskWizard.js +2 -2
  92. package/dist/worker/console/static-src/night/useNightBoard.js +8 -5
  93. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +141 -8
  94. package/dist/worker/console/static-src/operator-chat/compaction-message.js +79 -0
  95. package/dist/worker/console/static-src/operator-chat/details-dag-actions.js +122 -0
  96. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  97. package/dist/worker/console/static-src/operator-chat/runtime-selection-labels.js +27 -0
  98. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +1 -2
  99. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +25 -0
  100. package/dist/worker/console/static-src/operator-chat/turn-submission.js +3 -2
  101. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +181 -87
  102. package/dist/worker/console/static-src/operator-chat/useChatStream.js +159 -44
  103. package/dist/worker/console/static-src/operator-chat/useChatThread.js +177 -8
  104. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +63 -9
  105. package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
  106. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -3
  107. package/dist/worker/console/static-src/shell/useWorkspaces.js +299 -0
  108. package/dist/worker/console/static-src/shell/workspace-route.js +339 -0
  109. package/dist/worker/console/workspace-context.js +234 -0
  110. package/dist/worker/console/workspace-registry.js +214 -0
  111. package/dist/worker/observe/server.js +4 -0
  112. package/dist/worker/observe/static/api.js +26 -6
  113. package/dist/worker/observe/static/app.js +18 -70
  114. package/dist/worker/observe/static/dag-node-purpose.d.ts +6 -0
  115. package/dist/worker/observe/static/dag-node-purpose.js +203 -0
  116. package/dist/worker/observe/static/index.html +1 -1
  117. package/dist/worker/observe/static/inspect-workspace.js +307 -0
  118. package/dist/worker/observe/static/operator-chrome.css +145 -20
  119. package/dist/worker/observe/static/operator-chrome.d.ts +20 -3
  120. package/dist/worker/observe/static/operator-chrome.js +330 -100
  121. package/dist/worker/observe/static/router.d.ts +46 -0
  122. package/dist/worker/observe/static/router.js +25 -1
  123. package/dist/worker/observe/static/state.d.ts +50 -0
  124. package/dist/worker/observe/static/state.js +174 -2
  125. package/dist/worker/observe/static/styles.css +348 -38
  126. package/dist/worker/observe/static/views/dag-graph.js +4 -2
  127. package/dist/worker/observe/static/views/dag-inspector.js +310 -156
  128. package/dist/worker/observe/static/views/dag.d.ts +13 -0
  129. package/dist/worker/observe/static/views/dag.js +6 -4
  130. package/dist/worker/observe/static/views/dashboard.js +6 -4
  131. package/dist/worker/observe/static/views/night.js +2 -2
  132. package/dist/worker/observe/static/views/pool.js +1 -0
  133. package/dist/worker/observe/static/views/run.js +3 -1
  134. package/dist/worker/observe/static/views/task.js +1 -1
  135. package/dist/workflows/dag/backend-test-pytest-collection.js +11 -10
  136. package/dist/workflows/dag/backend-test-writer-completeness.js +7 -12
  137. package/dist/workflows/dag/dynamic-runtime/map.js +1 -0
  138. package/dist/workflows/dag/init-hybrid.js +113 -47
  139. package/dist/workflows/dag/interrupt-request.js +3 -3
  140. package/dist/workflows/dag/lifecycle.js +3 -3
  141. package/dist/workflows/dag/types.js +20 -1
  142. package/docs/README.md +1 -0
  143. package/docs/operations/local-development-environment.md +21 -0
  144. package/docs/templates/agent-dag.schema.json +18 -0
  145. package/docs/templates/backend-test-dag.json +265 -237
  146. package/harness.json +3 -3
  147. package/package.json +3 -1
  148. package/dist/worker/console/static/assets/channel-Dj_4Moei.js +0 -1
  149. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C0YYAyCa.js +0 -1
  150. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C0YYAyCa.js +0 -1
  151. package/dist/worker/console/static/assets/index-CW88Fjbw.js +0 -406
  152. package/dist/worker/console/static/assets/index-DP0ZJl39.css +0 -1
  153. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DrdDvela.js +0 -1
  154. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DPkEAULl.js +0 -8
  155. package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +0 -73
  156. package/dist/worker/console/static-src/operator-chat/useActivityRailTransition.js +0 -59
@@ -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
+ }
@@ -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)
@@ -147,6 +147,8 @@ export const backendTestConfigSchema = z
147
147
  gapDoc: z.string().min(1).optional(),
148
148
  /** gap-fill pytest execution scope: only affected items (default) or all eligible. */
149
149
  executeScope: z.enum(["affected", "all"]).optional(),
150
+ /** Backend-test-only workspace mutation control; omitted means Git guard. */
151
+ workspaceControl: z.enum(["git", "filesystem-only"]).optional(),
150
152
  })
151
153
  .strict()
152
154
  .optional();
@@ -237,5 +239,13 @@ export const taskConfigSchema = z.preprocess((raw) => {
237
239
  if ("executor" in obj || "cursorModel" in obj) {
238
240
  throw new Error(CURSOR_TASK_FIELD_REMOVED_ERROR);
239
241
  }
242
+ const backendTest = obj.backendTest;
243
+ if (backendTest &&
244
+ typeof backendTest === "object" &&
245
+ !Array.isArray(backendTest) &&
246
+ "workspaceControl" in backendTest &&
247
+ obj.taskKind !== "backend-test") {
248
+ throw new Error("backendTest.workspaceControl is only valid when taskKind is backend-test");
249
+ }
240
250
  return raw;
241
251
  }, taskConfigObjectSchema);
@@ -231,6 +231,10 @@ export function projectCompactSnapshot(value) {
231
231
  Number.isFinite(record.tokensBefore)
232
232
  ? Math.max(0, Math.floor(record.tokensBefore))
233
233
  : undefined;
234
+ const estimatedTokensAfter = typeof record.estimatedTokensAfter === "number" &&
235
+ Number.isFinite(record.estimatedTokensAfter)
236
+ ? Math.max(0, Math.floor(record.estimatedTokensAfter))
237
+ : undefined;
234
238
  const details = record.details === undefined
235
239
  ? undefined
236
240
  : boundedRedactedJson(record.details).preview;
@@ -239,9 +243,16 @@ export function projectCompactSnapshot(value) {
239
243
  summary,
240
244
  ...(firstKeptEntryId ? { firstKeptEntryId } : {}),
241
245
  ...(tokensBefore !== undefined ? { tokensBefore } : {}),
246
+ ...(estimatedTokensAfter !== undefined ? { estimatedTokensAfter } : {}),
242
247
  ...(details ? { details } : {}),
243
248
  previewHash: createHash("sha256")
244
- .update(JSON.stringify({ summary, firstKeptEntryId, tokensBefore, details }))
249
+ .update(JSON.stringify({
250
+ summary,
251
+ firstKeptEntryId,
252
+ tokensBefore,
253
+ estimatedTokensAfter,
254
+ details,
255
+ }))
245
256
  .digest("hex"),
246
257
  };
247
258
  }
@@ -433,6 +444,20 @@ function safeSessionId(sessionId) {
433
444
  * Link durable Chat refs to the existing canonical operation store/event ring.
434
445
  * This owns no operation state and schedules no work; it only projects facts.
435
446
  */
447
+ /** R8 AC-R8-001: operationIds whose operation-ref has been overshadowed by
448
+ * a later operation-unlinked tombstone in the same ring. A tombstoned
449
+ * association must never be restored by recoverSession / replay / fork. */
450
+ function tombstonedOperationIds(events) {
451
+ const tombstones = new Set();
452
+ for (const event of events) {
453
+ if (event.kind !== "operation-unlinked")
454
+ continue;
455
+ const operationId = event.data.operationId;
456
+ if (typeof operationId === "string" && operationId)
457
+ tombstones.add(operationId);
458
+ }
459
+ return tombstones;
460
+ }
436
461
  export function createChatOperationLinker(options) {
437
462
  const followers = new Map();
438
463
  const keyFor = (sessionId, operationId) => `${sessionId}\u0000${operationId}`;
@@ -535,14 +560,37 @@ export function createChatOperationLinker(options) {
535
560
  }
536
561
  return {
537
562
  link,
563
+ async unlink(input) {
564
+ // Idempotent removal (AC-R7-001): drop the live follower and the
565
+ // persisted operation-ref event so the association is truly gone.
566
+ const key = keyFor(input.sessionId, input.operationId);
567
+ followers.get(key)?.();
568
+ followers.delete(key);
569
+ const ring = options.chatEvents.snapshot(input.sessionId);
570
+ const refs = ring.filter((event) => event.kind === "operation-ref" &&
571
+ event.data.operationId ===
572
+ input.operationId);
573
+ for (const event of refs) {
574
+ options.chatEvents.append(input.sessionId, event.turnId, {
575
+ kind: "operation-unlinked",
576
+ data: { operationId: input.operationId, unlinkedAt: new Date().toISOString() },
577
+ });
578
+ }
579
+ return refs.length > 0;
580
+ },
538
581
  async recoverSession(sessionId) {
539
- const refs = options.chatEvents
540
- .snapshot(sessionId)
541
- .filter((event) => event.kind === "operation-ref");
582
+ const snapshot = options.chatEvents.snapshot(sessionId);
583
+ // R8 AC-R8-001: a tombstoned (unlinked) operationId must NOT be
584
+ // re-linked on recovery — the persisted operation-unlinked event
585
+ // overshadows any earlier operation-ref in the same ring.
586
+ const tombstones = tombstonedOperationIds(snapshot);
587
+ const refs = snapshot.filter((event) => event.kind === "operation-ref");
542
588
  for (const event of refs) {
543
589
  const data = event.data;
544
590
  if (!data.operationId || !data.toolCallId)
545
591
  continue;
592
+ if (tombstones.has(data.operationId))
593
+ continue;
546
594
  await link({
547
595
  sessionId,
548
596
  turnId: event.turnId,
@@ -566,6 +614,19 @@ export function createChatOperationLinker(options) {
566
614
  },
567
615
  };
568
616
  }
617
+ /** R8 AC-R8-001 (fork): same tombstone scan as the linker — kept local to
618
+ * the store so forkContextRefs needs no linker dependency. */
619
+ function forkTombstonedOperationIds(events) {
620
+ const tombstones = new Set();
621
+ for (const event of events) {
622
+ if (event.kind !== "operation-unlinked")
623
+ continue;
624
+ const operationId = event.data.operationId;
625
+ if (typeof operationId === "string" && operationId)
626
+ tombstones.add(operationId);
627
+ }
628
+ return tombstones;
629
+ }
569
630
  export function createChatEventStore(options) {
570
631
  const maxEvents = options?.maxEventsPerSession ?? DEFAULT_MAX_EVENTS;
571
632
  const maxBytes = options?.maxBytesPerSession ?? DEFAULT_MAX_BYTES;
@@ -735,13 +796,22 @@ export function createChatEventStore(options) {
735
796
  "assessment",
736
797
  "taskKind-confirmed",
737
798
  "human-gate-card",
799
+ "compaction_end",
738
800
  "compact",
739
801
  "artifact-ref",
740
802
  ]);
741
803
  let copiedCount = 0;
742
- for (const event of this.snapshot(input.sourceSessionId)) {
804
+ const source = this.snapshot(input.sourceSessionId);
805
+ // R8 AC-R8-001: fork excludes tombstoned associations — an unlinked
806
+ // operationId must not leak its operation-ref into a forked session.
807
+ const tombstones = forkTombstonedOperationIds(source);
808
+ for (const event of source) {
743
809
  if (!allowed.has(event.kind))
744
810
  continue;
811
+ if ((event.kind === "operation-ref" ||
812
+ event.kind === "operation-status") &&
813
+ tombstones.has(event.data.operationId ?? ""))
814
+ continue;
745
815
  const data = structuredClone(event.data);
746
816
  if (event.kind === "human-gate-card") {
747
817
  delete data.humanGateToken;
@@ -38,6 +38,13 @@ export function projectTaskContext(input) {
38
38
  const previous = gates.get(card.cardId);
39
39
  gates.set(card.cardId, safeGate({ ...card, ...(previous?.dagSpine && !card.dagSpine ? { dagSpine: previous.dagSpine } : {}) }));
40
40
  }
41
+ else if (event.kind === "operation-unlinked") {
42
+ // R8 AC-001/AC-R8-001: tombstone — an operation-unlinked event appended
43
+ // after its operation-ref removes the card from the projection for good.
44
+ const operationId = typeof data.operationId === "string" ? data.operationId : "";
45
+ if (operationId)
46
+ view.operations = view.operations.filter((operation) => operation.operationId !== operationId);
47
+ }
41
48
  else if (event.kind === "operation-ref" || event.kind === "operation-status") {
42
49
  const operation = operationFromEventPayload(data);
43
50
  if (operation && (!activeTaskId || !operation.taskId || operation.taskId === activeTaskId))
@@ -81,11 +81,23 @@ export function projectDeferredTurnSummary(deferredTurn) {
81
81
  blockedByTurnId: deferredTurn.blockedByTurnId,
82
82
  state: deferredTurn.state,
83
83
  updatedAt: deferredTurn.updatedAt,
84
+ text: deferredTurn.text,
84
85
  textPreview: deferredTurn.text.replace(/\s+/g, " ").trim().slice(0, 120),
85
86
  imageCount: deferredTurn.images.length,
87
+ images: structuredClone(deferredTurn.images),
86
88
  ...(deferredTurn.acceptedTurnId
87
89
  ? { acceptedTurnId: deferredTurn.acceptedTurnId }
88
90
  : {}),
89
91
  ...(deferredTurn.lastError ? { lastError: deferredTurn.lastError } : {}),
90
92
  };
91
93
  }
94
+ export function parseDeferredTurns(value) {
95
+ if (!Array.isArray(value))
96
+ return [];
97
+ return value
98
+ .map((item) => parseDeferredTurn(item))
99
+ .filter((item) => item !== undefined);
100
+ }
101
+ export function projectDeferredTurnSummaries(deferredTurns) {
102
+ return deferredTurns.map((item) => projectDeferredTurnSummary(item));
103
+ }