@stigmer/runner 3.1.1 → 3.1.3

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 (166) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.d.ts +49 -8
  3. package/dist/activities/execute-cursor/attachment-resolver.js +83 -26
  4. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  5. package/dist/activities/execute-cursor/capture-flow.d.ts +18 -0
  6. package/dist/activities/execute-cursor/capture-flow.js +21 -0
  7. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  8. package/dist/activities/execute-cursor/cas-observations.d.ts +13 -0
  9. package/dist/activities/execute-cursor/cas-observations.js +39 -5
  10. package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
  11. package/dist/activities/execute-cursor/hook-script.js +19 -1
  12. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  13. package/dist/activities/execute-cursor/index.d.ts +5 -0
  14. package/dist/activities/execute-cursor/index.js +222 -160
  15. package/dist/activities/execute-cursor/index.js.map +1 -1
  16. package/dist/activities/execute-cursor/message-translator.js +10 -1
  17. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  18. package/dist/activities/execute-cursor/prompt-builder.d.ts +32 -7
  19. package/dist/activities/execute-cursor/prompt-builder.js +38 -20
  20. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  21. package/dist/activities/execute-cursor/skill-resolver.d.ts +1 -10
  22. package/dist/activities/execute-cursor/skill-resolver.js +3 -55
  23. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  24. package/dist/activities/execute-cursor/stigmer-link.d.ts +35 -0
  25. package/dist/activities/execute-cursor/stigmer-link.js +73 -0
  26. package/dist/activities/execute-cursor/stigmer-link.js.map +1 -0
  27. package/dist/activities/execute-cursor/turn-boundary.d.ts +108 -0
  28. package/dist/activities/execute-cursor/turn-boundary.js +163 -0
  29. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -0
  30. package/dist/activities/execute-cursor/workspace-provision.d.ts +3 -2
  31. package/dist/activities/execute-cursor/workspace-provision.js +5 -3
  32. package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/index.js +89 -5
  34. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  35. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +17 -0
  36. package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
  37. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  38. package/dist/activities/execute-deep-agent/setup.js +22 -16
  39. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  40. package/dist/activities/execute-deep-agent/streaming-v3.js +6 -1
  41. package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
  42. package/dist/activities/execute-deep-agent/streaming.d.ts +7 -0
  43. package/dist/activities/execute-deep-agent/streaming.js +5 -1
  44. package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
  45. package/dist/config.d.ts +7 -0
  46. package/dist/config.js +9 -0
  47. package/dist/config.js.map +1 -1
  48. package/dist/middleware/approval-gate.js +36 -6
  49. package/dist/middleware/approval-gate.js.map +1 -1
  50. package/dist/runner-manager.d.ts +2 -0
  51. package/dist/runner-manager.js +2 -1
  52. package/dist/runner-manager.js.map +1 -1
  53. package/dist/runner.d.ts +2 -0
  54. package/dist/runner.js +2 -1
  55. package/dist/runner.js.map +1 -1
  56. package/dist/shared/artifact-storage.d.ts +32 -0
  57. package/dist/shared/artifact-storage.js +74 -1
  58. package/dist/shared/artifact-storage.js.map +1 -1
  59. package/dist/shared/filereview/capture.d.ts +18 -1
  60. package/dist/shared/filereview/capture.js +105 -28
  61. package/dist/shared/filereview/capture.js.map +1 -1
  62. package/dist/shared/filereview/cas-substrate.d.ts +9 -0
  63. package/dist/shared/filereview/cas-substrate.js +9 -1
  64. package/dist/shared/filereview/cas-substrate.js.map +1 -1
  65. package/dist/shared/filereview/events.d.ts +16 -0
  66. package/dist/shared/filereview/events.js +37 -0
  67. package/dist/shared/filereview/events.js.map +1 -1
  68. package/dist/shared/filereview/git-substrate.d.ts +42 -0
  69. package/dist/shared/filereview/git-substrate.js +78 -0
  70. package/dist/shared/filereview/git-substrate.js.map +1 -1
  71. package/dist/shared/filereview/index.d.ts +3 -1
  72. package/dist/shared/filereview/index.js +3 -1
  73. package/dist/shared/filereview/index.js.map +1 -1
  74. package/dist/shared/filereview/line-counts.d.ts +48 -0
  75. package/dist/shared/filereview/line-counts.js +72 -0
  76. package/dist/shared/filereview/line-counts.js.map +1 -0
  77. package/dist/shared/filereview/progress.d.ts +96 -0
  78. package/dist/shared/filereview/progress.js +134 -0
  79. package/dist/shared/filereview/progress.js.map +1 -0
  80. package/dist/shared/implement-plan-prompt.d.ts +44 -0
  81. package/dist/shared/implement-plan-prompt.js +98 -0
  82. package/dist/shared/implement-plan-prompt.js.map +1 -0
  83. package/dist/shared/plan-artifact.d.ts +59 -11
  84. package/dist/shared/plan-artifact.js +144 -16
  85. package/dist/shared/plan-artifact.js.map +1 -1
  86. package/dist/shared/plan-mode-prompt.d.ts +32 -0
  87. package/dist/shared/plan-mode-prompt.js +56 -0
  88. package/dist/shared/plan-mode-prompt.js.map +1 -0
  89. package/dist/shared/status.d.ts +11 -0
  90. package/dist/shared/status.js +12 -2
  91. package/dist/shared/status.js.map +1 -1
  92. package/dist/shared/tool-row.d.ts +30 -1
  93. package/dist/shared/tool-row.js +53 -4
  94. package/dist/shared/tool-row.js.map +1 -1
  95. package/dist/shared/workspace/session-root.d.ts +35 -0
  96. package/dist/shared/workspace/session-root.js +53 -0
  97. package/dist/shared/workspace/session-root.js.map +1 -0
  98. package/dist/shared/workspace/workspace-lock.d.ts +86 -0
  99. package/dist/shared/workspace/workspace-lock.js +187 -0
  100. package/dist/shared/workspace/workspace-lock.js.map +1 -0
  101. package/package.json +8 -5
  102. package/src/__tests__/config.test.ts +8 -0
  103. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  104. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  105. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +161 -0
  106. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +132 -2
  107. package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +55 -0
  108. package/src/activities/execute-cursor/__tests__/delta-enricher.test.ts +68 -0
  109. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +28 -0
  110. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +63 -4
  111. package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
  112. package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +36 -2
  113. package/src/activities/execute-cursor/attachment-resolver.ts +116 -34
  114. package/src/activities/execute-cursor/capture-flow.ts +32 -0
  115. package/src/activities/execute-cursor/cas-observations.ts +41 -5
  116. package/src/activities/execute-cursor/hook-script.ts +19 -1
  117. package/src/activities/execute-cursor/index.ts +243 -178
  118. package/src/activities/execute-cursor/message-translator.ts +9 -1
  119. package/src/activities/execute-cursor/prompt-builder.ts +60 -22
  120. package/src/activities/execute-cursor/skill-resolver.ts +3 -59
  121. package/src/activities/execute-cursor/stigmer-link.ts +78 -0
  122. package/src/activities/execute-cursor/turn-boundary.ts +274 -0
  123. package/src/activities/execute-cursor/workspace-provision.ts +5 -3
  124. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +7 -2
  125. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +7 -2
  126. package/src/activities/execute-deep-agent/__tests__/index.test.ts +4 -1
  127. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +99 -0
  128. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +7 -2
  129. package/src/activities/execute-deep-agent/index.ts +104 -5
  130. package/src/activities/execute-deep-agent/prompt-builder.ts +39 -0
  131. package/src/activities/execute-deep-agent/setup.ts +25 -17
  132. package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
  133. package/src/activities/execute-deep-agent/streaming.ts +12 -1
  134. package/src/config.ts +17 -0
  135. package/src/middleware/__tests__/approval-gate.test.ts +77 -12
  136. package/src/middleware/approval-gate.ts +39 -7
  137. package/src/runner-manager.ts +6 -1
  138. package/src/runner.ts +6 -1
  139. package/src/shared/__tests__/artifact-storage.test.ts +111 -1
  140. package/src/shared/__tests__/implement-plan-prompt.test.ts +86 -0
  141. package/src/shared/__tests__/plan-artifact.test.ts +142 -9
  142. package/src/shared/__tests__/secret-leak-scan.test.ts +105 -0
  143. package/src/shared/__tests__/status.test.ts +7 -2
  144. package/src/shared/__tests__/tool-row.test.ts +136 -1
  145. package/src/shared/artifact-storage.ts +83 -1
  146. package/src/shared/filereview/__tests__/capture.test.ts +182 -1
  147. package/src/shared/filereview/__tests__/cas-substrate.test.ts +34 -0
  148. package/src/shared/filereview/__tests__/events.test.ts +105 -0
  149. package/src/shared/filereview/__tests__/line-counts.test.ts +73 -0
  150. package/src/shared/filereview/__tests__/progress.test.ts +236 -0
  151. package/src/shared/filereview/capture.ts +134 -30
  152. package/src/shared/filereview/cas-substrate.ts +17 -1
  153. package/src/shared/filereview/events.ts +50 -0
  154. package/src/shared/filereview/git-substrate.ts +108 -0
  155. package/src/shared/filereview/index.ts +22 -1
  156. package/src/shared/filereview/line-counts.ts +83 -0
  157. package/src/shared/filereview/progress.ts +186 -0
  158. package/src/shared/implement-plan-prompt.ts +104 -0
  159. package/src/shared/plan-artifact.ts +152 -16
  160. package/src/shared/plan-mode-prompt.ts +56 -0
  161. package/src/shared/status.ts +11 -1
  162. package/src/shared/tool-row.ts +58 -5
  163. package/src/shared/workspace/__tests__/session-root.test.ts +42 -0
  164. package/src/shared/workspace/__tests__/workspace-lock.test.ts +200 -0
  165. package/src/shared/workspace/session-root.ts +60 -0
  166. package/src/shared/workspace/workspace-lock.ts +241 -0
@@ -9,12 +9,13 @@
9
9
 
10
10
  import { describe, it, expect } from "vitest";
11
11
  import { create } from "@bufbuild/protobuf";
12
- import { ApprovalAction } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
12
+ import { ApprovalAction, InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
13
13
  import { PendingApprovalSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
14
14
 
15
15
  import { buildPrompt } from "../index.js";
16
16
  import type { BuildPromptInput } from "../index.js";
17
- import { buildReinvocationPrompt, formatToolApprovalProtocol, buildToolApprovalRuleFile } from "../prompt-builder.js";
17
+ import { buildReinvocationPrompt, formatInteractionModePrefix, formatImplementPlanSection, formatToolApprovalProtocol, buildToolApprovalRuleFile } from "../prompt-builder.js";
18
+ import { PLAN_MODE_DIRECTIVE } from "../../../shared/plan-mode-prompt.js";
18
19
  import type { AgentResolution, AgentResolutionReason } from "../session-lifecycle.js";
19
20
 
20
21
  const USER_MESSAGE = "What was the secret token I told you?";
@@ -250,3 +251,132 @@ describe("buildReinvocationPrompt", () => {
250
251
  expect(prompt).not.toContain("Carry them out now");
251
252
  });
252
253
  });
254
+
255
+ describe("formatInteractionModePrefix", () => {
256
+ it("wraps the shared plan-mode directive in the interaction_mode section", () => {
257
+ const prefix = formatInteractionModePrefix(InteractionMode.PLAN);
258
+
259
+ expect(prefix).toBeDefined();
260
+ expect(prefix!.startsWith("<interaction_mode>")).toBe(true);
261
+ expect(prefix!.endsWith("</interaction_mode>")).toBe(true);
262
+ expect(prefix).toContain(PLAN_MODE_DIRECTIVE);
263
+ });
264
+
265
+ it.each([
266
+ ["AGENT", InteractionMode.AGENT],
267
+ ["UNSPECIFIED", InteractionMode.UNSPECIFIED],
268
+ ["undefined", undefined],
269
+ ])("returns undefined for %s", (_label, mode) => {
270
+ expect(formatInteractionModePrefix(mode)).toBeUndefined();
271
+ });
272
+
273
+ it("injects the directive into a Plan-mode first prompt", () => {
274
+ const prompt = buildPrompt(
275
+ input({
276
+ resolution: resolution("local", "created_first_execution"),
277
+ interactionMode: InteractionMode.PLAN,
278
+ }),
279
+ );
280
+
281
+ expect(prompt).toContain("<interaction_mode>");
282
+ expect(prompt).toContain("your FINAL message IS the plan");
283
+ });
284
+
285
+ it("prefixes the directive on a resumed Plan-mode follow-up (mode is per-execution)", () => {
286
+ const prompt = buildPrompt(
287
+ input({
288
+ resolution: resolution("local", "resumed_successfully"),
289
+ interactionMode: InteractionMode.PLAN,
290
+ }),
291
+ );
292
+
293
+ expect(prompt.startsWith("<interaction_mode>")).toBe(true);
294
+ expect(prompt.endsWith(USER_MESSAGE)).toBe(true);
295
+ });
296
+
297
+ it("keeps a resumed Agent-mode follow-up as the raw user message", () => {
298
+ const prompt = buildPrompt(
299
+ input({
300
+ resolution: resolution("local", "resumed_successfully"),
301
+ interactionMode: InteractionMode.AGENT,
302
+ }),
303
+ );
304
+
305
+ expect(prompt).toBe(USER_MESSAGE);
306
+ });
307
+ });
308
+
309
+ describe("formatImplementPlanSection", () => {
310
+ const PLAN_PATH = ".stigmer/inputs/plan.md";
311
+
312
+ it("wraps the attached-plan directive when the plan is among the attachments", () => {
313
+ const section = formatImplementPlanSection(true, [PLAN_PATH, ".stigmer/inputs/data.csv"]);
314
+
315
+ expect(section).toBeDefined();
316
+ expect(section!.startsWith("<implement_plan>")).toBe(true);
317
+ expect(section!.endsWith("</implement_plan>")).toBe(true);
318
+ expect(section).toContain(`\`${PLAN_PATH}\``);
319
+ expect(section).toContain("APPROVED");
320
+ });
321
+
322
+ it("falls back to the conversation-plan directive when no plan attachment resolved", () => {
323
+ const section = formatImplementPlanSection(true, [".stigmer/inputs/data.csv"]);
324
+
325
+ expect(section).toBeDefined();
326
+ expect(section).not.toContain("plan.md");
327
+ expect(section).toContain("conversation above");
328
+ });
329
+
330
+ it("returns undefined for an ordinary (non-build) execution", () => {
331
+ expect(formatImplementPlanSection(false, [PLAN_PATH])).toBeUndefined();
332
+ expect(formatImplementPlanSection(undefined, [PLAN_PATH])).toBeUndefined();
333
+ });
334
+
335
+ it("carries the plan-derived progress-tracking instruction (Tier 3)", () => {
336
+ const section = formatImplementPlanSection(true, [PLAN_PATH]);
337
+
338
+ expect(section).toContain("to-do list");
339
+ expect(section).toContain("break the plan into");
340
+ });
341
+
342
+ it("injects the directive into a build-from-plan first prompt", () => {
343
+ const prompt = buildPrompt(
344
+ input({
345
+ resolution: resolution("local", "created_first_execution"),
346
+ buildFromPlan: true,
347
+ attachmentPaths: [PLAN_PATH],
348
+ }),
349
+ );
350
+
351
+ expect(prompt).toContain("<implement_plan>");
352
+ expect(prompt).toContain(`\`${PLAN_PATH}\``);
353
+ expect(prompt).toContain(USER_MESSAGE);
354
+ });
355
+
356
+ it("prefixes the directive on a resumed build turn (build_from_plan is per-execution)", () => {
357
+ // The common shape: the plan turn ran earlier in the session, so the
358
+ // build turn resumes the agent — the directive must still arrive.
359
+ const prompt = buildPrompt(
360
+ input({
361
+ resolution: resolution("local", "resumed_successfully"),
362
+ buildFromPlan: true,
363
+ attachmentPaths: [PLAN_PATH],
364
+ }),
365
+ );
366
+
367
+ expect(prompt.startsWith("<implement_plan>")).toBe(true);
368
+ expect(prompt.endsWith(USER_MESSAGE)).toBe(true);
369
+ });
370
+
371
+ it("keeps a resumed non-build follow-up as the raw user message", () => {
372
+ const prompt = buildPrompt(
373
+ input({
374
+ resolution: resolution("local", "resumed_successfully"),
375
+ buildFromPlan: false,
376
+ attachmentPaths: [PLAN_PATH],
377
+ }),
378
+ );
379
+
380
+ expect(prompt).toBe(USER_MESSAGE);
381
+ });
382
+ });
@@ -26,6 +26,7 @@ import {
26
26
  resetCasObservations,
27
27
  casObservationsDir,
28
28
  buildObservationStagingScript,
29
+ buildSecretClassifyScript,
29
30
  } from "../cas-observations.js";
30
31
  import { isSecretLikePath } from "../../../shared/filereview/secret-paths.js";
31
32
 
@@ -184,4 +185,58 @@ describe("cas-observations sidecar", () => {
184
185
  }
185
186
  });
186
187
  });
188
+
189
+ // The deny-gate (no-capture-substrate) classify-only script. It shares the
190
+ // classifier fragment with the staging script, so its verdict must equal
191
+ // isSecretLikePath byte-for-byte — a secret must never fall through the hook's
192
+ // deny-gate secret hard-block (DD-26 #2).
193
+ describe("buildSecretClassifyScript (deny-gate classify-only)", () => {
194
+ function runClassify(salient: string): string {
195
+ return execFileSync(process.execPath, ["-e", buildSecretClassifyScript()], {
196
+ input: salient,
197
+ })
198
+ .toString()
199
+ .trim();
200
+ }
201
+
202
+ it("classifies secrets byte-for-byte identically to isSecretLikePath (no staging)", () => {
203
+ const paths = [
204
+ ".env",
205
+ ".env.local",
206
+ "config/.env.production",
207
+ "id_rsa",
208
+ "server.pem",
209
+ "certs/key.pfx",
210
+ "terraform.tfstate",
211
+ ".aws/credentials",
212
+ "home/.ssh/known_hosts",
213
+ ".npmrc",
214
+ "secrets.yaml",
215
+ "/abs/path/.env",
216
+ "id_rsa.pub",
217
+ "app.log",
218
+ "src/index.ts",
219
+ "docs/notes.md",
220
+ "build/out.js",
221
+ "environment.ts",
222
+ ];
223
+ for (const p of paths) {
224
+ const expected = isSecretLikePath(p) ? "secret" : "ok";
225
+ expect(runClassify(p), `classification for ${p}`).toBe(expected);
226
+ }
227
+ });
228
+
229
+ it("fail-closes on empty input (classifies as secret), matching isSecretLikePath('')", () => {
230
+ expect(runClassify("")).toBe("secret");
231
+ expect(isSecretLikePath("")).toBe(true);
232
+ });
233
+
234
+ it("does not stage anything (pure classifier)", async () => {
235
+ const ws = tmp("obs-classify-nostage-");
236
+ writeFileSync(join(ws, ".env"), "API_KEY=xyz", "utf-8");
237
+ expect(runClassify(".env")).toBe("secret");
238
+ // No sidecar dir is created or written by the classify-only script.
239
+ expect(await readCasObservations(ws)).toEqual({ captured: [], secretPaths: [] });
240
+ });
241
+ });
187
242
  });
@@ -240,3 +240,71 @@ describe("DeltaEnricher finalize reconciliation", () => {
240
240
  expect(messages[1].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
241
241
  });
242
242
  });
243
+
244
+ describe("DeltaEnricher recovery-retry stream epilogue", () => {
245
+ // Regression for the zombie RUNNING row of aex_01kws27q1e2esvkqjpvectttxf:
246
+ // the recovery retry's bare stream loop buffered the tool-call-completed
247
+ // delta but never flushed it, so the row lacked the completedAt evidence
248
+ // finalize's sweep requires and persisted as RUNNING forever. The epilogue
249
+ // must applyEnrichments (flush the buffered evidence) BEFORE finalize.
250
+ it("applyEnrichments + finalize promotes a retry's buffered-completion RUNNING row", () => {
251
+ vi.spyOn(console, "log").mockImplementation(() => {});
252
+
253
+ const enricher = new DeltaEnricher();
254
+ const messages: AgentMessage[] = [
255
+ makeMessage([{ id: "tc-retry", name: "Shell", status: ToolCallStatus.TOOL_CALL_RUNNING }]),
256
+ ];
257
+
258
+ // The retry's onDelta buffered the completion, but the bare retry loop
259
+ // never applied it to the transcript.
260
+ enricher.processDelta({
261
+ type: "tool-call-completed",
262
+ callId: "tc-retry",
263
+ toolCall: { type: "shell" },
264
+ } as unknown as InteractionUpdate);
265
+
266
+ // Without the flush, finalize alone cannot promote (no evidence on the row).
267
+ const unflushed = makeMessage([
268
+ { id: "tc-retry", name: "Shell", status: ToolCallStatus.TOOL_CALL_RUNNING },
269
+ ]);
270
+ new DeltaEnricher().finalize([unflushed]);
271
+ expect(unflushed.toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_RUNNING);
272
+
273
+ // The epilogue sequence: flush the buffered evidence, then finalize.
274
+ enricher.applyEnrichments(messages);
275
+ enricher.finalize(messages);
276
+
277
+ const tc = messages[0].toolCalls[0];
278
+ expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
279
+ expect(tc.completedAt).toBeTruthy();
280
+ });
281
+
282
+ it("re-running applyEnrichments and finalize is idempotent", () => {
283
+ vi.spyOn(console, "log").mockImplementation(() => {});
284
+
285
+ const enricher = new DeltaEnricher();
286
+ const messages: AgentMessage[] = [
287
+ makeMessage([{ id: "tc-1", name: "Shell", status: ToolCallStatus.TOOL_CALL_RUNNING }]),
288
+ ];
289
+
290
+ enricher.processDelta({
291
+ type: "tool-call-completed",
292
+ callId: "tc-1",
293
+ toolCall: { type: "shell" },
294
+ } as unknown as InteractionUpdate);
295
+
296
+ // Primary epilogue ...
297
+ enricher.applyEnrichments(messages);
298
+ enricher.finalize(messages);
299
+ const stamped = messages[0].toolCalls[0].completedAt;
300
+
301
+ // ... and the retry epilogue re-runs both. The row must keep its original
302
+ // completion evidence and terminal status.
303
+ enricher.applyEnrichments(messages);
304
+ enricher.finalize(messages);
305
+
306
+ const tc = messages[0].toolCalls[0];
307
+ expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
308
+ expect(tc.completedAt).toBe(stamped);
309
+ });
310
+ });
@@ -1025,6 +1025,34 @@ describe("reconcileDeniedToolCalls — authoritative hook input overlay", () =>
1025
1025
  expect(tc.approvalContentDigest).not.toBe("");
1026
1026
  });
1027
1027
 
1028
+ it("does NOT overlay a secret-like write's content (DD-26 #2 defensive guard)", async () => {
1029
+ // Normally unreachable — the hook hard-blocks a secret write and records no
1030
+ // ledger entry — but if a hook classify failure fell one through with content,
1031
+ // that content must still never reach args / args_preview / the digest.
1032
+ const tc = toolCall({
1033
+ id: "c1",
1034
+ name: "write",
1035
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
1036
+ argsPreview: JSON.stringify({ path: ".env" }),
1037
+ });
1038
+ const messages = [aiMessageWith([tc])];
1039
+
1040
+ await reconcileDeniedToolCalls(
1041
+ messages,
1042
+ [{
1043
+ toolName: "Write",
1044
+ token: grantToken("write", ".env"),
1045
+ input: { file_path: ".env", content: "API_KEY=super-secret" },
1046
+ }],
1047
+ undefined,
1048
+ rootBackend(),
1049
+ );
1050
+
1051
+ expect(args(tc).content).toBeUndefined(); // content never overlaid
1052
+ expect(tc.argsPreview).not.toContain("super-secret");
1053
+ expect(tc.approvalContentDigest).toBe(""); // digest not stamped from secret content
1054
+ });
1055
+
1028
1056
  it("stamps the captured edit old/new strings and a digest", async () => {
1029
1057
  const tc = toolCall({
1030
1058
  id: "c1",
@@ -403,10 +403,11 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
403
403
  });
404
404
 
405
405
  it("keeps gating a write to a GITIGNORED path (the snapshot cannot revert it)", () => {
406
- // .env-style ignored paths are invisible to the git snapshot, so capture
407
- // mode must still gate them for explicit approval.
408
- const h = setup({ captureMode: true, gitignored: ["secret.txt"] });
409
- expect(h.decide(hookWrite("secret.txt")).permission).toBe("deny");
406
+ // A NON-secret gitignored path is invisible to the git snapshot, so capture
407
+ // mode must still gate it for explicit approval. (A secret-like gitignored
408
+ // write is hard-blocked instead see the deny-gate secret cases below.)
409
+ const h = setup({ captureMode: true, gitignored: ["ignored.txt"] });
410
+ expect(h.decide(hookWrite("ignored.txt")).permission).toBe("deny");
410
411
  expect(h.ledger().map((e) => e.toolName)).toContain("Write");
411
412
  // A non-ignored sibling still flows.
412
413
  expect(h.decide(hookWrite("normal.txt")).permission).toBe("allow");
@@ -418,6 +419,64 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
418
419
  });
419
420
  });
420
421
 
422
+ // Deny-gate secret hard-block (DD-26 #2): with no capture substrate for a write
423
+ // (capture off — the classic deny-gate — or captureIgnored off in a git-no-
424
+ // storage workspace) a secret-like WRITE must NOT surface its content for
425
+ // approval. The hook hard-blocks it with the security message and records NO
426
+ // ledger entry, so it never becomes an approvable WAITING row; a non-secret
427
+ // write still deny-gates, and a delete (content-less) stays gated.
428
+ describe("deny-gate secret hard-block (DD-26 #2)", () => {
429
+ it("hard-blocks a secret-like write and records NO ledger entry", () => {
430
+ const h = setup({}); // captureMode off — the classic deny-gate
431
+ const dec = h.decide(hookWrite(".env", "API_KEY=abc"));
432
+ expect(dec.permission).toBe("deny");
433
+ expect(dec.raw).toContain("blocked for security");
434
+ // SECRET_BLOCKED, not APPROVAL_REQUIRED: the model is told to move on.
435
+ expect(dec.raw.toLowerCase()).toContain("nothing was written");
436
+ expect(dec.raw).not.toContain("submitted to the user for approval");
437
+ expect(h.ledger()).toEqual([]); // never recorded → never approvable
438
+ });
439
+
440
+ it("hard-blocks a secret-like edit (path-fragment match)", () => {
441
+ const h = setup({});
442
+ const dec = h.decide(hookEdit(".ssh/id_rsa"));
443
+ expect(dec.permission).toBe("deny");
444
+ expect(dec.raw).toContain("blocked for security");
445
+ expect(h.ledger()).toEqual([]);
446
+ });
447
+
448
+ it("still deny-gates a NON-secret write and records its content for approval", () => {
449
+ const h = setup({});
450
+ const dec = h.decide(hookWrite("notes.md", "hello"));
451
+ expect(dec.permission).toBe("deny");
452
+ expect(dec.raw).toContain("submitted to the user for approval"); // APPROVAL_REQUIRED
453
+ expect(h.ledger().map((e) => e.toolName)).toContain("Write");
454
+ });
455
+
456
+ it("a write-category lease does NOT bypass the secret block", () => {
457
+ const h = setup({ leasedCategories: ["write"] });
458
+ expect(h.decide(hookWrite("notes.md", "x")).permission).toBe("allow"); // non-secret rides the lease
459
+ const dec = h.decide(hookWrite(".env", "SECRET")); // secret is still hard-blocked
460
+ expect(dec.permission).toBe("deny");
461
+ expect(dec.raw).toContain("blocked for security");
462
+ expect(h.ledger()).toEqual([]);
463
+ });
464
+
465
+ it("hard-blocks a secret write in a git workspace with captureIgnored off (no storage)", () => {
466
+ const h = setup({ captureMode: true, captureIgnored: false, gitignored: [".env"] });
467
+ const dec = h.decide(hookWrite(".env", "API_KEY=abc"));
468
+ expect(dec.permission).toBe("deny");
469
+ expect(dec.raw).toContain("blocked for security");
470
+ expect(h.ledger()).toEqual([]);
471
+ });
472
+
473
+ it("does NOT hard-block a secret DELETE (no content; stays deny-gated)", () => {
474
+ const h = setup({});
475
+ expect(h.decide(hookDelete(".env")).permission).toBe("deny");
476
+ expect(h.ledger().map((e) => e.toolName)).toContain("Delete");
477
+ });
478
+ });
479
+
421
480
  // CAS parity (DD-18): with captureIgnored on, a non-secret gitignored write no
422
481
  // longer stays on the deny-gate — the hook stages its pre-turn bytes into the
423
482
  // cas-observations sidecar and ALLOWS it (apply-then-review), while a secret-
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Tests for the extracted turn boundary (turn-boundary.ts) — the post-run
3
+ * pipeline shared by the primary path and the recovery retries.
4
+ *
5
+ * The load-bearing scenario is the recovery-retry sequence that motivated the
6
+ * extraction (production case aex_01kws27q1e2esvkqjpvectttxf): the primary
7
+ * boundary runs against an untouched tree (the resumed agent errored before
8
+ * doing anything), the retry agent then edits files, and the boundary is
9
+ * re-entered — the candidate must be authored exactly once and the turn must
10
+ * pause for review. Runs against a REAL temp git repo with in-memory
11
+ * transcript + status protos, mirroring capture-flow.test.ts.
12
+ */
13
+
14
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
15
+ import { execFile } from "node:child_process";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { promisify } from "node:util";
19
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
20
+ import { create } from "@bufbuild/protobuf";
21
+ import {
22
+ AgentMessageSchema,
23
+ ToolCallSchema,
24
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
25
+ import type { AgentMessage } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
26
+ import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
27
+ import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
28
+ import {
29
+ FileReviewEventType,
30
+ MessageType,
31
+ ToolCallStatus,
32
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
33
+ import { captureBaselineToLedger } from "../capture-flow.js";
34
+ import { denialLedgerPath } from "../approval-state.js";
35
+ import { toolCallIdentityToken } from "../message-translator.js";
36
+ import { runTurnBoundary, type TurnBoundaryOptions } from "../turn-boundary.js";
37
+
38
+ const execFileAsync = promisify(execFile);
39
+ const EXEC_ID = "exec-boundary-1";
40
+ const CHANGE_SET_ID = `${EXEC_ID}:0`;
41
+
42
+ let repo: string;
43
+ let hitlDir: string;
44
+
45
+ async function git(args: string[]): Promise<void> {
46
+ await execFileAsync("git", args, { cwd: repo });
47
+ }
48
+ async function write(rel: string, content: string): Promise<void> {
49
+ await writeFile(join(repo, rel), content, "utf-8");
50
+ }
51
+
52
+ function newStatus(): AgentExecutionStatus {
53
+ return create(AgentExecutionStatusSchema, {});
54
+ }
55
+
56
+ /** A streamed (COMPLETED) file-edit tool call, as the SDK would have recorded. */
57
+ function streamedEdit(id: string, path: string, content: string): AgentMessage {
58
+ return create(AgentMessageSchema, {
59
+ type: MessageType.MESSAGE_AI,
60
+ toolCalls: [
61
+ create(ToolCallSchema, {
62
+ id,
63
+ name: "edit",
64
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
65
+ args: { path, content },
66
+ }),
67
+ ],
68
+ });
69
+ }
70
+
71
+ /** Boundary options for this repo's turn; overrides layer the per-test shape. */
72
+ function boundaryOpts(
73
+ status: AgentExecutionStatus,
74
+ baselineTree: string,
75
+ overrides?: Partial<TurnBoundaryOptions>,
76
+ ): TurnBoundaryOptions {
77
+ return {
78
+ status,
79
+ executionId: EXEC_ID,
80
+ changeSetId: CHANGE_SET_ID,
81
+ hitlDir,
82
+ captureMode: true,
83
+ baselineTree,
84
+ primaryWorkspaceDir: repo,
85
+ gitWorkspace: true,
86
+ turnStartMessageIndex: 0,
87
+ approvalGrants: undefined,
88
+ globalBypass: false,
89
+ seededSubAgents: [],
90
+ artifactStorage: undefined,
91
+ mergedPolicies: new Map(),
92
+ ...overrides,
93
+ };
94
+ }
95
+
96
+ function candidateEvents(status: AgentExecutionStatus) {
97
+ return (status.fileReviewEventStream?.events ?? []).filter(
98
+ (e) => e.eventType === FileReviewEventType.CANDIDATE_CAPTURED,
99
+ );
100
+ }
101
+
102
+ beforeEach(async () => {
103
+ repo = await mkdtemp(join(tmpdir(), "stigmer-boundary-repo-"));
104
+ hitlDir = await mkdtemp(join(tmpdir(), "stigmer-boundary-hitl-"));
105
+ await git(["init", "-q"]);
106
+ await git(["config", "user.email", "t@t.local"]);
107
+ await git(["config", "user.name", "t"]);
108
+ await write("notes.md", "original notes\n");
109
+ await git(["add", "-A"]);
110
+ await git(["commit", "-q", "-m", "initial"]);
111
+ });
112
+
113
+ afterEach(async () => {
114
+ await rm(repo, { recursive: true, force: true });
115
+ await rm(hitlDir, { recursive: true, force: true });
116
+ });
117
+
118
+ describe("runTurnBoundary", () => {
119
+ it("authors a candidate, stamps the row, and pauses when the turn edited files", async () => {
120
+ const status = newStatus();
121
+ const baseline = await captureBaselineToLedger({
122
+ status,
123
+ gitRoot: repo,
124
+ executionId: EXEC_ID,
125
+ changeSetId: CHANGE_SET_ID,
126
+ });
127
+
128
+ await write("notes.md", "original notes\n\n## TODO\n- ship\n");
129
+ status.messages.push(
130
+ streamedEdit("tc-1", "notes.md", "original notes\n\n## TODO\n- ship\n"),
131
+ );
132
+
133
+ const result = await runTurnBoundary(boundaryOpts(status, baseline));
134
+
135
+ expect(result.waiting).toBe(true);
136
+ expect(result.capturedChangeCount).toBe(1);
137
+ expect(result.deniedToolCallCount).toBe(0);
138
+ expect(candidateEvents(status)).toHaveLength(1);
139
+ // The streamed row is stamped with the change set id (observational record
140
+ // anchoring the decision surface).
141
+ expect(status.messages[0].toolCalls[0].fileChangeSetId).toBe(CHANGE_SET_ID);
142
+ });
143
+
144
+ it("reports a clean turn when nothing changed (no candidate, no pause)", async () => {
145
+ const status = newStatus();
146
+ const baseline = await captureBaselineToLedger({
147
+ status,
148
+ gitRoot: repo,
149
+ executionId: EXEC_ID,
150
+ changeSetId: CHANGE_SET_ID,
151
+ });
152
+
153
+ const result = await runTurnBoundary(boundaryOpts(status, baseline));
154
+
155
+ expect(result.waiting).toBe(false);
156
+ expect(result.capturedChangeCount).toBe(0);
157
+ expect(result.deniedToolCallCount).toBe(0);
158
+ expect(candidateEvents(status)).toHaveLength(0);
159
+ });
160
+
161
+ it("skips capture when no baseline was authored this turn", async () => {
162
+ const status = newStatus();
163
+ await write("notes.md", "edited without a baseline\n");
164
+
165
+ const result = await runTurnBoundary(
166
+ boundaryOpts(status, "", { baselineTree: undefined }),
167
+ );
168
+
169
+ expect(result.waiting).toBe(false);
170
+ expect(result.capturedChangeCount).toBe(0);
171
+ expect(candidateEvents(status)).toHaveLength(0);
172
+ });
173
+
174
+ it("re-entered after a no-op primary call, authors the candidate exactly once (recovery-retry sequence)", async () => {
175
+ const status = newStatus();
176
+ const baseline = await captureBaselineToLedger({
177
+ status,
178
+ gitRoot: repo,
179
+ executionId: EXEC_ID,
180
+ changeSetId: CHANGE_SET_ID,
181
+ });
182
+
183
+ // Primary boundary: the resumed agent errored before touching anything —
184
+ // the tree is at baseline, no candidate is authored, the turn looks clean.
185
+ const primary = await runTurnBoundary(boundaryOpts(status, baseline));
186
+ expect(primary.waiting).toBe(false);
187
+ expect(candidateEvents(status)).toHaveLength(0);
188
+
189
+ // Recovery retry: a fresh agent re-ran the prompt and created the file.
190
+ await write("notes.md", "original notes\n\nretry made this edit\n");
191
+ status.messages.push(
192
+ streamedEdit("tc-retry", "notes.md", "original notes\n\nretry made this edit\n"),
193
+ );
194
+
195
+ // Boundary re-entry: the retry's edit reaches the ledger and arms the gate.
196
+ const retry = await runTurnBoundary(boundaryOpts(status, baseline));
197
+ expect(retry.waiting).toBe(true);
198
+ expect(retry.capturedChangeCount).toBe(1);
199
+ expect(candidateEvents(status)).toHaveLength(1);
200
+ expect(status.messages[0].toolCalls[0].fileChangeSetId).toBe(CHANGE_SET_ID);
201
+ });
202
+
203
+ it("surfaces a hook denial as a WAITING_APPROVAL gate and pauses", async () => {
204
+ const status = newStatus();
205
+ const baseline = await captureBaselineToLedger({
206
+ status,
207
+ gitRoot: repo,
208
+ executionId: EXEC_ID,
209
+ changeSetId: CHANGE_SET_ID,
210
+ });
211
+
212
+ // The hook gated a shell command mid-turn; the streamed call is still
213
+ // RUNNING (Cursor reported the deny to the model, not a completion).
214
+ const shellCall = create(ToolCallSchema, {
215
+ id: "tc-shell",
216
+ name: "shell",
217
+ status: ToolCallStatus.TOOL_CALL_RUNNING,
218
+ args: { command: "rm -rf build" },
219
+ });
220
+ status.messages.push(
221
+ create(AgentMessageSchema, {
222
+ type: MessageType.MESSAGE_AI,
223
+ content: "cleaning the build dir",
224
+ toolCalls: [shellCall],
225
+ }),
226
+ );
227
+ await writeFile(
228
+ denialLedgerPath(hitlDir),
229
+ JSON.stringify({ toolName: "shell", token: toolCallIdentityToken(shellCall) }) + "\n",
230
+ "utf-8",
231
+ );
232
+
233
+ const result = await runTurnBoundary(boundaryOpts(status, baseline));
234
+
235
+ expect(result.waiting).toBe(true);
236
+ expect(result.deniedToolCallCount).toBe(1);
237
+ expect(result.capturedChangeCount).toBe(0);
238
+ expect(shellCall.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
239
+ });
240
+
241
+ it("waits for the denial-stop cancel to settle before reading the ledger", async () => {
242
+ const status = newStatus();
243
+ const baseline = await captureBaselineToLedger({
244
+ status,
245
+ gitRoot: repo,
246
+ executionId: EXEC_ID,
247
+ changeSetId: CHANGE_SET_ID,
248
+ });
249
+
250
+ const shellCall = create(ToolCallSchema, {
251
+ id: "tc-late",
252
+ name: "shell",
253
+ status: ToolCallStatus.TOOL_CALL_RUNNING,
254
+ args: { command: "curl example.com" },
255
+ });
256
+ status.messages.push(
257
+ create(AgentMessageSchema, {
258
+ type: MessageType.MESSAGE_AI,
259
+ toolCalls: [shellCall],
260
+ }),
261
+ );
262
+
263
+ // The denial lands only while the cancel is settling — a premature ledger
264
+ // read would miss it and leave the row RUNNING forever.
265
+ const denialCancelSettled = (async () => {
266
+ await writeFile(
267
+ denialLedgerPath(hitlDir),
268
+ JSON.stringify({ toolName: "shell", token: toolCallIdentityToken(shellCall) }) + "\n",
269
+ "utf-8",
270
+ );
271
+ })();
272
+
273
+ const result = await runTurnBoundary(
274
+ boundaryOpts(status, baseline, { denialCancelSettled }),
275
+ );
276
+
277
+ expect(result.deniedToolCallCount).toBe(1);
278
+ expect(shellCall.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
279
+ });
280
+ });