@stigmer/runner 3.1.3 → 3.1.5

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 (114) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.js +1 -1
  3. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  4. package/dist/activities/execute-cursor/capture-flow.d.ts +26 -15
  5. package/dist/activities/execute-cursor/capture-flow.js +56 -18
  6. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  7. package/dist/activities/execute-cursor/command-provenance.d.ts +11 -25
  8. package/dist/activities/execute-cursor/command-provenance.js +25 -115
  9. package/dist/activities/execute-cursor/command-provenance.js.map +1 -1
  10. package/dist/activities/execute-cursor/index.js +297 -478
  11. package/dist/activities/execute-cursor/index.js.map +1 -1
  12. package/dist/activities/execute-cursor/skill-resolver.js +1 -1
  13. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  14. package/dist/activities/execute-cursor/todo-tracker.d.ts +6 -1
  15. package/dist/activities/execute-cursor/todo-tracker.js +15 -43
  16. package/dist/activities/execute-cursor/todo-tracker.js.map +1 -1
  17. package/dist/activities/execute-cursor/turn-stream.d.ts +141 -0
  18. package/dist/activities/execute-cursor/turn-stream.js +249 -0
  19. package/dist/activities/execute-cursor/turn-stream.js.map +1 -0
  20. package/dist/activities/execute-deep-agent/command-provenance.d.ts +61 -0
  21. package/dist/activities/execute-deep-agent/command-provenance.js +72 -0
  22. package/dist/activities/execute-deep-agent/command-provenance.js.map +1 -0
  23. package/dist/activities/execute-deep-agent/index.js +88 -20
  24. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  25. package/dist/activities/execute-deep-agent/status-builder.js +8 -1
  26. package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
  27. package/dist/activities/execute-deep-agent/v3-status-builder.js +12 -1
  28. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
  29. package/dist/activities/execute-deep-agent/writeback-coordinator.js +5 -1
  30. package/dist/activities/execute-deep-agent/writeback-coordinator.js.map +1 -1
  31. package/dist/otel.js +10 -0
  32. package/dist/otel.js.map +1 -1
  33. package/dist/shared/filereview/cas-progress.d.ts +63 -0
  34. package/dist/shared/filereview/cas-progress.js +128 -0
  35. package/dist/shared/filereview/cas-progress.js.map +1 -0
  36. package/dist/shared/filereview/cas-substrate.d.ts +29 -0
  37. package/dist/shared/filereview/cas-substrate.js +46 -21
  38. package/dist/shared/filereview/cas-substrate.js.map +1 -1
  39. package/dist/shared/filereview/command-provenance.d.ts +93 -0
  40. package/dist/shared/filereview/command-provenance.js +132 -0
  41. package/dist/shared/filereview/command-provenance.js.map +1 -0
  42. package/dist/shared/filereview/git-substrate.d.ts +9 -3
  43. package/dist/shared/filereview/git-substrate.js +4 -0
  44. package/dist/shared/filereview/git-substrate.js.map +1 -1
  45. package/dist/shared/filereview/index.d.ts +4 -3
  46. package/dist/shared/filereview/index.js +3 -2
  47. package/dist/shared/filereview/index.js.map +1 -1
  48. package/dist/shared/filereview/progress.d.ts +105 -34
  49. package/dist/shared/filereview/progress.js +96 -34
  50. package/dist/shared/filereview/progress.js.map +1 -1
  51. package/dist/shared/plan-mode-prompt.d.ts +9 -0
  52. package/dist/shared/plan-mode-prompt.js +18 -0
  53. package/dist/shared/plan-mode-prompt.js.map +1 -1
  54. package/dist/shared/todos.d.ts +56 -0
  55. package/dist/shared/todos.js +98 -0
  56. package/dist/shared/todos.js.map +1 -0
  57. package/dist/shared/tool-row.d.ts +16 -0
  58. package/dist/shared/tool-row.js +31 -0
  59. package/dist/shared/tool-row.js.map +1 -1
  60. package/dist/shared/workspace/git-identity.d.ts +36 -0
  61. package/dist/shared/workspace/git-identity.js +39 -0
  62. package/dist/shared/workspace/git-identity.js.map +1 -0
  63. package/dist/shared/workspace/local-backend.d.ts +6 -2
  64. package/dist/shared/workspace/local-backend.js +6 -2
  65. package/dist/shared/workspace/local-backend.js.map +1 -1
  66. package/dist/shared/workspace/stigmer-link.d.ts +54 -0
  67. package/dist/shared/workspace/stigmer-link.js +92 -0
  68. package/dist/shared/workspace/stigmer-link.js.map +1 -0
  69. package/dist/shared/workspace/types.d.ts +6 -4
  70. package/package.json +2 -2
  71. package/src/__tests__/otel-turn-span.test.ts +61 -0
  72. package/src/activities/execute-cursor/__tests__/progress-substrate.test.ts +169 -0
  73. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +349 -0
  74. package/src/activities/execute-cursor/attachment-resolver.ts +1 -1
  75. package/src/activities/execute-cursor/capture-flow.ts +71 -25
  76. package/src/activities/execute-cursor/command-provenance.ts +25 -120
  77. package/src/activities/execute-cursor/index.ts +345 -505
  78. package/src/activities/execute-cursor/skill-resolver.ts +1 -1
  79. package/src/activities/execute-cursor/todo-tracker.ts +17 -59
  80. package/src/activities/execute-cursor/turn-stream.ts +418 -0
  81. package/src/activities/execute-deep-agent/__tests__/command-provenance.test.ts +252 -0
  82. package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +78 -0
  83. package/src/activities/execute-deep-agent/__tests__/v3-status-builder.test.ts +105 -1
  84. package/src/activities/execute-deep-agent/__tests__/writeback-coordinator.test.ts +30 -3
  85. package/src/activities/execute-deep-agent/command-provenance.ts +102 -0
  86. package/src/activities/execute-deep-agent/index.ts +107 -20
  87. package/src/activities/execute-deep-agent/status-builder.ts +9 -0
  88. package/src/activities/execute-deep-agent/v3-status-builder.ts +13 -0
  89. package/src/activities/execute-deep-agent/writeback-coordinator.ts +5 -1
  90. package/src/otel.ts +8 -0
  91. package/src/shared/__tests__/todos.test.ts +216 -0
  92. package/src/shared/filereview/__tests__/cas-progress.test.ts +228 -0
  93. package/src/shared/filereview/__tests__/cas-substrate.test.ts +66 -0
  94. package/src/shared/filereview/__tests__/command-provenance.test.ts +252 -0
  95. package/src/shared/filereview/__tests__/progress.test.ts +112 -10
  96. package/src/shared/filereview/cas-progress.ts +170 -0
  97. package/src/shared/filereview/cas-substrate.ts +69 -24
  98. package/src/shared/filereview/command-provenance.ts +180 -0
  99. package/src/shared/filereview/git-substrate.ts +13 -3
  100. package/src/shared/filereview/index.ts +15 -1
  101. package/src/shared/filereview/progress.ts +171 -47
  102. package/src/shared/plan-mode-prompt.ts +18 -0
  103. package/src/shared/todos.ts +126 -0
  104. package/src/shared/tool-row.ts +34 -0
  105. package/src/shared/workspace/__tests__/git-identity.test.ts +124 -0
  106. package/src/shared/workspace/__tests__/stigmer-link.test.ts +173 -0
  107. package/src/shared/workspace/git-identity.ts +41 -0
  108. package/src/shared/workspace/local-backend.ts +6 -2
  109. package/src/shared/workspace/stigmer-link.ts +97 -0
  110. package/src/shared/workspace/types.ts +6 -4
  111. package/dist/activities/execute-cursor/stigmer-link.d.ts +0 -35
  112. package/dist/activities/execute-cursor/stigmer-link.js +0 -73
  113. package/dist/activities/execute-cursor/stigmer-link.js.map +0 -1
  114. package/src/activities/execute-cursor/stigmer-link.ts +0 -78
@@ -20,12 +20,18 @@ import {
20
20
  snapshotBaseline,
21
21
  dropCaptureRefs,
22
22
  type GitProgressEntry,
23
- type ProgressDelta,
23
+ type GitProgressDelta,
24
24
  } from "../git-substrate.js";
25
25
  import {
26
26
  buildFileChangeProgress,
27
+ createGitProgressSubstrate,
28
+ createHybridProgressSubstrate,
27
29
  PROGRESS_MAX_ENTRIES,
28
30
  shouldCaptureProgress,
31
+ type ProgressCapture,
32
+ type ProgressDelta,
33
+ type ProgressEntry,
34
+ type ProgressSubstrate,
29
35
  } from "../progress.js";
30
36
 
31
37
  const execFileAsync = promisify(execFile);
@@ -49,7 +55,7 @@ async function writeBytes(rel: string, bytes: Uint8Array): Promise<void> {
49
55
  await writeFile(join(repo, rel), bytes);
50
56
  }
51
57
 
52
- function entryFor(delta: ProgressDelta, path: string): GitProgressEntry | undefined {
58
+ function entryFor(delta: GitProgressDelta, path: string): GitProgressEntry | undefined {
53
59
  return delta.entries.find((e) => e.pathAfter === path || e.pathBefore === path);
54
60
  }
55
61
 
@@ -157,23 +163,28 @@ describe("captureProgressDelta (real git repo)", () => {
157
163
  });
158
164
 
159
165
  describe("buildFileChangeProgress (pure)", () => {
160
- function delta(entries: GitProgressEntry[]): ProgressDelta {
161
- return { afterTree: "tree-sha", entries };
166
+ function delta(entries: ProgressEntry[], totalFilesChanged?: number): ProgressDelta {
167
+ return totalFilesChanged === undefined ? { entries } : { entries, totalFilesChanged };
162
168
  }
163
- function entry(path: string, added: number, removed: number): GitProgressEntry {
169
+ function entry(
170
+ path: string,
171
+ added: number,
172
+ removed: number,
173
+ kind: FileChangeKind = FileChangeKind.MODIFY,
174
+ ): ProgressEntry {
164
175
  return {
165
- pathBefore: path,
176
+ pathBefore: kind === FileChangeKind.ADD ? "" : path,
166
177
  pathAfter: path,
167
- changeType: FileChangeType.MODIFY,
178
+ kind,
168
179
  linesAdded: added,
169
180
  linesRemoved: removed,
170
181
  };
171
182
  }
172
183
 
173
- it("sums aggregate counts and maps kind", () => {
184
+ it("sums aggregate counts and carries the per-entry kind through unchanged", () => {
174
185
  const progress = buildFileChangeProgress(
175
186
  delta([
176
- { pathBefore: "", pathAfter: "a.ts", changeType: FileChangeType.CREATE, linesAdded: 5, linesRemoved: 0 },
187
+ entry("a.ts", 5, 0, FileChangeKind.ADD),
177
188
  entry("b.ts", 2, 3),
178
189
  ]),
179
190
  CHANGE_SET_ID,
@@ -187,6 +198,18 @@ describe("buildFileChangeProgress (pure)", () => {
187
198
  expect(progress.capturedAt).not.toBe("");
188
199
  });
189
200
 
201
+ it("reports totalFilesChanged when a substrate capped its reads, else the entry count", () => {
202
+ // git substrate leaves totalFilesChanged undefined -> filesChanged = entries.length.
203
+ const gitLike = buildFileChangeProgress(delta([entry("a.ts", 1, 0)]), CHANGE_SET_ID);
204
+ expect(gitLike.filesChanged).toBe(1);
205
+
206
+ // cas substrate read only a bounded prefix -> filesChanged reflects the honest
207
+ // total even though fewer entries were emitted.
208
+ const casLike = buildFileChangeProgress(delta([entry("a.ts", 1, 0)], 7), CHANGE_SET_ID);
209
+ expect(casLike.filesChanged).toBe(7);
210
+ expect(casLike.entries).toHaveLength(1);
211
+ });
212
+
190
213
  it("zeroes counts for secret-like paths (path visible, magnitude withheld)", () => {
191
214
  const progress = buildFileChangeProgress(
192
215
  delta([entry(".env", 10, 2), entry("app.ts", 4, 1)]),
@@ -203,7 +226,7 @@ describe("buildFileChangeProgress (pure)", () => {
203
226
  });
204
227
 
205
228
  it("caps entries but keeps files_changed and totals honest over all files", () => {
206
- const entries: GitProgressEntry[] = [];
229
+ const entries: ProgressEntry[] = [];
207
230
  for (let i = 0; i < PROGRESS_MAX_ENTRIES + 25; i++) {
208
231
  entries.push(entry(`f${i}.ts`, 1, 1));
209
232
  }
@@ -234,3 +257,82 @@ describe("shouldCaptureProgress (pure)", () => {
234
257
  expect(shouldCaptureProgress(1000, 3001, 2000)).toBe(true);
235
258
  });
236
259
  });
260
+
261
+ describe("createGitProgressSubstrate (real git repo)", () => {
262
+ it("reports changed:true then reuses the cached full delta on an unchanged tree", async () => {
263
+ const baseline = await snapshotBaseline(repo, EXEC_ID);
264
+ const sub = createGitProgressSubstrate({
265
+ workspaceRoot: repo,
266
+ executionId: EXEC_ID,
267
+ baselineTree: baseline,
268
+ });
269
+
270
+ await write("src/new.ts", "a\nb\n");
271
+ const first = await sub.capture();
272
+ expect(first.changed).toBe(true);
273
+ expect(first.delta.entries.length).toBeGreaterThan(0);
274
+
275
+ // Nothing moved since the last capture: the tree-sha short-circuits, but the
276
+ // full delta is still returned (changed:false) so a hybrid can merge it.
277
+ const second = await sub.capture();
278
+ expect(second.changed).toBe(false);
279
+ expect(second.delta.entries).toEqual(first.delta.entries);
280
+ });
281
+ });
282
+
283
+ /** A fake substrate returning a fixed capture — for merge/short-circuit logic. */
284
+ function fakeSubstrate(capture: ProgressCapture): ProgressSubstrate {
285
+ return { capture: () => Promise.resolve(capture) };
286
+ }
287
+
288
+ function progressEntry(path: string): ProgressEntry {
289
+ return { pathBefore: "", pathAfter: path, kind: FileChangeKind.ADD, linesAdded: 1, linesRemoved: 0 };
290
+ }
291
+
292
+ describe("createHybridProgressSubstrate", () => {
293
+ it("concatenates disjoint git + cas slices and sums the honest totals", async () => {
294
+ const git = fakeSubstrate({
295
+ delta: { entries: [progressEntry("tracked.ts")] }, // git: total = entries.length
296
+ changed: true,
297
+ });
298
+ const cas = fakeSubstrate({
299
+ delta: { entries: [progressEntry(".env.local")], totalFilesChanged: 3 }, // cas capped
300
+ changed: true,
301
+ });
302
+ const hybrid = createHybridProgressSubstrate(git, cas);
303
+
304
+ const { delta, changed } = await hybrid.capture();
305
+ expect(changed).toBe(true);
306
+ expect(delta.entries.map((e) => e.pathAfter)).toEqual(["tracked.ts", ".env.local"]);
307
+ // 1 (git entries) + 3 (cas honest total) = 4.
308
+ expect(delta.totalFilesChanged).toBe(4);
309
+ });
310
+
311
+ it("still emits the full git slice when only the cas slice changed", async () => {
312
+ // Git unchanged returns its cached full delta with changed:false; the hybrid
313
+ // must keep it (a `ProgressDelta | undefined` contract would have dropped it).
314
+ const git = fakeSubstrate({
315
+ delta: { entries: [progressEntry("tracked.ts")] },
316
+ changed: false,
317
+ });
318
+ const cas = fakeSubstrate({
319
+ delta: { entries: [progressEntry("build/out.js")], totalFilesChanged: 1 },
320
+ changed: true,
321
+ });
322
+ const hybrid = createHybridProgressSubstrate(git, cas);
323
+
324
+ const { delta, changed } = await hybrid.capture();
325
+ expect(changed).toBe(true);
326
+ expect(delta.entries.map((e) => e.pathAfter)).toEqual(["tracked.ts", "build/out.js"]);
327
+ expect(delta.totalFilesChanged).toBe(2);
328
+ });
329
+
330
+ it("reports changed:false only when NEITHER slice moved", async () => {
331
+ const git = fakeSubstrate({ delta: { entries: [] }, changed: false });
332
+ const cas = fakeSubstrate({ delta: { entries: [], totalFilesChanged: 0 }, changed: false });
333
+ const hybrid = createHybridProgressSubstrate(git, cas);
334
+
335
+ const { changed } = await hybrid.capture();
336
+ expect(changed).toBe(false);
337
+ });
338
+ });
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Mid-run progress substrate for the CAS (content-addressed) domain — the
3
+ * non-git and gitignored half of DD-33. The git substrate ({@link ./progress.js}
4
+ * `createGitProgressSubstrate`) covers git-tracked changes cheaply via
5
+ * `--numstat`; this covers the paths git cannot see, sourced from the same
6
+ * per-turn observer the turn-boundary CAS capture reads:
7
+ * - a non-git workspace: EVERY tool-mediated write (the whole change set), and
8
+ * - a git tree's gitignored writes (the CAS half of a HYBRID turn).
9
+ *
10
+ * COST MODEL (why this is not as cheap as git)
11
+ * --------------------------------------------
12
+ * `git --numstat` yields exact `+N −M` with zero byte reads; CAS has no such free
13
+ * lunch — kind, no-op exclusion, and counts all need the after-bytes. So we bound
14
+ * the work: only the sorted read-PREFIX (up to {@link PROGRESS_MAX_ENTRIES}) has
15
+ * its after-bytes read and counted; the tail contributes to `totalFilesChanged`
16
+ * (the honest count) only. `files_changed` is therefore net-exact for a normal
17
+ * turn (≤ the budget) and an upper bound beyond it — matching git where git is
18
+ * free, diverging only where the substrate cannot be. A `size+mtime` signature
19
+ * (the stats are collected anyway) short-circuits an unchanged capture.
20
+ *
21
+ * CONSISTENCY & SECRET SAFETY
22
+ * ---------------------------
23
+ * Counts come from {@link classifyCasChange} — the SAME classifier the reviewed
24
+ * set uses — so the strip and the change set never disagree. Secret-like paths
25
+ * are excluded up front via {@link partitionIgnoredPathsBySecret} (and zeroed
26
+ * again in `buildFileChangeProgress` as a backstop); no file bodies are ever
27
+ * carried on the wire. Only tool-mediated writes are observed, exactly the scope
28
+ * of the non-git / HYBRID turn-boundary capture — no new divergence.
29
+ *
30
+ * @since File-Change HITL Redesign (non-git + hybrid mid-run progress / DD-33)
31
+ */
32
+
33
+ import { readFile, stat } from "node:fs/promises";
34
+ import { join } from "node:path";
35
+ import { FileChangeKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
36
+ import { classifyCasChange } from "./cas-substrate.js";
37
+ import { LINE_COUNT_MAX_BYTES } from "./line-counts.js";
38
+ import {
39
+ PROGRESS_MAX_ENTRIES,
40
+ type ProgressCapture,
41
+ type ProgressDelta,
42
+ type ProgressEntry,
43
+ type ProgressSubstrate,
44
+ } from "./progress.js";
45
+ import { partitionIgnoredPathsBySecret } from "./secret-paths.js";
46
+
47
+ /**
48
+ * A snapshot of the turn's CAS-observed paths, read on each capture:
49
+ * - `before`: first-touched paths' pre-turn bytes (`null` = the path did not
50
+ * exist before → an ADD). The KEYS are the touched-path set.
51
+ * - `blockedSecretPaths`: paths the gate hard-blocked as secret-like — excluded
52
+ * from capture.
53
+ *
54
+ * Deep-agent supplies a synchronous copy of its live `CasCaptureObserver`; Cursor
55
+ * supplies a reader over the hook's on-disk sidecar. The reader MUST return an
56
+ * atomic snapshot (copy the live map before any await) so a concurrent sub-agent
57
+ * write cannot mutate it mid-capture.
58
+ */
59
+ export interface CasTouchedSnapshot {
60
+ readonly before: ReadonlyMap<string, Uint8Array | null>;
61
+ readonly blockedSecretPaths: ReadonlySet<string>;
62
+ }
63
+
64
+ /** Produces a {@link CasTouchedSnapshot} for one capture. */
65
+ export type CasTouchedReader = () => CasTouchedSnapshot | Promise<CasTouchedSnapshot>;
66
+
67
+ /**
68
+ * The CAS progress substrate. Reads the touched set on each capture, computes
69
+ * kind + `+N −M` for the bounded read-prefix via {@link classifyCasChange}, and
70
+ * reports `totalFilesChanged` over the full capturable set. Owns a `size+mtime`
71
+ * signature so an unchanged capture returns `changed:false` (the cached delta is
72
+ * reused, letting the hybrid still merge it with a changed git slice).
73
+ */
74
+ export function createCasProgressSubstrate(opts: {
75
+ readonly workspaceRoot: string;
76
+ readonly read: CasTouchedReader;
77
+ /** Read budget / display cap; defaults to {@link PROGRESS_MAX_ENTRIES}. Injectable for tests. */
78
+ readonly maxEntries?: number;
79
+ }): ProgressSubstrate {
80
+ const maxEntries = opts.maxEntries ?? PROGRESS_MAX_ENTRIES;
81
+ let cachedFull: ProgressDelta = { entries: [], totalFilesChanged: 0 };
82
+ let lastSignature: string | undefined;
83
+
84
+ return {
85
+ async capture(): Promise<ProgressCapture> {
86
+ const snapshot = await opts.read();
87
+ const { capturablePaths } = partitionIgnoredPathsBySecret(
88
+ snapshot.before.keys(),
89
+ snapshot.blockedSecretPaths,
90
+ );
91
+ // Deterministic order so the read prefix and entry order are stable.
92
+ const sorted = [...capturablePaths].sort();
93
+ const prefix = sorted.slice(0, maxEntries);
94
+
95
+ const entries: ProgressEntry[] = [];
96
+ // The signature keys on the capturable count (catches a new touch) plus the
97
+ // read prefix's size+mtime (catches a content edit to a shown file). A
98
+ // change to an UNshown tail file has no display effect, so it is not keyed.
99
+ const sigParts: string[] = [`${capturablePaths.length}`];
100
+
101
+ for (const relPath of prefix) {
102
+ const abs = join(opts.workspaceRoot, relPath);
103
+ const st = await statOrNull(abs);
104
+ sigParts.push(`${relPath}\u0000${st ? `${st.size}:${st.mtimeMs}` : "\u2205"}`);
105
+
106
+ const beforeBytes = snapshot.before.get(relPath) ?? null;
107
+ const beforeBuf = beforeBytes === null ? null : Buffer.from(beforeBytes);
108
+
109
+ // Oversized after: derive the kind without reading (bound the I/O) and
110
+ // leave counts at zero — the same "no stat" shape a binary/oversized side
111
+ // takes in the reviewed set. A same-size no-op cannot be excluded here; an
112
+ // accepted overstatement only for very large files.
113
+ if (st && st.size > LINE_COUNT_MAX_BYTES) {
114
+ const kind = beforeBuf === null ? FileChangeKind.ADD : FileChangeKind.MODIFY;
115
+ entries.push({
116
+ pathBefore: kind === FileChangeKind.ADD ? "" : relPath,
117
+ pathAfter: relPath,
118
+ kind,
119
+ linesAdded: 0,
120
+ linesRemoved: 0,
121
+ });
122
+ continue;
123
+ }
124
+
125
+ const afterBytes = st ? await readFileOrNull(abs) : null;
126
+ const afterBuf = afterBytes === null ? null : Buffer.from(afterBytes);
127
+ const cls = classifyCasChange(relPath, beforeBuf, afterBuf);
128
+ if (!cls) continue; // no-op (unchanged touch) or both sides absent
129
+ entries.push({
130
+ pathBefore: cls.pathBefore,
131
+ pathAfter: cls.pathAfter,
132
+ kind: cls.kind,
133
+ linesAdded: cls.lineCounts?.linesAdded ?? 0,
134
+ linesRemoved: cls.lineCounts?.linesRemoved ?? 0,
135
+ });
136
+ }
137
+
138
+ // Honest total: the capturable set minus the no-ops detected in the read
139
+ // prefix. Net-exact for a turn within the read budget; an upper bound beyond
140
+ // it (tail no-ops are not read, so cannot be excluded).
141
+ const prefixNoOps = prefix.length - entries.length;
142
+ const totalFilesChanged = capturablePaths.length - prefixNoOps;
143
+
144
+ const signature = sigParts.join("|");
145
+ if (signature === lastSignature) {
146
+ return { delta: cachedFull, changed: false };
147
+ }
148
+ lastSignature = signature;
149
+ cachedFull = { entries, totalFilesChanged };
150
+ return { delta: cachedFull, changed: true };
151
+ },
152
+ };
153
+ }
154
+
155
+ async function statOrNull(abs: string): Promise<{ size: number; mtimeMs: number } | null> {
156
+ try {
157
+ const s = await stat(abs);
158
+ return { size: s.size, mtimeMs: s.mtimeMs };
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ async function readFileOrNull(abs: string): Promise<Uint8Array | null> {
165
+ try {
166
+ return await readFile(abs);
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
@@ -189,6 +189,66 @@ export async function snapshotCasChangeSet(opts: {
189
189
  };
190
190
  }
191
191
 
192
+ /**
193
+ * The display-facing classification of one CAS path's before/after bytes: the
194
+ * change kind, whether either side is binary, and the `+N −M` line counts.
195
+ */
196
+ export interface CasChangeClassification {
197
+ /** Path before the change (workspace-relative). Empty for ADD. */
198
+ readonly pathBefore: string;
199
+ /** Path after the change. Empty for DELETE. */
200
+ readonly pathAfter: string;
201
+ readonly kind: FileChangeKind;
202
+ /** A NUL byte on either side — the diff cannot render as text. */
203
+ readonly isBinary: boolean;
204
+ /** `+N −M` from the in-memory bytes; absent for a binary or oversized side. */
205
+ readonly lineCounts?: LineChangeCounts;
206
+ }
207
+
208
+ /**
209
+ * Classify one CAS path's before/after bytes. Returns `undefined` for a
210
+ * non-change — both sides absent, or a touch that left the bytes unchanged —
211
+ * exactly as the git substrate's diff omits unchanged files.
212
+ *
213
+ * This is the SINGLE classification authority for a CAS before/after pair: the
214
+ * turn-boundary capture ({@link buildCasCapturedFile}) and the mid-run progress
215
+ * producer ({@link ./cas-progress.js}) both route through it, so the live "N
216
+ * files changed so far" strip and the reviewed change set can never disagree on
217
+ * an edge case (the trailing-newline class of bug {@link ./line-counts.js}
218
+ * warns about). Kind, no-op detection, binary detection, and counting live here
219
+ * and nowhere else.
220
+ */
221
+ export function classifyCasChange(
222
+ path: string,
223
+ beforeBuf: Buffer | null,
224
+ afterBuf: Buffer | null,
225
+ ): CasChangeClassification | undefined {
226
+ if (beforeBuf === null && afterBuf === null) return undefined;
227
+ // A touch that did not change the bytes is not a reviewable change.
228
+ if (beforeBuf && afterBuf && beforeBuf.equals(afterBuf)) return undefined;
229
+
230
+ const kind =
231
+ beforeBuf === null
232
+ ? FileChangeKind.ADD
233
+ : afterBuf === null
234
+ ? FileChangeKind.DELETE
235
+ : FileChangeKind.MODIFY;
236
+ const isBinary =
237
+ (beforeBuf !== null && bytesLookBinary(beforeBuf)) ||
238
+ (afterBuf !== null && bytesLookBinary(afterBuf));
239
+
240
+ return {
241
+ pathBefore: kind === FileChangeKind.ADD ? "" : path,
242
+ pathAfter: kind === FileChangeKind.DELETE ? "" : path,
243
+ kind,
244
+ isBinary,
245
+ // A binary change has no text line diff to count.
246
+ lineCounts: isBinary
247
+ ? undefined
248
+ : countLineChanges(beforeBuf?.toString("utf8"), afterBuf?.toString("utf8")),
249
+ };
250
+ }
251
+
192
252
  /**
193
253
  * Store one path's before/after blobs and classify the change. Returns
194
254
  * `undefined` for a no-op (both sides absent, or an unchanged touch).
@@ -199,44 +259,29 @@ async function buildCasCapturedFile(
199
259
  capture: CasPathCapture,
200
260
  ): Promise<CasCapturedFile | undefined> {
201
261
  const { path, before, after, captureClass } = capture;
202
- if (before === null && after === null) return undefined;
203
-
204
262
  const beforeBuf = before === null ? null : Buffer.from(before);
205
263
  const afterBuf = after === null ? null : Buffer.from(after);
206
264
 
207
- // A touch that did not change the bytes is not a reviewable change.
208
- if (beforeBuf && afterBuf && beforeBuf.equals(afterBuf)) return undefined;
265
+ const classification = classifyCasChange(path, beforeBuf, afterBuf);
266
+ if (!classification) return undefined;
209
267
 
210
268
  const beforeRef = beforeBuf ? await storeBlob(storage, executionId, beforeBuf) : undefined;
211
269
  const afterRef = afterBuf ? await storeBlob(storage, executionId, afterBuf) : undefined;
212
270
 
213
- const kind =
214
- beforeRef === undefined
215
- ? FileChangeKind.ADD
216
- : afterRef === undefined
217
- ? FileChangeKind.DELETE
218
- : FileChangeKind.MODIFY;
219
-
220
- const isCreate = kind === FileChangeKind.ADD;
221
- const isDelete = kind === FileChangeKind.DELETE;
222
- const isBinary = beforeRef?.isBinary || afterRef?.isBinary;
223
-
224
271
  return {
225
- pathBefore: isCreate ? "" : path,
226
- pathAfter: isDelete ? "" : path,
227
- kind,
272
+ pathBefore: classification.pathBefore,
273
+ pathAfter: classification.pathAfter,
274
+ kind: classification.kind,
228
275
  captureClass,
229
276
  before: beforeRef,
230
277
  after: afterRef,
231
278
  // Binary on either side means the diff cannot render as text; the change set
232
279
  // then cannot be approved as complete (parity with the git substrate).
233
- diffComplete: !isBinary,
280
+ diffComplete: !classification.isBinary,
234
281
  // Display counts are taken NOW, while the text bytes are still in memory —
235
- // after this they exist only as offloaded blobs. A binary change has no
236
- // line diff to count.
237
- lineCounts: isBinary
238
- ? undefined
239
- : countLineChanges(beforeBuf?.toString("utf8"), afterBuf?.toString("utf8")),
282
+ // after this they exist only as offloaded blobs (the same counts the mid-run
283
+ // progress producer derives via classifyCasChange).
284
+ lineCounts: classification.lineCounts,
240
285
  };
241
286
  }
242
287
 
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The DD-28 approved-command turn qualification rule — the harness-agnostic core
3
+ * of the auto-keep policy's runner-side facts.
4
+ *
5
+ * A turn whose ONLY mutation source was shell commands the human had already
6
+ * authorized should not re-gate its file effects at the turn-boundary review —
7
+ * the user consented to the command, and the command's file effects are the
8
+ * consented outcome. The server cannot derive this itself (tool calls carry no
9
+ * turn marker), so the runner — the only component that owns turn scoping —
10
+ * derives the facts and attaches them to the CANDIDATE_CAPTURED event as
11
+ * {@link TurnCommandProvenance}.
12
+ *
13
+ * This module owns the *qualification rule* both harnesses share; each harness
14
+ * supplies only what genuinely differs — how "this turn's tool calls" are scoped,
15
+ * and how a single executed command's DIRECT consent row is identified — via
16
+ * {@link CommandProvenanceCoreInputs}. Keeping the rule in one place is what makes
17
+ * the Cursor and deep-agent qualifications provably identical (a second copy would
18
+ * drift on edge cases — the divergence the AI-Engineer role warns against).
19
+ *
20
+ * TRUST BOUNDARY. This module asserts turn FACTS (which tools ran this turn),
21
+ * the same trust level as the captured bytes themselves. It never asserts
22
+ * CONSENT: a resolved consent id merely POINTS at a transcript row whose
23
+ * `approval_action` was authored by the server's SubmitApproval (and is
24
+ * preserved against runner writes) — the backend re-verifies every claimed row
25
+ * against that server-owned record before authoring the policy decision, so a
26
+ * runner cannot mint authorization it was never given.
27
+ *
28
+ * FAIL-CLOSED. Every uncertainty disqualifies (returns undefined → the set
29
+ * reviews manually, exactly as before DD-28): a file-tool call, an MCP tool, a
30
+ * sub-agent delegation, an unrecognized tool name, or an executed shell command
31
+ * with no provable consent source. Being conservative here costs only an extra
32
+ * review; being permissive would silently waive one.
33
+ */
34
+
35
+ import { create } from "@bufbuild/protobuf";
36
+ import type { AgentMessage, ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
37
+ import { TurnCommandProvenanceSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
38
+ import type { TurnCommandProvenance } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
39
+ import { ApprovalAction, ToolKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
40
+ import { classifyTool, toolApprovalCategory } from "../tool-kind.js";
41
+ import { isToolCallRowHidden } from "../tool-row.js";
42
+
43
+ /**
44
+ * Tool kinds that can never mutate the workspace: their presence in a turn is
45
+ * irrelevant to change-set provenance. Everything OUTSIDE this set is either a
46
+ * consent-mapped shell command or a disqualifier — never silently ignored.
47
+ */
48
+ export const NON_MUTATING_KINDS: ReadonlySet<ToolKind> = new Set([
49
+ ToolKind.FILE_READ,
50
+ ToolKind.SEARCH,
51
+ ToolKind.LIST,
52
+ ToolKind.FETCH,
53
+ ToolKind.WEB_SEARCH,
54
+ ToolKind.THINK,
55
+ ToolKind.TODO,
56
+ ]);
57
+
58
+ /**
59
+ * The harness-supplied inputs to the shared DD-28 qualification rule. The two
60
+ * function inputs are exactly the two things that differ between harnesses
61
+ * (turn scoping is expressed by which calls appear in {@link turnToolCalls}).
62
+ */
63
+ export interface CommandProvenanceCoreInputs {
64
+ /**
65
+ * This turn's tool calls, already scoped by the harness (Cursor: the positional
66
+ * slice from the turn's first streamed message; deep-agent: the calls whose id
67
+ * is new since a pre-stream settled snapshot). Order is preserved but irrelevant.
68
+ */
69
+ readonly turnToolCalls: readonly ToolCall[];
70
+ /**
71
+ * The WHOLE transcript, for the APPROVE_ALL lease lookup — a lease is
72
+ * run-lifetime and its granting row usually lives in a prior turn.
73
+ */
74
+ readonly messages: readonly AgentMessage[];
75
+ /**
76
+ * Whether a (shell) tool call actually EXECUTED this turn, as opposed to being
77
+ * a pending/denied gate that produced no mutation. A non-executed shell neither
78
+ * needs consent nor disqualifies (Cursor: not in the denial ledger; deep-agent:
79
+ * status is COMPLETED).
80
+ */
81
+ readonly isExecutedCommand: (tc: ToolCall) => boolean;
82
+ /**
83
+ * The tool-call id of the row whose server-authored `approval_action` DIRECTLY
84
+ * authorized this executed command, or undefined when none does (then the lease
85
+ * / global-bypass fallbacks apply). Cursor maps a reinvocation grant token back
86
+ * to its consent row; the deep-agent's gated command IS its own consent row
87
+ * (same id, `approval_action` written in place).
88
+ */
89
+ readonly resolveDirectConsent: (tc: ToolCall) => string | undefined;
90
+ /** True when the pre-armed spec.auto_approve_all bypassed the gate. */
91
+ readonly globalBypass: boolean;
92
+ }
93
+
94
+ /**
95
+ * Apply the DD-28 qualification rule to a harness-scoped turn, returning the
96
+ * {@link TurnCommandProvenance} to attach to the candidate — or undefined when
97
+ * the turn does not qualify (any non-shell mutation-capable call, any unknown
98
+ * tool, any executed command without a provable consent source, or no executed
99
+ * consented command at all).
100
+ */
101
+ export function qualifyTurnCommandProvenance(
102
+ inputs: CommandProvenanceCoreInputs,
103
+ ): TurnCommandProvenance | undefined {
104
+ const { turnToolCalls, messages, isExecutedCommand, resolveDirectConsent, globalBypass } = inputs;
105
+
106
+ const consentIds = new Set<string>();
107
+ let authorizedByAutoApproveAll = false;
108
+ let executedCommandCount = 0;
109
+
110
+ for (const tc of turnToolCalls) {
111
+ // A hidden row is a collapsed twin/reaction — it never executed.
112
+ if (isToolCallRowHidden(tc)) continue;
113
+
114
+ const kind = classifyTool(tc.name, tc.mcpServerSlug);
115
+ if (NON_MUTATING_KINDS.has(kind)) continue;
116
+
117
+ if (kind !== ToolKind.SHELL) {
118
+ // A file tool (mixed turn), a sub-agent (unattributable mutations), an
119
+ // MCP tool (opaque side effects), or an unrecognized name — the turn's
120
+ // mutations cannot be attributed to consented commands. Fail closed.
121
+ return undefined;
122
+ }
123
+
124
+ if (!isExecutedCommand(tc)) {
125
+ // A pending / denied shell — this turn's gate, not a mutation source.
126
+ continue;
127
+ }
128
+ executedCommandCount++;
129
+
130
+ const consentId = resolveDirectConsent(tc);
131
+ if (consentId) {
132
+ consentIds.add(consentId);
133
+ continue;
134
+ }
135
+ const leaseConsentId = findLeaseConsentId(messages, tc.name);
136
+ if (leaseConsentId) {
137
+ consentIds.add(leaseConsentId);
138
+ continue;
139
+ }
140
+ if (globalBypass) {
141
+ authorizedByAutoApproveAll = true;
142
+ continue;
143
+ }
144
+ // An executed shell command with no grant, no lease, and no bypass should
145
+ // be impossible (every un-consented shell is gated); if it ever happens,
146
+ // the honest answer is a manual review, not a waived one.
147
+ return undefined;
148
+ }
149
+
150
+ // A turn that executed no consented command has nothing to attribute the
151
+ // change set to — whatever changed came from somewhere else. Manual review.
152
+ if (executedCommandCount === 0) return undefined;
153
+
154
+ return create(TurnCommandProvenanceSchema, {
155
+ consentToolCallIds: [...consentIds],
156
+ authorizedByAutoApproveAll,
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Find the transcript row whose APPROVE_ALL authored the run-lifetime lease
162
+ * covering `toolName`'s category — the consent row a lease-executed command
163
+ * cites. Searched across the whole transcript (a lease usually originates in a
164
+ * prior turn); the first (earliest) APPROVE_ALL of the category is the lease's
165
+ * origin. Returns undefined when no lease row exists for the category.
166
+ */
167
+ export function findLeaseConsentId(
168
+ messages: readonly AgentMessage[],
169
+ toolName: string,
170
+ ): string | undefined {
171
+ const category = toolApprovalCategory(toolName);
172
+ if (!category) return undefined;
173
+ for (const msg of messages) {
174
+ for (const tc of msg.toolCalls) {
175
+ if (tc.approvalAction !== ApprovalAction.APPROVE_ALL) continue;
176
+ if (toolApprovalCategory(tc.name) === category) return tc.id;
177
+ }
178
+ }
179
+ return undefined;
180
+ }
@@ -97,6 +97,10 @@ export function captureRef(executionId: string): string {
97
97
  * garbage collection — but `git commit-tree` still requires an author/committer,
98
98
  * and the repo may have no user.name/email configured. Supplied via env so we
99
99
  * never mutate the repo's config.
100
+ *
101
+ * Deliberately distinct from the public agent identity in
102
+ * `../workspace/git-identity.ts`, which authors the write-back commits that
103
+ * land on real branches and pull requests.
100
104
  */
101
105
  const SNAPSHOT_IDENTITY_ENV: Record<string, string> = {
102
106
  GIT_AUTHOR_NAME: "stigmer-runner",
@@ -325,8 +329,14 @@ export interface GitProgressEntry {
325
329
  readonly linesRemoved: number;
326
330
  }
327
331
 
328
- /** Result of {@link captureProgressDelta}. */
329
- export interface ProgressDelta {
332
+ /**
333
+ * Result of {@link captureProgressDelta} — the git-numstat delta. Distinct from
334
+ * the substrate-neutral `ProgressDelta` (in {@link ./progress.js}) that the
335
+ * git/cas/hybrid substrates emit: this carries the working-tree sha (for the
336
+ * short-circuit) and git's `FileChangeType` entries; the neutral one carries
337
+ * proto `FileChangeKind` entries and an honest total.
338
+ */
339
+ export interface GitProgressDelta {
330
340
  /** The post-diff working-tree sha, for the caller's short-circuit cache. */
331
341
  readonly afterTree: string;
332
342
  /** One count-only entry per changed file (no content). */
@@ -357,7 +367,7 @@ export async function captureProgressDelta(
357
367
  baselineTree: string,
358
368
  excludePaths: readonly string[] = [],
359
369
  lastTreeSha?: string,
360
- ): Promise<ProgressDelta | undefined> {
370
+ ): Promise<GitProgressDelta | undefined> {
361
371
  const gitDir = await resolveGitDir(gitRoot);
362
372
  const afterTree = await writeWorkingTree(gitRoot, gitDir, "progress", executionId, excludePaths);
363
373
  if (lastTreeSha !== undefined && afterTree === lastTreeSha) return undefined;