@stigmer/runner 3.1.2 → 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 (77) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.d.ts +1 -1
  3. package/dist/activities/execute-cursor/attachment-resolver.js +1 -1
  4. package/dist/activities/execute-cursor/capture-flow.d.ts +18 -0
  5. package/dist/activities/execute-cursor/capture-flow.js +21 -0
  6. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  7. package/dist/activities/execute-cursor/index.js +121 -139
  8. package/dist/activities/execute-cursor/index.js.map +1 -1
  9. package/dist/activities/execute-cursor/turn-boundary.d.ts +108 -0
  10. package/dist/activities/execute-cursor/turn-boundary.js +163 -0
  11. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -0
  12. package/dist/activities/execute-deep-agent/index.js +30 -3
  13. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  14. package/dist/activities/execute-deep-agent/streaming-v3.js +6 -1
  15. package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
  16. package/dist/activities/execute-deep-agent/streaming.d.ts +7 -0
  17. package/dist/activities/execute-deep-agent/streaming.js +5 -1
  18. package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
  19. package/dist/shared/filereview/capture.d.ts +18 -1
  20. package/dist/shared/filereview/capture.js +105 -28
  21. package/dist/shared/filereview/capture.js.map +1 -1
  22. package/dist/shared/filereview/cas-substrate.d.ts +9 -0
  23. package/dist/shared/filereview/cas-substrate.js +9 -1
  24. package/dist/shared/filereview/cas-substrate.js.map +1 -1
  25. package/dist/shared/filereview/events.d.ts +16 -0
  26. package/dist/shared/filereview/events.js +37 -0
  27. package/dist/shared/filereview/events.js.map +1 -1
  28. package/dist/shared/filereview/git-substrate.d.ts +42 -0
  29. package/dist/shared/filereview/git-substrate.js +78 -0
  30. package/dist/shared/filereview/git-substrate.js.map +1 -1
  31. package/dist/shared/filereview/index.d.ts +3 -1
  32. package/dist/shared/filereview/index.js +3 -1
  33. package/dist/shared/filereview/index.js.map +1 -1
  34. package/dist/shared/filereview/line-counts.d.ts +48 -0
  35. package/dist/shared/filereview/line-counts.js +72 -0
  36. package/dist/shared/filereview/line-counts.js.map +1 -0
  37. package/dist/shared/filereview/progress.d.ts +96 -0
  38. package/dist/shared/filereview/progress.js +134 -0
  39. package/dist/shared/filereview/progress.js.map +1 -0
  40. package/dist/shared/implement-plan-prompt.d.ts +6 -4
  41. package/dist/shared/implement-plan-prompt.js +38 -6
  42. package/dist/shared/implement-plan-prompt.js.map +1 -1
  43. package/dist/shared/plan-artifact.d.ts +59 -11
  44. package/dist/shared/plan-artifact.js +144 -16
  45. package/dist/shared/plan-artifact.js.map +1 -1
  46. package/dist/shared/plan-mode-prompt.d.ts +6 -4
  47. package/dist/shared/plan-mode-prompt.js +10 -5
  48. package/dist/shared/plan-mode-prompt.js.map +1 -1
  49. package/package.json +3 -2
  50. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +7 -0
  51. package/src/activities/execute-cursor/__tests__/delta-enricher.test.ts +68 -0
  52. package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
  53. package/src/activities/execute-cursor/attachment-resolver.ts +1 -1
  54. package/src/activities/execute-cursor/capture-flow.ts +32 -0
  55. package/src/activities/execute-cursor/index.ts +130 -153
  56. package/src/activities/execute-cursor/turn-boundary.ts +274 -0
  57. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +11 -0
  58. package/src/activities/execute-deep-agent/index.ts +34 -3
  59. package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
  60. package/src/activities/execute-deep-agent/streaming.ts +12 -1
  61. package/src/shared/__tests__/implement-plan-prompt.test.ts +40 -2
  62. package/src/shared/__tests__/plan-artifact.test.ts +142 -9
  63. package/src/shared/filereview/__tests__/capture.test.ts +182 -1
  64. package/src/shared/filereview/__tests__/cas-substrate.test.ts +34 -0
  65. package/src/shared/filereview/__tests__/events.test.ts +105 -0
  66. package/src/shared/filereview/__tests__/line-counts.test.ts +73 -0
  67. package/src/shared/filereview/__tests__/progress.test.ts +236 -0
  68. package/src/shared/filereview/capture.ts +134 -30
  69. package/src/shared/filereview/cas-substrate.ts +17 -1
  70. package/src/shared/filereview/events.ts +50 -0
  71. package/src/shared/filereview/git-substrate.ts +108 -0
  72. package/src/shared/filereview/index.ts +22 -1
  73. package/src/shared/filereview/line-counts.ts +83 -0
  74. package/src/shared/filereview/progress.ts +186 -0
  75. package/src/shared/implement-plan-prompt.ts +39 -8
  76. package/src/shared/plan-artifact.ts +152 -16
  77. package/src/shared/plan-mode-prompt.ts +10 -5
@@ -292,5 +292,16 @@ describe("buildEnhancedSystemPrompt", () => {
292
292
 
293
293
  expect(prompt).not.toContain("## Implement the approved plan");
294
294
  });
295
+
296
+ it("carries the plan-derived progress-tracking instruction (Tier 3)", () => {
297
+ const prompt = buildEnhancedSystemPrompt({
298
+ ...base,
299
+ buildFromPlan: true,
300
+ injectedFiles: [planFile],
301
+ });
302
+
303
+ expect(prompt).toContain("to-do list");
304
+ expect(prompt).toContain("break the plan into");
305
+ });
295
306
  });
296
307
  });
@@ -50,6 +50,10 @@ import {
50
50
  captureBaselineToLedger,
51
51
  captureCandidateToLedger,
52
52
  } from "../../shared/filereview/capture.js";
53
+ import {
54
+ captureFileChangeProgress,
55
+ newProgressCaptureState,
56
+ } from "../../shared/filereview/progress.js";
53
57
  import { hasCandidateCaptured } from "../../shared/filereview/events.js";
54
58
  import { casBlobReader, type CasPathCapture } from "../../shared/filereview/cas-substrate.js";
55
59
  import { partitionIgnoredPathsBySecret } from "../../shared/filereview/secret-paths.js";
@@ -319,6 +323,15 @@ export function createDeepAgentActivities(config: Config) {
319
323
  });
320
324
  }
321
325
 
326
+ // Per-turn state for mid-run live capture (DD-32): the last progress tree
327
+ // sha (short-circuit) + last capture time (floor), threaded across the
328
+ // streaming loop's persists via the beforePersist hook below. The capture
329
+ // flags are hoisted so the deferred hook does not depend on `setup`
330
+ // (non-null here, but not narrowable inside a later-invoked closure).
331
+ const progressState = newProgressCaptureState();
332
+ const progressCaptureMode = setup.captureMode;
333
+ const progressGitWorkspace = setup.gitWorkspace;
334
+
322
335
  const cancellationSignal = Context.current().cancellationSignal;
323
336
 
324
337
  const result: StreamResult = await streamExecution({
@@ -344,6 +357,23 @@ export function createDeepAgentActivities(config: Config) {
344
357
  globalBypass: setup.globalBypass,
345
358
  },
346
359
  streamVersion: setup.streamVersion,
360
+ // Mid-run live capture (DD-32): attach file_change_progress before each
361
+ // scheduled persist. Git capture mode only (a pinned baseline exists);
362
+ // deep-agent writes no runner-owned gate files into the tree, so no
363
+ // excludePaths — matching its turn-boundary candidate capture.
364
+ beforePersist: async (status) => {
365
+ if (!progressCaptureMode || !progressGitWorkspace || !captureBaselineTree) {
366
+ return;
367
+ }
368
+ await captureFileChangeProgress({
369
+ status,
370
+ gitRoot,
371
+ executionId,
372
+ changeSetId,
373
+ baselineTree: captureBaselineTree,
374
+ state: progressState,
375
+ });
376
+ },
347
377
  });
348
378
 
349
379
  await processPostStream({
@@ -556,9 +586,10 @@ export function createDeepAgentActivities(config: Config) {
556
586
  }
557
587
 
558
588
  // Plan mode: the agent's final message is the plan. Publish it as a
559
- // first-class plan.md artifact so the UI can render a reviewable Plan
560
- // card and a follow-up Implement run can reference it. Read-only mode
561
- // produces no file to auto-publish, so this is the only artifact path.
589
+ // first-class plan artifact (named from the plan's title) so the UI can
590
+ // render a reviewable Plan card and a follow-up Implement run can
591
+ // reference it. Read-only mode produces no file to auto-publish, so
592
+ // this is the only artifact path.
562
593
  // Requires artifact storage; with none (proxy misconfig) the plan text
563
594
  // still lives in the final message — only the reviewable artifact is
564
595
  // skipped (mirrors Cursor's storage-guarded plan publish).
@@ -118,10 +118,15 @@ export async function streamExecutionV3(
118
118
  }
119
119
 
120
120
  statusBuilder.syncSubAgentExecutions();
121
+ // Mid-run live capture (DD-32): attach file_change_progress to the live
122
+ // status before it is persisted. Injected so this loop stays ignorant of
123
+ // file-review specifics; a no-op when the hook is absent or nothing changed.
124
+ const statusToPersist = statusBuilder.currentStatus;
125
+ await deps.beforePersist?.(statusToPersist);
121
126
  const signal = await persistStatus(
122
127
  client,
123
128
  executionId,
124
- statusBuilder.currentStatus,
129
+ statusToPersist,
125
130
  { offload, retry: retryOptions },
126
131
  );
127
132
  scheduler.markUpdateSent(eventsProcessed);
@@ -83,6 +83,13 @@ export interface StreamDependencies {
83
83
  readonly approvalProvider?: ApprovalPolicyProvider;
84
84
  /** Streaming protocol version. Defaults to "v2" if unset. */
85
85
  readonly streamVersion?: "v2" | "v3";
86
+ /**
87
+ * Optional hook invoked with the live status just before each scheduled
88
+ * persist. Used by mid-run live capture (DD-32) to attach file_change_progress.
89
+ * Kept as an injected callback (closing over the turn's baseline tree / change
90
+ * set id in index.ts) so this loop stays ignorant of file-review specifics.
91
+ */
92
+ readonly beforePersist?: (status: AgentExecutionStatus) => Promise<void>;
86
93
  }
87
94
 
88
95
  export interface StreamResult {
@@ -189,10 +196,14 @@ async function streamExecutionV2(
189
196
  statusBuilder.clearForceFlag();
190
197
  }
191
198
 
199
+ // Mid-run live capture (DD-32): attach file_change_progress to the live
200
+ // status before persisting (see streaming-v3.ts for the rationale).
201
+ const statusToPersist = statusBuilder.currentStatus;
202
+ await deps.beforePersist?.(statusToPersist);
192
203
  const signal = await persistStatus(
193
204
  client,
194
205
  executionId,
195
- statusBuilder.currentStatus,
206
+ statusToPersist,
196
207
  { offload, retry: retryOptions },
197
208
  );
198
209
  scheduler.markUpdateSent(eventsProcessed);
@@ -5,7 +5,7 @@ import {
5
5
  } from "../implement-plan-prompt.js";
6
6
 
7
7
  describe("findApprovedPlanPath", () => {
8
- it("finds the plan by its canonical filename among attachment paths", () => {
8
+ it("finds the plan by the legacy filename among attachment paths", () => {
9
9
  expect(
10
10
  findApprovedPlanPath([
11
11
  ".stigmer/inputs/data.csv",
@@ -14,15 +14,28 @@ describe("findApprovedPlanPath", () => {
14
14
  ).toBe(".stigmer/inputs/plan.md");
15
15
  });
16
16
 
17
+ it("finds a title-named <slug>_<id>.plan.md plan", () => {
18
+ expect(
19
+ findApprovedPlanPath([
20
+ ".stigmer/inputs/data.csv",
21
+ ".stigmer/inputs/plan-card-ux-cleanup_a1b2c3d4.plan.md",
22
+ ]),
23
+ ).toBe(".stigmer/inputs/plan-card-ux-cleanup_a1b2c3d4.plan.md");
24
+ });
25
+
17
26
  it("returns undefined when no plan attachment resolved", () => {
18
27
  expect(findApprovedPlanPath([".stigmer/inputs/data.csv"])).toBeUndefined();
19
28
  expect(findApprovedPlanPath([])).toBeUndefined();
20
29
  });
21
30
 
22
- it("matches the basename exactly — a plan-adjacent filename is not the plan", () => {
31
+ it("matches the plan convention only — a plan-adjacent filename is not the plan", () => {
23
32
  expect(
24
33
  findApprovedPlanPath([".stigmer/inputs/my-plan.md.bak"]),
25
34
  ).toBeUndefined();
35
+ // A file merely containing "plan" is not a plan artifact.
36
+ expect(
37
+ findApprovedPlanPath([".stigmer/inputs/myplan.md"]),
38
+ ).toBeUndefined();
26
39
  });
27
40
  });
28
41
 
@@ -45,4 +58,29 @@ describe("buildImplementPlanDirective", () => {
45
58
  expect(directive).toContain("conversation above");
46
59
  expect(directive).not.toContain("plan.md");
47
60
  });
61
+
62
+ // Tier 3 (plan-driven build progress): both variants instruct the agent to
63
+ // derive its to-do list from the plan and keep it current — the agent's todo
64
+ // tool is the single writer of status.todos, so this instruction is the
65
+ // entire plan→progress linkage.
66
+ it("instructs plan-derived progress tracking in the attached variant", () => {
67
+ const directive = buildImplementPlanDirective(".stigmer/inputs/plan.md");
68
+
69
+ expect(directive).toContain("to-do list");
70
+ expect(directive).toContain("break the plan into");
71
+ expect(directive).toContain("in progress");
72
+ expect(directive).toContain("completed");
73
+ });
74
+
75
+ it("instructs plan-derived progress tracking in the conversation-only variant", () => {
76
+ const directive = buildImplementPlanDirective(undefined);
77
+
78
+ expect(directive).toContain("to-do list");
79
+ expect(directive).toContain("break the plan into");
80
+ expect(directive).toContain("in progress");
81
+ expect(directive).toContain("completed");
82
+ // The progress block must never name the file — this variant exists
83
+ // because no plan file materialized.
84
+ expect(directive).not.toContain("plan.md");
85
+ });
48
86
  });
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { describe, it, expect } from "vitest";
2
3
  import { create } from "@bufbuild/protobuf";
3
4
  import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
@@ -11,8 +12,9 @@ import { makeInMemoryArtifactStorage } from "../../__test-utils__/fake-artifact-
11
12
  import {
12
13
  extractFinalPlanText,
13
14
  publishPlanArtifact,
15
+ planArtifactName,
16
+ isPlanArtifactName,
14
17
  PLAN_ARTIFACT_NAME,
15
- PLAN_ARTIFACT_SANDBOX_PATH,
16
18
  } from "../plan-artifact.js";
17
19
 
18
20
  /** Records uploads and returns deterministic download URLs (canonical double + shim). */
@@ -27,6 +29,11 @@ function fakeStorage(): ArtifactStorage & { uploads: { key: string; content: Buf
27
29
  return Object.assign(storage, { uploads });
28
30
  }
29
31
 
32
+ /** Mirrors the runner's content-hash discriminator so tests can assert exact names. */
33
+ function planId(text: string): string {
34
+ return createHash("sha256").update(text, "utf-8").digest("hex").slice(0, 8);
35
+ }
36
+
30
37
  function statusWith(...messages: { type: MessageType; content: string }[]) {
31
38
  return create(AgentExecutionStatusSchema, {
32
39
  messages: messages.map((m) =>
@@ -59,30 +66,129 @@ describe("extractFinalPlanText", () => {
59
66
  });
60
67
  });
61
68
 
69
+ describe("planArtifactName", () => {
70
+ it("derives a hyphenated <slug>_<id>.plan.md name from the plan's leading # H1", () => {
71
+ const text = "# Plan Card UX Cleanup\n\nBody";
72
+ // "Plan Card…" has no label separator, so the genuine word "plan" survives.
73
+ expect(planArtifactName(text)).toBe(`plan-card-ux-cleanup_${planId(text)}.plan.md`);
74
+ });
75
+
76
+ it("strips a leading 'Plan:' LABEL (colon/dash-anchored) from the slug", () => {
77
+ const text = "# Plan: Fix the download UX\n\nbody";
78
+ expect(planArtifactName(text)).toBe(`fix-the-download-ux_${planId(text)}.plan.md`);
79
+ });
80
+
81
+ it("collapses runs of punctuation/whitespace and trims edge hyphens", () => {
82
+ const text = "# Fix: the (download) UX!! \nx";
83
+ expect(planArtifactName(text)).toBe(`fix-the-download-ux_${planId(text)}.plan.md`);
84
+ });
85
+
86
+ it("unwraps a ```markdown fenced plan before reading the title (SDK parity)", () => {
87
+ const text = "```markdown\n# Named Plan\n\nbody\n```";
88
+ expect(planArtifactName(text)).toBe(`named-plan_${planId(text)}.plan.md`);
89
+ });
90
+
91
+ it("unwraps a bare ``` fenced plan before reading the title (SDK parity)", () => {
92
+ const text = "```\n# Bare Fenced Plan\n\nbody\n```";
93
+ expect(planArtifactName(text)).toBe(`bare-fenced-plan_${planId(text)}.plan.md`);
94
+ });
95
+
96
+ it("caps the slug length so filenames stay bounded", () => {
97
+ const longTitle = "# " + "word ".repeat(40); // ~200 chars of words
98
+ const name = planArtifactName(longTitle);
99
+ const match = /^(.+)_[0-9a-f]{8}\.plan\.md$/.exec(name);
100
+ expect(match).not.toBeNull();
101
+ const slug = match![1];
102
+ expect(slug.length).toBeLessThanOrEqual(60);
103
+ expect(slug.endsWith("-")).toBe(false);
104
+ });
105
+
106
+ it("falls back to a bare <id>.plan.md when there is no leading H1", () => {
107
+ const noTitle = "No title here\n\nbody";
108
+ expect(planArtifactName(noTitle)).toBe(`${planId(noTitle)}.plan.md`);
109
+ // An H1 further down is body content, not the title.
110
+ const later = "intro\n\n# Later Heading";
111
+ expect(planArtifactName(later)).toBe(`${planId(later)}.plan.md`);
112
+ });
113
+
114
+ it("falls back to a bare <id>.plan.md when the title is only the 'Plan:' label", () => {
115
+ const text = "# Plan:\n\nbody";
116
+ expect(planArtifactName(text)).toBe(`${planId(text)}.plan.md`);
117
+ });
118
+
119
+ it("falls back to a bare <id>.plan.md when the title has no alphanumerics", () => {
120
+ const text = "# ---\n\nbody";
121
+ expect(planArtifactName(text)).toBe(`${planId(text)}.plan.md`);
122
+ });
123
+
124
+ it("never freshly emits the legacy plan.md name", () => {
125
+ for (const text of ["# A Title\n\nbody", "no heading", "# Plan:\n\nx"]) {
126
+ expect(planArtifactName(text)).not.toBe(PLAN_ARTIFACT_NAME);
127
+ }
128
+ });
129
+ });
130
+
131
+ describe("isPlanArtifactName", () => {
132
+ it("accepts the legacy exact name and any *.plan.md", () => {
133
+ expect(isPlanArtifactName("plan.md")).toBe(true);
134
+ expect(isPlanArtifactName("feature_x.plan.md")).toBe(true);
135
+ expect(isPlanArtifactName("fix-the-download-ux_a1b2c3d4.plan.md")).toBe(true);
136
+ expect(isPlanArtifactName("a1b2c3d4.plan.md")).toBe(true);
137
+ });
138
+
139
+ it("rejects non-plan filenames", () => {
140
+ expect(isPlanArtifactName("notes.md")).toBe(false);
141
+ expect(isPlanArtifactName("plan.md.bak")).toBe(false);
142
+ expect(isPlanArtifactName("myplan.md")).toBe(false);
143
+ });
144
+ });
145
+
62
146
  describe("publishPlanArtifact", () => {
63
- it("uploads plan.md and registers a FILE artifact on the status", async () => {
147
+ it("uploads the named plan and registers a FILE artifact on the status", async () => {
64
148
  const status = create(AgentExecutionStatusSchema, {});
65
149
  const storage = fakeStorage();
66
150
 
151
+ const planText = "# Plan Card UX Cleanup\n1. step one\n2. step two\n";
152
+ const name = `plan-card-ux-cleanup_${planId(planText)}.plan.md`;
67
153
  await publishPlanArtifact({
68
154
  status,
69
155
  executionId: "aex_123",
70
- planText: "# Plan\n1. step one\n2. step two\n",
156
+ planText,
71
157
  artifactStorage: storage,
72
158
  });
73
159
 
74
160
  expect(storage.uploads).toHaveLength(1);
75
- expect(storage.uploads[0].key).toBe("artifacts/aex_123/plan.md");
161
+ expect(storage.uploads[0].key).toBe(`artifacts/aex_123/${name}`);
76
162
  expect(storage.uploads[0].contentType).toBe("text/markdown");
77
163
 
78
164
  expect(status.artifacts).toHaveLength(1);
79
165
  const artifact = status.artifacts[0];
80
- expect(artifact.name).toBe(PLAN_ARTIFACT_NAME);
81
- expect(artifact.sandboxPath).toBe(PLAN_ARTIFACT_SANDBOX_PATH);
166
+ expect(artifact.name).toBe(name);
167
+ expect(artifact.sandboxPath).toBe(`.stigmer/plans/${name}`);
82
168
  expect(artifact.kind).toBe(ExecutionArtifactKind.FILE);
83
- expect(artifact.storageKey).toBe("artifacts/aex_123/plan.md");
169
+ expect(artifact.storageKey).toBe(`artifacts/aex_123/${name}`);
84
170
  expect(artifact.sizeBytes).toBeGreaterThan(0n);
85
171
  expect(artifact.contentHash).toHaveLength(64);
172
+ // The filename's <id> is the leading 8 hex of the full content hash.
173
+ expect(artifact.contentHash.startsWith(planId(planText))).toBe(true);
174
+ });
175
+
176
+ it("uses a bare <id>.plan.md name for a titleless plan (never the legacy plan.md)", async () => {
177
+ const status = create(AgentExecutionStatusSchema, {});
178
+ const storage = fakeStorage();
179
+
180
+ const planText = "just a paragraph, no heading";
181
+ const name = `${planId(planText)}.plan.md`;
182
+ await publishPlanArtifact({
183
+ status,
184
+ executionId: "aex_notitle",
185
+ planText,
186
+ artifactStorage: storage,
187
+ });
188
+
189
+ expect(status.artifacts[0].name).toBe(name);
190
+ expect(status.artifacts[0].name).not.toBe(PLAN_ARTIFACT_NAME);
191
+ expect(storage.uploads[0].key).toBe(`artifacts/aex_notitle/${name}`);
86
192
  });
87
193
 
88
194
  it("is a no-op for empty plan text (nothing to publish)", async () => {
@@ -100,20 +206,47 @@ describe("publishPlanArtifact", () => {
100
206
  expect(status.artifacts).toHaveLength(0);
101
207
  });
102
208
 
103
- it("replaces an existing plan.md rather than appending a duplicate", async () => {
209
+ it("replaces an existing plan rather than appending a duplicate", async () => {
104
210
  const status = create(AgentExecutionStatusSchema, {});
105
211
  const storage = fakeStorage();
106
212
 
107
213
  await publishPlanArtifact({ status, executionId: "aex_re", planText: "v1", artifactStorage: storage });
108
214
  await publishPlanArtifact({ status, executionId: "aex_re", planText: "v2 longer plan", artifactStorage: storage });
109
215
 
216
+ // Replacement is at the STATUS level, matched by the *.plan.md predicate:
217
+ // exactly one plan artifact survives. Because names are content-derived,
218
+ // the two differing-content writes land under DIFFERENT keys (the first is
219
+ // an orphan blob) — but this synthetic double-publish never happens in
220
+ // production, where publishPlanArtifact runs once per execution finalization
221
+ // and a same-content retry re-writes the same key.
110
222
  expect(status.artifacts).toHaveLength(1);
111
223
  expect(storage.uploads).toHaveLength(2);
112
- // The surviving artifact reflects the latest content hash.
113
224
  const latestHash = storage.uploads[1].content;
114
225
  expect(status.artifacts[0].sizeBytes).toBe(BigInt(latestHash.length));
115
226
  });
116
227
 
228
+ it("replaces a prior plan even when the derived name changed (predicate match)", async () => {
229
+ const status = create(AgentExecutionStatusSchema, {});
230
+ const storage = fakeStorage();
231
+
232
+ await publishPlanArtifact({
233
+ status,
234
+ executionId: "aex_rename",
235
+ planText: "# First Title\n\nbody",
236
+ artifactStorage: storage,
237
+ });
238
+ const secondText = "# Second Title\n\nbody";
239
+ await publishPlanArtifact({
240
+ status,
241
+ executionId: "aex_rename",
242
+ planText: secondText,
243
+ artifactStorage: storage,
244
+ });
245
+
246
+ expect(status.artifacts).toHaveLength(1);
247
+ expect(status.artifacts[0].name).toBe(`second-title_${planId(secondText)}.plan.md`);
248
+ });
249
+
117
250
  it("never throws when the upload fails (publish is non-fatal)", async () => {
118
251
  const status = create(AgentExecutionStatusSchema, {});
119
252
  const { storage: failing } = makeInMemoryArtifactStorage();
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { promisify } from "node:util";
16
16
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
17
- import { clone, create } from "@bufbuild/protobuf";
17
+ import { clone, create, toBinary } from "@bufbuild/protobuf";
18
18
  import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
19
19
  import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
20
20
  import {
@@ -28,6 +28,7 @@ import {
28
28
  FileCaptureClass,
29
29
  FileChangeKind,
30
30
  FileChangeSetStatus,
31
+ FileChangeType,
31
32
  FileDecisionAction,
32
33
  FileDecisionScope,
33
34
  FileReviewBlockReason,
@@ -41,7 +42,9 @@ import {
41
42
  captureBaselineToLedger,
42
43
  captureCandidateToLedger,
43
44
  deriveCaptureMode,
45
+ partitionGitChangesBySecret,
44
46
  } from "../capture.js";
47
+ import type { GitSubstrateChange } from "../git-substrate.js";
45
48
  import { casBlobReader } from "../cas-substrate.js";
46
49
 
47
50
  const execFileAsync = promisify(execFile);
@@ -609,6 +612,184 @@ describe("capture orchestration — secret-blocked DIFF_UNREVIEWABLE (DD-E)", ()
609
612
  });
610
613
  });
611
614
 
615
+ // A git-TRACKED secret-like file (a committed credentials.json, .env.example,
616
+ // ...) FLOWS in capture mode (the gate allows tracked mutations with no secret
617
+ // check), so it reaches the turn boundary in the git diff WITH real bytes. Those
618
+ // bytes must never enter the ledger: the capture seam authors it content-less
619
+ // (SECRET_WITHHELD / GIT_TRACKED), discard-only, and the git substrate reverts
620
+ // it byte-exact on reject. This is the one place that can withhold a DELETE's
621
+ // baseline (before) secret too — the gate never sees a delete's content.
622
+ // (DD-26 follow-up #3.)
623
+ describe("capture orchestration — git-tracked secret withheld (DD-26 follow-up #3)", () => {
624
+ const SECRET_PATH = "config/credentials.json";
625
+ const BASELINE_SECRET = "OLD_API_KEY=sk-live-OLDSECRET-do-not-persist-111\n";
626
+ const NEW_SECRET = "NEW_API_KEY=sk-live-NEWSECRET-do-not-persist-222\n";
627
+
628
+ async function commitBaselineSecret(): Promise<void> {
629
+ await write(SECRET_PATH, BASELINE_SECRET);
630
+ await git(["add", "-A"]);
631
+ await git(["commit", "-q", "-m", "add tracked secret"]);
632
+ }
633
+
634
+ // Serialize the WHOLE persisted status (ledger + any rows) and assert neither
635
+ // the baseline nor the new secret bytes survive anywhere — the leak-scan that
636
+ // would FAIL before this fix (the git diff carried the inline body).
637
+ function assertNoSecretBytes(status: AgentExecutionStatus): void {
638
+ const wire = Buffer.from(toBinary(AgentExecutionStatusSchema, status));
639
+ expect(wire.includes(Buffer.from(BASELINE_SECRET))).toBe(false);
640
+ expect(wire.includes(Buffer.from(NEW_SECRET))).toBe(false);
641
+ }
642
+
643
+ it("MODIFY: content-less SECRET_WITHHELD/GIT_TRACKED entry; PARTIAL_BLOCKED; nothing persisted", async () => {
644
+ await commitBaselineSecret();
645
+ const status = newStatus();
646
+ const baseline = await captureBaselineToLedger({
647
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
648
+ });
649
+ await write(SECRET_PATH, NEW_SECRET); // the agent edits the committed secret
650
+
651
+ await captureCandidateToLedger({
652
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
653
+ baselineTree: baseline, harnessId: HARNESS,
654
+ });
655
+
656
+ const entry = candidateChanges(status).find((c) => (c.pathAfter || c.pathBefore) === SECRET_PATH)!;
657
+ expect(entry).toBeDefined();
658
+ expect(entry.captureClass).toBe(FileCaptureClass.GIT_TRACKED);
659
+ expect(entry.kind).toBe(FileChangeKind.MODIFY);
660
+ expect(entry.diffComplete).toBe(false);
661
+ expect(entry.blockedReason).toBe(FileReviewBlockReason.SECRET_WITHHELD);
662
+ // No content and no enforcement digests on EITHER side (before is secret too).
663
+ expect(entry.before).toBeUndefined();
664
+ expect(entry.after).toBeUndefined();
665
+ expect(entry.beforeSha256).toBe("");
666
+ expect(entry.afterSha256).toBe("");
667
+ expect(candidateCompleteness(status)).toBe(DiffCompleteness.PARTIAL_BLOCKED);
668
+ assertNoSecretBytes(status);
669
+ });
670
+
671
+ it("CREATE: a newly-created tracked secret is content-less (kind ADD, no after bytes)", async () => {
672
+ const status = newStatus();
673
+ const baseline = await captureBaselineToLedger({
674
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
675
+ });
676
+ await write(SECRET_PATH, NEW_SECRET); // brand-new secret file this turn
677
+
678
+ await captureCandidateToLedger({
679
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
680
+ baselineTree: baseline, harnessId: HARNESS,
681
+ });
682
+
683
+ const entry = candidateChanges(status).find((c) => (c.pathAfter || c.pathBefore) === SECRET_PATH)!;
684
+ expect(entry.kind).toBe(FileChangeKind.ADD);
685
+ expect(entry.captureClass).toBe(FileCaptureClass.GIT_TRACKED);
686
+ expect(entry.diffComplete).toBe(false);
687
+ expect(entry.blockedReason).toBe(FileReviewBlockReason.SECRET_WITHHELD);
688
+ expect(entry.after).toBeUndefined();
689
+ expect(entry.afterSha256).toBe("");
690
+ expect(candidateCompleteness(status)).toBe(DiffCompleteness.PARTIAL_BLOCKED);
691
+ assertNoSecretBytes(status);
692
+ });
693
+
694
+ it("DELETE: withholds the deleted file's baseline (before) secret bytes (kind DELETE)", async () => {
695
+ await commitBaselineSecret();
696
+ const status = newStatus();
697
+ const baseline = await captureBaselineToLedger({
698
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
699
+ });
700
+ await rm(join(repo, SECRET_PATH), { force: true }); // the agent deletes the secret
701
+
702
+ await captureCandidateToLedger({
703
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
704
+ baselineTree: baseline, harnessId: HARNESS,
705
+ });
706
+
707
+ const entry = candidateChanges(status).find((c) => c.pathBefore === SECRET_PATH)!;
708
+ expect(entry).toBeDefined();
709
+ expect(entry.kind).toBe(FileChangeKind.DELETE);
710
+ expect(entry.captureClass).toBe(FileCaptureClass.GIT_TRACKED);
711
+ expect(entry.diffComplete).toBe(false);
712
+ // The DELETE's `before` side is the committed secret — the leak only this
713
+ // seam can withhold (the gate never sees a delete's content).
714
+ expect(entry.before).toBeUndefined();
715
+ expect(entry.beforeSha256).toBe("");
716
+ expect(candidateCompleteness(status)).toBe(DiffCompleteness.PARTIAL_BLOCKED);
717
+ assertNoSecretBytes(status);
718
+ });
719
+
720
+ it("mixed set: the non-secret file stays keepable; the secret reverts byte-exact on reject", async () => {
721
+ await commitBaselineSecret();
722
+ const status = newStatus();
723
+ const baseline = await captureBaselineToLedger({
724
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
725
+ });
726
+ await write("notes.md", "planton notes\n"); // reviewable tracked change
727
+ await write(SECRET_PATH, NEW_SECRET); // withheld tracked secret change
728
+
729
+ await captureCandidateToLedger({
730
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
731
+ baselineTree: baseline, harnessId: HARNESS,
732
+ });
733
+
734
+ const byPath = new Map(candidateChanges(status).map((c) => [c.pathAfter || c.pathBefore, c]));
735
+ // The non-secret file is fully reviewable/keepable (content present).
736
+ expect(byPath.get("notes.md")!.diffComplete).toBe(true);
737
+ expect(byPath.get("notes.md")!.after).toBeDefined();
738
+ expect(byPath.get("notes.md")!.blockedReason).toBe(FileReviewBlockReason.UNSPECIFIED);
739
+ // The secret rides alongside content-less, forcing the set PARTIAL_BLOCKED.
740
+ expect(byPath.get(SECRET_PATH)!.diffComplete).toBe(false);
741
+ expect(byPath.get(SECRET_PATH)!.blockedReason).toBe(FileReviewBlockReason.SECRET_WITHHELD);
742
+ expect(candidateCompleteness(status)).toBe(DiffCompleteness.PARTIAL_BLOCKED);
743
+ assertNoSecretBytes(status);
744
+
745
+ // Keep the reviewable file; discard the secret. The git substrate reverts the
746
+ // secret to its committed baseline BYTE-EXACT (not deleted) and keeps notes.md.
747
+ const changeSet = decidedChangeSet(status, {
748
+ "notes.md": FileDecisionAction.APPROVE,
749
+ [SECRET_PATH]: FileDecisionAction.REJECT,
750
+ });
751
+ const result = await applyCaptureDecisions({
752
+ status, gitRoot: repo, executionId: EXEC_ID, changeSet, harnessId: HARNESS,
753
+ });
754
+
755
+ expect(result.failed).toBe(false);
756
+ expect(result.approvedPaths).toEqual(["notes.md"]);
757
+ expect(result.rejectedPaths).toEqual([SECRET_PATH]);
758
+ expect(await read("notes.md")).toBe("planton notes\n");
759
+ expect(await read(SECRET_PATH)).toBe(BASELINE_SECRET); // reverted byte-exact
760
+ // Even after reconcile, no secret bytes are anywhere in the persisted status.
761
+ assertNoSecretBytes(status);
762
+ });
763
+ });
764
+
765
+ // partitionGitChangesBySecret: the pure split the composition relies on.
766
+ describe("partitionGitChangesBySecret (pure)", () => {
767
+ const mk = (path: string): GitSubstrateChange => ({
768
+ path,
769
+ changeType: FileChangeType.MODIFY,
770
+ before: { kind: "inline", text: "a" },
771
+ after: { kind: "inline", text: "b" },
772
+ });
773
+
774
+ it("routes secret-like paths to `secret` and everything else to `safe`", () => {
775
+ const { safe, secret } = partitionGitChangesBySecret([
776
+ mk("src/main.ts"),
777
+ mk("config/credentials.json"),
778
+ mk(".env"),
779
+ mk("README.md"),
780
+ mk("keys/id_rsa"),
781
+ ]);
782
+ expect(safe.map((c) => c.path)).toEqual(["src/main.ts", "README.md"]);
783
+ expect(secret.map((c) => c.path)).toEqual(["config/credentials.json", ".env", "keys/id_rsa"]);
784
+ });
785
+
786
+ it("returns everything safe when no path is secret-like", () => {
787
+ const { safe, secret } = partitionGitChangesBySecret([mk("a.ts"), mk("b/c.md")]);
788
+ expect(safe).toHaveLength(2);
789
+ expect(secret).toHaveLength(0);
790
+ });
791
+ });
792
+
612
793
  // Slice 2a (DD-21 D2): the shared orchestration must capture and reconcile a
613
794
  // NON-GIT workspace entirely from the CAS manifest — no git refs, no whole-tree
614
795
  // snapshot, bounded to the paths the observer actually touched. These exercise
@@ -97,6 +97,40 @@ describe("snapshotCasChangeSet — classification", () => {
97
97
  expect(del.before?.sha256).toBe(sha256Bytes(bytes("BYE=1")));
98
98
  });
99
99
 
100
+ it("counts display line changes from the in-memory bytes at capture", async () => {
101
+ const { storage } = makeFakeStorage();
102
+ const { manifest } = await snapshotCasChangeSet({
103
+ storage,
104
+ executionId: EXEC,
105
+ changeSetId: CHANGE_SET,
106
+ captures: [
107
+ { path: "created.env", before: null, after: bytes("A=1\nB=2\n"), captureClass: IGNORED },
108
+ { path: "changed.env", before: bytes("OLD=1\n"), after: bytes("OLD=2\n"), captureClass: IGNORED },
109
+ // Binary side: no text diff exists, so no counts may claim one does.
110
+ { path: "blob.bin", before: null, after: new Uint8Array([0, 1, 2]), captureClass: IGNORED },
111
+ ],
112
+ });
113
+ const byPath = new Map(manifest.files.map((f) => [f.pathAfter || f.pathBefore, f]));
114
+
115
+ expect(byPath.get("created.env")!.lineCounts).toEqual({ linesAdded: 2, linesRemoved: 0 });
116
+ expect(byPath.get("changed.env")!.lineCounts).toEqual({ linesAdded: 1, linesRemoved: 1 });
117
+ expect(byPath.get("blob.bin")!.lineCounts).toBeUndefined();
118
+ });
119
+
120
+ it("keeps line counts out of the persisted manifest (display data, not an enforcement record)", async () => {
121
+ const { storage, readBlob } = makeFakeStorage();
122
+ const { ref } = await snapshotCasChangeSet({
123
+ storage,
124
+ executionId: EXEC,
125
+ changeSetId: CHANGE_SET,
126
+ captures: [
127
+ { path: "changed.env", before: bytes("OLD=1\n"), after: bytes("OLD=2\n"), captureClass: IGNORED },
128
+ ],
129
+ });
130
+ const roundTripped = await loadCasManifest({ readBlob, ref });
131
+ expect(roundTripped.files[0].lineCounts).toBeUndefined();
132
+ });
133
+
100
134
  it("drops a touched-but-unchanged path (before == after)", async () => {
101
135
  const { storage } = makeFakeStorage();
102
136
  const { manifest } = await snapshotCasChangeSet({