@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
@@ -138,6 +138,111 @@ describe("buildCapturedFileChange", () => {
138
138
  }
139
139
  });
140
140
 
141
+ it("stamps display line counts from inline sides", () => {
142
+ const modify = buildCapturedFileChange({
143
+ id: "fc-counts",
144
+ pathBefore: "src/a.ts",
145
+ pathAfter: "src/a.ts",
146
+ kind: FileChangeKind.MODIFY,
147
+ captureClass: FileCaptureClass.GIT_TRACKED,
148
+ before: "one\ntwo\n",
149
+ after: "one\nTWO\nthree\n",
150
+ });
151
+ expect(modify.linesAdded).toBe(2);
152
+ expect(modify.linesRemoved).toBe(1);
153
+
154
+ const add = buildCapturedFileChange({
155
+ id: "fc-add-counts",
156
+ pathBefore: "",
157
+ pathAfter: "src/new.ts",
158
+ kind: FileChangeKind.ADD,
159
+ captureClass: FileCaptureClass.GIT_TRACKED,
160
+ after: "a\nb\nc\n",
161
+ });
162
+ expect(add.linesAdded).toBe(3);
163
+ expect(add.linesRemoved).toBe(0);
164
+ });
165
+
166
+ it("prefers explicit lineCounts (the CAS substrate's capture-time counts) over inline counting", () => {
167
+ const change = buildCapturedFileChange({
168
+ id: "fc-explicit",
169
+ pathBefore: "notes.txt",
170
+ pathAfter: "notes.txt",
171
+ kind: FileChangeKind.MODIFY,
172
+ captureClass: FileCaptureClass.NON_GIT_CAS,
173
+ before: {
174
+ kind: "ref", sha256: "s1", storageKey: "k1", sizeBytes: 10, isBinary: false,
175
+ },
176
+ after: {
177
+ kind: "ref", sha256: "s2", storageKey: "k2", sizeBytes: 12, isBinary: false,
178
+ },
179
+ lineCounts: { linesAdded: 7, linesRemoved: 4 },
180
+ });
181
+ expect(change.linesAdded).toBe(7);
182
+ expect(change.linesRemoved).toBe(4);
183
+ });
184
+
185
+ it("leaves counts at zero when a side is not countable (binary / ref without explicit counts / withheld)", () => {
186
+ // Binary side: no text diff exists, so no count may claim one does.
187
+ const binary = binaryChange("bin");
188
+ expect(binary.linesAdded).toBe(0);
189
+ expect(binary.linesRemoved).toBe(0);
190
+
191
+ // Ref side without explicit counts: the bytes are not here to count.
192
+ const ref = buildCapturedFileChange({
193
+ id: "fc-ref",
194
+ pathBefore: "big.txt",
195
+ pathAfter: "big.txt",
196
+ kind: FileChangeKind.MODIFY,
197
+ captureClass: FileCaptureClass.NON_GIT_CAS,
198
+ before: { kind: "ref", sha256: "s1", storageKey: "k1", sizeBytes: 9, isBinary: false },
199
+ after: { kind: "ref", sha256: "s2", storageKey: "k2", sizeBytes: 9, isBinary: false },
200
+ });
201
+ expect(ref.linesAdded).toBe(0);
202
+ expect(ref.linesRemoved).toBe(0);
203
+
204
+ // Secret-withheld: content-less by design.
205
+ const withheld = unavailableChange("secret");
206
+ expect(withheld.linesAdded).toBe(0);
207
+ expect(withheld.linesRemoved).toBe(0);
208
+ });
209
+
210
+ it("keeps line counts out of the enforcement digests (informational only)", () => {
211
+ const common = {
212
+ id: "fc-digest",
213
+ pathBefore: "a",
214
+ pathAfter: "a",
215
+ kind: FileChangeKind.MODIFY,
216
+ captureClass: FileCaptureClass.NON_GIT_CAS,
217
+ before: {
218
+ kind: "ref", sha256: "s1", storageKey: "k1", sizeBytes: 3, isBinary: false,
219
+ },
220
+ after: {
221
+ kind: "ref", sha256: "s2", storageKey: "k2", sizeBytes: 3, isBinary: false,
222
+ },
223
+ } as const;
224
+ // Identical content with and without counts must be digest-indistinguishable —
225
+ // structurally guaranteed (FileDigestInput excludes counts), locked here so a
226
+ // future refactor cannot silently fold display data into enforcement.
227
+ const withoutCounts = buildCapturedFileChange(common);
228
+ const withCounts = buildCapturedFileChange({
229
+ ...common,
230
+ lineCounts: { linesAdded: 5, linesRemoved: 2 },
231
+ });
232
+ expect(withCounts.fileDigest).toBe(withoutCounts.fileDigest);
233
+
234
+ const aggWithout = buildCandidateCapturedEvent(ctx, undefined, [withoutCounts]);
235
+ const aggWith = buildCandidateCapturedEvent(ctx, undefined, [withCounts]);
236
+ if (
237
+ aggWithout.payload.case === "candidateCaptured" &&
238
+ aggWith.payload.case === "candidateCaptured"
239
+ ) {
240
+ expect(aggWith.payload.value.aggregateDigest).toBe(
241
+ aggWithout.payload.value.aggregateDigest,
242
+ );
243
+ }
244
+ });
245
+
141
246
  it("omits the before side for an ADD and the after side for a DELETE", () => {
142
247
  const add = buildCapturedFileChange({
143
248
  id: "fc-add",
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Capture-time line counting: the `+N −M` semantics must mirror what the SDK's
3
+ * jsdiff-based diff renderer shows (same algorithm, same "\ No newline"
4
+ * exclusion), with absent sides treated as the empty document and a byte-size
5
+ * guard that skips counting rather than stalling capture.
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import { countLineChanges, LINE_COUNT_MAX_BYTES } from "../line-counts.js";
10
+
11
+ describe("countLineChanges", () => {
12
+ it("counts a plain modify", () => {
13
+ expect(countLineChanges("a\nb\nc\n", "a\nB\nc\nd\n")).toEqual({
14
+ linesAdded: 2,
15
+ linesRemoved: 1,
16
+ });
17
+ });
18
+
19
+ it("counts an ADD (absent before) as all lines added", () => {
20
+ expect(countLineChanges(undefined, "one\ntwo\nthree\n")).toEqual({
21
+ linesAdded: 3,
22
+ linesRemoved: 0,
23
+ });
24
+ });
25
+
26
+ it("counts a DELETE (absent after) as all lines removed", () => {
27
+ expect(countLineChanges("one\ntwo\n", undefined)).toEqual({
28
+ linesAdded: 0,
29
+ linesRemoved: 2,
30
+ });
31
+ });
32
+
33
+ it("returns zero counts for identical sides", () => {
34
+ expect(countLineChanges("same\n", "same\n")).toEqual({
35
+ linesAdded: 0,
36
+ linesRemoved: 0,
37
+ });
38
+ });
39
+
40
+ it("returns undefined when both sides are absent", () => {
41
+ expect(countLineChanges(undefined, undefined)).toBeUndefined();
42
+ });
43
+
44
+ it("excludes the no-trailing-newline marker from the counts", () => {
45
+ // "a" -> "a\n": jsdiff models this as the "a" line replaced (one removed,
46
+ // one added) plus "\ No newline" metadata, which must not be counted —
47
+ // exactly the population the SDK's mapPatchHunks renders.
48
+ expect(countLineChanges("a", "a\n")).toEqual({
49
+ linesAdded: 1,
50
+ linesRemoved: 1,
51
+ });
52
+ });
53
+
54
+ it("counts an added file with no trailing newline by its real line count", () => {
55
+ expect(countLineChanges(undefined, "one\ntwo")).toEqual({
56
+ linesAdded: 2,
57
+ linesRemoved: 0,
58
+ });
59
+ });
60
+
61
+ it("skips counting when a side exceeds the byte guard", () => {
62
+ const huge = "x\n".repeat(LINE_COUNT_MAX_BYTES / 2 + 1);
63
+ expect(countLineChanges(huge, "small\n")).toBeUndefined();
64
+ expect(countLineChanges("small\n", huge)).toBeUndefined();
65
+ });
66
+
67
+ it("guards on UTF-8 byte length, not character count", () => {
68
+ // Each "€" is 3 bytes; a string just over the guard in bytes while well
69
+ // under it in characters must still be skipped.
70
+ const wide = "€".repeat(Math.ceil(LINE_COUNT_MAX_BYTES / 3) + 1);
71
+ expect(countLineChanges(wide, "")).toBeUndefined();
72
+ });
73
+ });
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Unit tests for mid-run live capture (DD-32):
3
+ * - `captureProgressDelta` (git-substrate) against a REAL temp git repo — the
4
+ * only faithful test for git plumbing (kinds, counts, binary, rename→add+del,
5
+ * tree-unchanged short-circuit, excludePaths).
6
+ * - `buildFileChangeProgress` (pure) — secret zeroing, entry cap with honest
7
+ * totals, aggregate sums.
8
+ * - `shouldCaptureProgress` (pure) — the debounce floor.
9
+ */
10
+
11
+ import { execFile } from "node:child_process";
12
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { promisify } from "node:util";
16
+ import { beforeEach, afterEach, describe, expect, it } from "vitest";
17
+ import { FileChangeKind, FileChangeType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
18
+ import {
19
+ captureProgressDelta,
20
+ snapshotBaseline,
21
+ dropCaptureRefs,
22
+ type GitProgressEntry,
23
+ type ProgressDelta,
24
+ } from "../git-substrate.js";
25
+ import {
26
+ buildFileChangeProgress,
27
+ PROGRESS_MAX_ENTRIES,
28
+ shouldCaptureProgress,
29
+ } from "../progress.js";
30
+
31
+ const execFileAsync = promisify(execFile);
32
+ const EXEC_ID = "exec-progress-1";
33
+ const CHANGE_SET_ID = "exec-progress-1:0";
34
+ const RUNNER_OWNED_PATHS: readonly string[] = [".cursor/hooks.json"];
35
+
36
+ let repo: string;
37
+
38
+ async function git(args: string[]): Promise<void> {
39
+ await execFileAsync("git", args, { cwd: repo });
40
+ }
41
+
42
+ async function write(rel: string, content: string): Promise<void> {
43
+ await mkdir(join(repo, rel, ".."), { recursive: true });
44
+ await writeFile(join(repo, rel), content, "utf-8");
45
+ }
46
+
47
+ async function writeBytes(rel: string, bytes: Uint8Array): Promise<void> {
48
+ await mkdir(join(repo, rel, ".."), { recursive: true });
49
+ await writeFile(join(repo, rel), bytes);
50
+ }
51
+
52
+ function entryFor(delta: ProgressDelta, path: string): GitProgressEntry | undefined {
53
+ return delta.entries.find((e) => e.pathAfter === path || e.pathBefore === path);
54
+ }
55
+
56
+ beforeEach(async () => {
57
+ repo = await mkdtemp(join(tmpdir(), "stigmer-progress-"));
58
+ await git(["init", "-q"]);
59
+ await git(["config", "user.email", "t@t.local"]);
60
+ await git(["config", "user.name", "t"]);
61
+ await write("src/main.ts", "export const x = 1;\n");
62
+ await write("keep.txt", "line1\nline2\nline3\n");
63
+ await git(["add", "-A"]);
64
+ await git(["commit", "-q", "-m", "initial"]);
65
+ });
66
+
67
+ afterEach(async () => {
68
+ await dropCaptureRefs(repo, EXEC_ID).catch(() => {});
69
+ await rm(repo, { recursive: true, force: true });
70
+ });
71
+
72
+ describe("captureProgressDelta (real git repo)", () => {
73
+ it("reports ADD / MODIFY / DELETE with line counts and honest path sides", async () => {
74
+ const baseline = await snapshotBaseline(repo, EXEC_ID);
75
+
76
+ await write("src/new.ts", "a\nb\nc\n"); // ADD (3 added)
77
+ await write("src/main.ts", "export const x = 2;\nexport const y = 3;\n"); // MODIFY
78
+ await rm(join(repo, "keep.txt")); // DELETE (3 removed)
79
+
80
+ const delta = await captureProgressDelta(repo, EXEC_ID, baseline);
81
+ expect(delta).toBeDefined();
82
+ expect(delta!.entries).toHaveLength(3);
83
+
84
+ const added = entryFor(delta!, "src/new.ts")!;
85
+ expect(added.changeType).toBe(FileChangeType.CREATE);
86
+ expect(added.pathBefore).toBe("");
87
+ expect(added.pathAfter).toBe("src/new.ts");
88
+ expect(added.linesAdded).toBe(3);
89
+ expect(added.linesRemoved).toBe(0);
90
+
91
+ const modified = entryFor(delta!, "src/main.ts")!;
92
+ expect(modified.changeType).toBe(FileChangeType.MODIFY);
93
+ expect(modified.pathBefore).toBe("src/main.ts");
94
+ expect(modified.pathAfter).toBe("src/main.ts");
95
+ expect(modified.linesAdded).toBeGreaterThan(0);
96
+
97
+ const deleted = entryFor(delta!, "keep.txt")!;
98
+ expect(deleted.changeType).toBe(FileChangeType.DELETE);
99
+ expect(deleted.pathBefore).toBe("keep.txt");
100
+ expect(deleted.pathAfter).toBe("");
101
+ expect(deleted.linesRemoved).toBe(3);
102
+ expect(deleted.linesAdded).toBe(0);
103
+ });
104
+
105
+ it("surfaces a binary change with zero counts (numstat reports '-')", async () => {
106
+ const baseline = await snapshotBaseline(repo, EXEC_ID);
107
+ await writeBytes("blob.bin", new Uint8Array([0, 1, 2, 0, 255, 0, 3]));
108
+
109
+ const delta = await captureProgressDelta(repo, EXEC_ID, baseline);
110
+ const bin = entryFor(delta!, "blob.bin")!;
111
+ expect(bin.changeType).toBe(FileChangeType.CREATE);
112
+ expect(bin.linesAdded).toBe(0);
113
+ expect(bin.linesRemoved).toBe(0);
114
+ });
115
+
116
+ it("splits a rename into a delete + create (--no-renames)", async () => {
117
+ const baseline = await snapshotBaseline(repo, EXEC_ID);
118
+ await rm(join(repo, "keep.txt"));
119
+ await write("renamed.txt", "line1\nline2\nline3\n");
120
+
121
+ const delta = await captureProgressDelta(repo, EXEC_ID, baseline);
122
+ expect(entryFor(delta!, "keep.txt")!.changeType).toBe(FileChangeType.DELETE);
123
+ expect(entryFor(delta!, "renamed.txt")!.changeType).toBe(FileChangeType.CREATE);
124
+ });
125
+
126
+ it("short-circuits (returns undefined) when the tree is unchanged since lastTreeSha", async () => {
127
+ const baseline = await snapshotBaseline(repo, EXEC_ID);
128
+ await write("src/new.ts", "a\nb\n");
129
+
130
+ const first = await captureProgressDelta(repo, EXEC_ID, baseline);
131
+ expect(first).toBeDefined();
132
+
133
+ const second = await captureProgressDelta(repo, EXEC_ID, baseline, [], first!.afterTree);
134
+ expect(second).toBeUndefined();
135
+ });
136
+
137
+ it("recomputes (0 entries) after the agent reverts its own edits", async () => {
138
+ const baseline = await snapshotBaseline(repo, EXEC_ID);
139
+ await write("src/new.ts", "a\nb\n");
140
+ const first = await captureProgressDelta(repo, EXEC_ID, baseline);
141
+
142
+ await rm(join(repo, "src/new.ts")); // revert to clean
143
+ const second = await captureProgressDelta(repo, EXEC_ID, baseline, [], first!.afterTree);
144
+ expect(second).toBeDefined();
145
+ expect(second!.entries).toHaveLength(0);
146
+ });
147
+
148
+ it("excludes runner-owned gate files from the delta", async () => {
149
+ const baseline = await snapshotBaseline(repo, EXEC_ID, RUNNER_OWNED_PATHS);
150
+ await write(".cursor/hooks.json", "{}\n");
151
+ await write("real.ts", "x\n");
152
+
153
+ const delta = await captureProgressDelta(repo, EXEC_ID, baseline, RUNNER_OWNED_PATHS);
154
+ expect(entryFor(delta!, ".cursor/hooks.json")).toBeUndefined();
155
+ expect(entryFor(delta!, "real.ts")).toBeDefined();
156
+ });
157
+ });
158
+
159
+ describe("buildFileChangeProgress (pure)", () => {
160
+ function delta(entries: GitProgressEntry[]): ProgressDelta {
161
+ return { afterTree: "tree-sha", entries };
162
+ }
163
+ function entry(path: string, added: number, removed: number): GitProgressEntry {
164
+ return {
165
+ pathBefore: path,
166
+ pathAfter: path,
167
+ changeType: FileChangeType.MODIFY,
168
+ linesAdded: added,
169
+ linesRemoved: removed,
170
+ };
171
+ }
172
+
173
+ it("sums aggregate counts and maps kind", () => {
174
+ const progress = buildFileChangeProgress(
175
+ delta([
176
+ { pathBefore: "", pathAfter: "a.ts", changeType: FileChangeType.CREATE, linesAdded: 5, linesRemoved: 0 },
177
+ entry("b.ts", 2, 3),
178
+ ]),
179
+ CHANGE_SET_ID,
180
+ );
181
+ expect(progress.changeSetId).toBe(CHANGE_SET_ID);
182
+ expect(progress.filesChanged).toBe(2);
183
+ expect(progress.linesAdded).toBe(7);
184
+ expect(progress.linesRemoved).toBe(3);
185
+ expect(progress.entries[0].kind).toBe(FileChangeKind.ADD);
186
+ expect(progress.entries[1].kind).toBe(FileChangeKind.MODIFY);
187
+ expect(progress.capturedAt).not.toBe("");
188
+ });
189
+
190
+ it("zeroes counts for secret-like paths (path visible, magnitude withheld)", () => {
191
+ const progress = buildFileChangeProgress(
192
+ delta([entry(".env", 10, 2), entry("app.ts", 4, 1)]),
193
+ CHANGE_SET_ID,
194
+ );
195
+ // The secret path is still counted as a changed file and its path is present…
196
+ expect(progress.filesChanged).toBe(2);
197
+ const secret = progress.entries.find((e) => e.pathAfter === ".env")!;
198
+ expect(secret.linesAdded).toBe(0);
199
+ expect(secret.linesRemoved).toBe(0);
200
+ // …but its magnitude is excluded from the aggregate.
201
+ expect(progress.linesAdded).toBe(4);
202
+ expect(progress.linesRemoved).toBe(1);
203
+ });
204
+
205
+ it("caps entries but keeps files_changed and totals honest over all files", () => {
206
+ const entries: GitProgressEntry[] = [];
207
+ for (let i = 0; i < PROGRESS_MAX_ENTRIES + 25; i++) {
208
+ entries.push(entry(`f${i}.ts`, 1, 1));
209
+ }
210
+ const progress = buildFileChangeProgress(delta(entries), CHANGE_SET_ID);
211
+ expect(progress.entries).toHaveLength(PROGRESS_MAX_ENTRIES);
212
+ expect(progress.filesChanged).toBe(PROGRESS_MAX_ENTRIES + 25);
213
+ expect(progress.linesAdded).toBe(PROGRESS_MAX_ENTRIES + 25);
214
+ expect(progress.linesRemoved).toBe(PROGRESS_MAX_ENTRIES + 25);
215
+ });
216
+
217
+ it("produces a zero-file snapshot for an empty delta (revert-to-clean)", () => {
218
+ const progress = buildFileChangeProgress(delta([]), CHANGE_SET_ID);
219
+ expect(progress.filesChanged).toBe(0);
220
+ expect(progress.entries).toHaveLength(0);
221
+ });
222
+ });
223
+
224
+ describe("shouldCaptureProgress (pure)", () => {
225
+ it("always captures on the first call (lastAtMs = 0, real clock)", () => {
226
+ // The caller starts a fresh turn with lastAtMs = 0 and now = Date.now(),
227
+ // which is always far past any floor.
228
+ expect(shouldCaptureProgress(0, Date.now(), 2000)).toBe(true);
229
+ });
230
+
231
+ it("gates within the floor and allows once it elapses", () => {
232
+ expect(shouldCaptureProgress(1000, 2500, 2000)).toBe(false);
233
+ expect(shouldCaptureProgress(1000, 3000, 2000)).toBe(true);
234
+ expect(shouldCaptureProgress(1000, 3001, 2000)).toBe(true);
235
+ });
236
+ });
@@ -77,6 +77,7 @@ import {
77
77
  type CasPathCapture,
78
78
  type CasSnapshotRef,
79
79
  } from "./cas-substrate.js";
80
+ import { isSecretLikePath } from "./secret-paths.js";
80
81
 
81
82
  /**
82
83
  * Whether a turn runs in apply-then-review CAPTURE mode (file edits flow and are
@@ -244,21 +245,41 @@ export async function captureCandidateToLedger(opts: {
244
245
 
245
246
  const unreviewable = unreviewablePaths ?? [];
246
247
 
248
+ // Secret handling is three-way across the substrates, and this is the last of
249
+ // the three (the other two happen upstream at the harness gate):
250
+ // 1. A gitignored secret WRITE never flows — hard-blocked at the gate /
251
+ // deny-gate (DD-12/DD-30). It arrives here only as a path in
252
+ // `unreviewablePaths` (recorded by the gate), authored content-less below.
253
+ // 2. A gitignored secret that flowed under the global bypass is withheld from
254
+ // CAS by `partitionIgnoredPathsBySecret` (secret-paths.ts), so its bytes
255
+ // never reach `casCaptures`.
256
+ // 3. A git-TRACKED secret DID flow (the capture-mode gate allows tracked
257
+ // mutations with no secret check) and is present in `gitChanges` with real
258
+ // bytes. We split it out here and author it content-less, so its content
259
+ // never reaches the ledger. Unlike a gitignored secret it HAS a reversible
260
+ // substrate (the git refs), so it is withheld-and-reverted rather than
261
+ // never-applied — same never-persisted guarantee, appropriate mechanism.
262
+ const { safe: safeGitChanges, secret: secretGitChanges } =
263
+ partitionGitChangesBySecret(gitChanges);
264
+
247
265
  // A turn that changed nothing (no tracked/ignored change and no blocked path)
248
- // authors no event.
266
+ // authors no event. A secret-only tracked turn is NOT a no-op — its file is in
267
+ // `gitChanges`, so the guard below is false and it authors a blocked entry.
249
268
  if (gitChanges.length === 0 && casFiles.length === 0 && unreviewable.length === 0) {
250
269
  return gitChanges;
251
270
  }
252
271
 
253
- // One combined change set: git-tracked (inline bodies) + CAS (blob refs) +
254
- // secret-blocked (content-less, DIFF_UNREVIEWABLE). The aggregate digest folds
255
- // all (buildCandidateCapturedEvent sorts by file digest), so the reviewed diff
256
- // and its identity span every substrate. deriveDiffCompleteness then rolls the
257
- // per-file signals up three ways: any content-less (non-binary) entry forces
258
- // PARTIAL_BLOCKED; a set blocked only by binaries is BINARY_SUMMARY_ONLY
259
- // (keepable in one acknowledged action); else COMPLETE.
272
+ // One combined change set: git-tracked (inline bodies) + git-tracked-secret
273
+ // (content-less) + CAS (blob refs) + gitignored-secret-blocked (content-less).
274
+ // The aggregate digest folds all (buildCandidateCapturedEvent sorts by file
275
+ // digest), so the reviewed diff and its identity span every substrate.
276
+ // deriveDiffCompleteness then rolls the per-file signals up three ways: any
277
+ // content-less (non-binary) entry forces PARTIAL_BLOCKED; a set blocked only by
278
+ // binaries is BINARY_SUMMARY_ONLY (keepable in one acknowledged action); else
279
+ // COMPLETE.
260
280
  const captured = [
261
- ...gitChanges.map((c) => buildCapturedFileChange(toCapturedChangeInput(changeSetId, c))),
281
+ ...safeGitChanges.map((c) => buildCapturedFileChange(toCapturedChangeInput(changeSetId, c))),
282
+ ...secretGitChanges.map((c) => buildCapturedFileChange(trackedSecretChangeInput(changeSetId, c))),
262
283
  ...casFiles.map((f) => buildCapturedFileChange(casToCapturedChangeInput(changeSetId, f))),
263
284
  ...unreviewable.map((p) => buildCapturedFileChange(unreviewableChangeInput(changeSetId, p, unreviewableCaptureClass))),
264
285
  ];
@@ -622,7 +643,9 @@ function candidateCasRef(changeSet: FileChangeSet): CasSnapshotRef | undefined {
622
643
  * after bodies are carried as blob REFS (already offloaded to artifact storage
623
644
  * by the CAS substrate), never re-inlined — so the CANDIDATE event stays small
624
645
  * and the bytes are stored exactly once. Enforcement digests come from the blob
625
- * content addresses, identical in form to the git path.
646
+ * content addresses, identical in form to the git path. The display line counts
647
+ * ride along explicitly — a ref side has no inline text for the producer to
648
+ * count, so the substrate's capture-time counts are the only honest source.
626
649
  */
627
650
  function casToCapturedChangeInput(
628
651
  changeSetId: string,
@@ -634,6 +657,7 @@ function casToCapturedChangeInput(
634
657
  pathAfter: file.pathAfter,
635
658
  kind: file.kind,
636
659
  captureClass: file.captureClass,
660
+ lineCounts: file.lineCounts,
637
661
  before: file.before
638
662
  ? {
639
663
  kind: "ref",
@@ -657,37 +681,117 @@ function casToCapturedChangeInput(
657
681
  }
658
682
 
659
683
  /**
660
- * Map a secret-blocked gitignored path to a content-less producer input (design
661
- * doc 12, DD-E). The bytes are deliberately never captured, so both sides are
662
- * absent (empty enforcement digests) and `diffComplete=false`. Being non-binary
663
- * incomplete with no keepable bytes, it forces the change set to PARTIAL_BLOCKED
664
- * (never BINARY_SUMMARY_ONLY) — approval is blocked and the path is surfaced
665
- * honestly, while its CONTENT never enters the ledger or storage. `blockedReason` records
666
- * the honest cause (SECRET_WITHHELD) so the review UI can say *why* rather than
667
- * showing a cause-agnostic "unavailable" (doc 15). Kind is MODIFY: the write was
668
- * blocked before it ran, so create-vs-modify is unknown and irrelevant (nothing
669
- * is ever applied or reconciled for this entry). `captureClass` is the turn's CAS
670
- * substrate class (GIT_IGNORED_CAPTURED | NON_GIT_CAS) so the blocked path is
671
- * labeled with its true provenance.
684
+ * The single content-less `SECRET_WITHHELD` producer input the one shape every
685
+ * secret-blocked change (gitignored OR git-tracked) is authored with, so the two
686
+ * producers can never drift (a divergence in `diffComplete`/`blockedReason` would
687
+ * make the backend gate treat one kind of secret differently from the other).
688
+ *
689
+ * Both sides are absent (empty enforcement digests) and `diffComplete=false`.
690
+ * Being non-binary incomplete with no keepable bytes, it forces the change set to
691
+ * PARTIAL_BLOCKED (never BINARY_SUMMARY_ONLY) approval is blocked and the path
692
+ * is surfaced honestly, while its CONTENT never enters the ledger or storage.
693
+ * `blockedReason=SECRET_WITHHELD` records the honest cause so the review UI can say
694
+ * *why* rather than a cause-agnostic "unavailable" (doc 15). Callers supply the
695
+ * id / paths / kind / captureClass so each producer keeps its own honest identity.
672
696
  */
673
- function unreviewableChangeInput(
674
- changeSetId: string,
675
- path: string,
697
+ function secretWithheldChangeInput(
698
+ id: string,
699
+ pathBefore: string,
700
+ pathAfter: string,
701
+ kind: FileChangeKind,
676
702
  captureClass: FileCaptureClass,
677
703
  ): CapturedChangeInput {
678
704
  return {
679
- id: `${changeSetId}:${path}`,
680
- pathBefore: path,
681
- pathAfter: path,
682
- kind: FileChangeKind.MODIFY,
705
+ id,
706
+ pathBefore,
707
+ pathAfter,
708
+ kind,
683
709
  captureClass,
684
710
  diffComplete: false,
685
711
  blockedReason: FileReviewBlockReason.SECRET_WITHHELD,
686
712
  };
687
713
  }
688
714
 
715
+ /**
716
+ * Map a secret-blocked GITIGNORED path (the write never flowed — hard-blocked at
717
+ * the harness gate, design doc 12 / DD-E) to a content-less entry. The write was
718
+ * blocked before it ran, so create-vs-modify is unknown and irrelevant (nothing
719
+ * is ever applied or reconciled for this entry) — kind is MODIFY. `captureClass`
720
+ * is the turn's CAS substrate class (GIT_IGNORED_CAPTURED | NON_GIT_CAS).
721
+ */
722
+ function unreviewableChangeInput(
723
+ changeSetId: string,
724
+ path: string,
725
+ captureClass: FileCaptureClass,
726
+ ): CapturedChangeInput {
727
+ return secretWithheldChangeInput(
728
+ `${changeSetId}:${path}`,
729
+ path,
730
+ path,
731
+ FileChangeKind.MODIFY,
732
+ captureClass,
733
+ );
734
+ }
735
+
736
+ /**
737
+ * Map a secret-like GIT-TRACKED change to a content-less entry (DD-26 follow-up
738
+ * #3). Unlike a gitignored secret, a tracked secret write actually FLOWED (the
739
+ * capture-mode gate allows tracked mutations), so it is present in the git diff
740
+ * with real bytes — which must never be persisted into the ledger. We author it
741
+ * content-less here so its CONTENT never reaches the ledger / Temporal history /
742
+ * storage, while the honest kind + paths (a CREATE/MODIFY/DELETE, mirroring
743
+ * {@link toCapturedChangeInput}'s id/path derivation so the resume reconcile
744
+ * lookup matches) and `GIT_TRACKED` provenance are preserved. It is discard-only
745
+ * (PARTIAL_BLOCKED): on resume it can only be rejected, and the git substrate
746
+ * reverts it byte-exact from the baseline ref (the reversible substrate a
747
+ * gitignored secret lacks — so "withhold + revert" upholds never-persisted here
748
+ * without the gate hard-block). A DELETE is handled here too: the git diff
749
+ * resurrects the deleted file's baseline (secret) bytes as the `before` side, a
750
+ * leak only this seam can withhold (the gate sees no content for a delete).
751
+ */
752
+ function trackedSecretChangeInput(
753
+ changeSetId: string,
754
+ change: GitCapturedChange,
755
+ ): CapturedChangeInput {
756
+ const isCreate = change.changeType === FileChangeType.CREATE;
757
+ const isDelete = change.changeType === FileChangeType.DELETE;
758
+ const pathBefore = isCreate ? "" : change.path;
759
+ const pathAfter = isDelete ? "" : change.path;
760
+ return secretWithheldChangeInput(
761
+ `${changeSetId}:${pathAfter || pathBefore}`,
762
+ pathBefore,
763
+ pathAfter,
764
+ toFileChangeKind(change.changeType),
765
+ FileCaptureClass.GIT_TRACKED,
766
+ );
767
+ }
768
+
769
+ /**
770
+ * Split a turn's git-tracked changes by the secret gate: a secret-like tracked
771
+ * path (a committed `credentials.json`, `.env.example`, `*.tfvars`, ...) goes to
772
+ * `secret` (authored content-less via {@link trackedSecretChangeInput}); every
773
+ * other tracked change stays in `safe` (its diff is captured normally). Pure and
774
+ * exported for direct unit testing. Keyed on the same {@link isSecretLikePath}
775
+ * classifier the CAS/gitignored path uses ({@link partitionIgnoredPathsBySecret}
776
+ * in secret-paths.ts), so a path is classified identically everywhere. NOTE: the
777
+ * classifier is path-based (DD-12 D2), so a rename to an innocuous name defeats
778
+ * it — an intentional, cross-substrate limitation, not introduced here.
779
+ */
780
+ export function partitionGitChangesBySecret(changes: readonly GitCapturedChange[]): {
781
+ readonly safe: readonly GitCapturedChange[];
782
+ readonly secret: readonly GitCapturedChange[];
783
+ } {
784
+ const safe: GitCapturedChange[] = [];
785
+ const secret: GitCapturedChange[] = [];
786
+ for (const change of changes) {
787
+ if (isSecretLikePath(change.path)) secret.push(change);
788
+ else safe.push(change);
789
+ }
790
+ return { safe, secret };
791
+ }
792
+
689
793
  /** Map the git capture kind to the file-review {@link FileChangeKind}. */
690
- function toFileChangeKind(changeType: FileChangeType): FileChangeKind {
794
+ export function toFileChangeKind(changeType: FileChangeType): FileChangeKind {
691
795
  switch (changeType) {
692
796
  case FileChangeType.CREATE:
693
797
  return FileChangeKind.ADD;