@stigmer/runner 3.1.3 → 3.1.4

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 (83) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/capture-flow.d.ts +26 -15
  3. package/dist/activities/execute-cursor/capture-flow.js +56 -18
  4. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  5. package/dist/activities/execute-cursor/command-provenance.d.ts +11 -25
  6. package/dist/activities/execute-cursor/command-provenance.js +25 -115
  7. package/dist/activities/execute-cursor/command-provenance.js.map +1 -1
  8. package/dist/activities/execute-cursor/index.js +296 -477
  9. package/dist/activities/execute-cursor/index.js.map +1 -1
  10. package/dist/activities/execute-cursor/todo-tracker.d.ts +6 -1
  11. package/dist/activities/execute-cursor/todo-tracker.js +15 -43
  12. package/dist/activities/execute-cursor/todo-tracker.js.map +1 -1
  13. package/dist/activities/execute-cursor/turn-stream.d.ts +141 -0
  14. package/dist/activities/execute-cursor/turn-stream.js +249 -0
  15. package/dist/activities/execute-cursor/turn-stream.js.map +1 -0
  16. package/dist/activities/execute-deep-agent/command-provenance.d.ts +61 -0
  17. package/dist/activities/execute-deep-agent/command-provenance.js +72 -0
  18. package/dist/activities/execute-deep-agent/command-provenance.js.map +1 -0
  19. package/dist/activities/execute-deep-agent/index.js +60 -18
  20. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  21. package/dist/activities/execute-deep-agent/status-builder.js +8 -1
  22. package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/v3-status-builder.js +12 -1
  24. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
  25. package/dist/otel.js +10 -0
  26. package/dist/otel.js.map +1 -1
  27. package/dist/shared/filereview/cas-progress.d.ts +63 -0
  28. package/dist/shared/filereview/cas-progress.js +128 -0
  29. package/dist/shared/filereview/cas-progress.js.map +1 -0
  30. package/dist/shared/filereview/cas-substrate.d.ts +29 -0
  31. package/dist/shared/filereview/cas-substrate.js +46 -21
  32. package/dist/shared/filereview/cas-substrate.js.map +1 -1
  33. package/dist/shared/filereview/command-provenance.d.ts +93 -0
  34. package/dist/shared/filereview/command-provenance.js +132 -0
  35. package/dist/shared/filereview/command-provenance.js.map +1 -0
  36. package/dist/shared/filereview/git-substrate.d.ts +9 -3
  37. package/dist/shared/filereview/git-substrate.js.map +1 -1
  38. package/dist/shared/filereview/index.d.ts +4 -3
  39. package/dist/shared/filereview/index.js +3 -2
  40. package/dist/shared/filereview/index.js.map +1 -1
  41. package/dist/shared/filereview/progress.d.ts +105 -34
  42. package/dist/shared/filereview/progress.js +96 -34
  43. package/dist/shared/filereview/progress.js.map +1 -1
  44. package/dist/shared/plan-mode-prompt.d.ts +9 -0
  45. package/dist/shared/plan-mode-prompt.js +18 -0
  46. package/dist/shared/plan-mode-prompt.js.map +1 -1
  47. package/dist/shared/todos.d.ts +56 -0
  48. package/dist/shared/todos.js +98 -0
  49. package/dist/shared/todos.js.map +1 -0
  50. package/dist/shared/tool-row.d.ts +16 -0
  51. package/dist/shared/tool-row.js +31 -0
  52. package/dist/shared/tool-row.js.map +1 -1
  53. package/package.json +2 -2
  54. package/src/__tests__/otel-turn-span.test.ts +61 -0
  55. package/src/activities/execute-cursor/__tests__/progress-substrate.test.ts +169 -0
  56. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +349 -0
  57. package/src/activities/execute-cursor/capture-flow.ts +71 -25
  58. package/src/activities/execute-cursor/command-provenance.ts +25 -120
  59. package/src/activities/execute-cursor/index.ts +344 -504
  60. package/src/activities/execute-cursor/todo-tracker.ts +17 -59
  61. package/src/activities/execute-cursor/turn-stream.ts +418 -0
  62. package/src/activities/execute-deep-agent/__tests__/command-provenance.test.ts +252 -0
  63. package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +78 -0
  64. package/src/activities/execute-deep-agent/__tests__/v3-status-builder.test.ts +105 -1
  65. package/src/activities/execute-deep-agent/command-provenance.ts +102 -0
  66. package/src/activities/execute-deep-agent/index.ts +74 -18
  67. package/src/activities/execute-deep-agent/status-builder.ts +9 -0
  68. package/src/activities/execute-deep-agent/v3-status-builder.ts +13 -0
  69. package/src/otel.ts +8 -0
  70. package/src/shared/__tests__/todos.test.ts +216 -0
  71. package/src/shared/filereview/__tests__/cas-progress.test.ts +228 -0
  72. package/src/shared/filereview/__tests__/cas-substrate.test.ts +66 -0
  73. package/src/shared/filereview/__tests__/command-provenance.test.ts +252 -0
  74. package/src/shared/filereview/__tests__/progress.test.ts +112 -10
  75. package/src/shared/filereview/cas-progress.ts +170 -0
  76. package/src/shared/filereview/cas-substrate.ts +69 -24
  77. package/src/shared/filereview/command-provenance.ts +180 -0
  78. package/src/shared/filereview/git-substrate.ts +9 -3
  79. package/src/shared/filereview/index.ts +15 -1
  80. package/src/shared/filereview/progress.ts +171 -47
  81. package/src/shared/plan-mode-prompt.ts +18 -0
  82. package/src/shared/todos.ts +126 -0
  83. package/src/shared/tool-row.ts +34 -0
@@ -24,6 +24,7 @@ import {
24
24
  casBlobKey,
25
25
  casBlobReader,
26
26
  casManifestKey,
27
+ classifyCasChange,
27
28
  loadCasManifest,
28
29
  restoreCasToBaseline,
29
30
  snapshotCasChangeSet,
@@ -436,3 +437,68 @@ describe("digest independence from capture class", () => {
436
437
  expect(files.map((f) => f.pathAfter)).toEqual(["a", "m", "z"]);
437
438
  });
438
439
  });
440
+
441
+ // The single classification authority for a CAS before/after pair. Both the
442
+ // turn-boundary capture (buildCasCapturedFile, exercised via snapshotCasChangeSet)
443
+ // and the mid-run progress producer (cas-progress) route through it, so these
444
+ // cases lock the shape the strip and the reviewed set share.
445
+ describe("classifyCasChange (pure)", () => {
446
+ const buf = (s: string): Buffer => Buffer.from(s, "utf8");
447
+
448
+ it("classifies ADD from a null before and blanks the before path", () => {
449
+ const c = classifyCasChange("new.ts", null, buf("a\nb\nc\n"))!;
450
+ expect(c.kind).toBe(FileChangeKind.ADD);
451
+ expect(c.pathBefore).toBe("");
452
+ expect(c.pathAfter).toBe("new.ts");
453
+ expect(c.isBinary).toBe(false);
454
+ expect(c.lineCounts).toEqual({ linesAdded: 3, linesRemoved: 0 });
455
+ });
456
+
457
+ it("classifies DELETE from a null after and blanks the after path", () => {
458
+ const c = classifyCasChange("gone.ts", buf("x\ny\n"), null)!;
459
+ expect(c.kind).toBe(FileChangeKind.DELETE);
460
+ expect(c.pathBefore).toBe("gone.ts");
461
+ expect(c.pathAfter).toBe("");
462
+ expect(c.lineCounts).toEqual({ linesAdded: 0, linesRemoved: 2 });
463
+ });
464
+
465
+ it("classifies MODIFY from two present sides", () => {
466
+ const c = classifyCasChange("m.ts", buf("a\n"), buf("a\nb\n"))!;
467
+ expect(c.kind).toBe(FileChangeKind.MODIFY);
468
+ expect(c.pathBefore).toBe("m.ts");
469
+ expect(c.pathAfter).toBe("m.ts");
470
+ expect(c.lineCounts).toEqual({ linesAdded: 1, linesRemoved: 0 });
471
+ });
472
+
473
+ it("returns undefined for both-null and for a no-op touch (unchanged bytes)", () => {
474
+ expect(classifyCasChange("p", null, null)).toBeUndefined();
475
+ expect(classifyCasChange("p", buf("same\n"), buf("same\n"))).toBeUndefined();
476
+ });
477
+
478
+ it("marks a binary side and withholds line counts", () => {
479
+ const bin = Buffer.from([0, 1, 2, 0, 255, 0]);
480
+ const c = classifyCasChange("blob.bin", null, bin)!;
481
+ expect(c.kind).toBe(FileChangeKind.ADD);
482
+ expect(c.isBinary).toBe(true);
483
+ expect(c.lineCounts).toBeUndefined();
484
+ });
485
+
486
+ it("is the authority snapshotCasChangeSet uses (parity guard)", async () => {
487
+ const { storage } = makeFakeStorage();
488
+ const before = bytes("one\ntwo\n");
489
+ const after = bytes("one\ntwo\nthree\n");
490
+ const { manifest } = await snapshotCasChangeSet({
491
+ storage, executionId: EXEC, changeSetId: CHANGE_SET,
492
+ captures: [{ path: "m.ts", before, after, captureClass: IGNORED }],
493
+ });
494
+ const file = manifest.files[0];
495
+ const direct = classifyCasChange("m.ts", Buffer.from(before), Buffer.from(after))!;
496
+ // The captured file's display classification is exactly what the shared
497
+ // classifier produces — no second authority that could drift.
498
+ expect(file.kind).toBe(direct.kind);
499
+ expect(file.pathBefore).toBe(direct.pathBefore);
500
+ expect(file.pathAfter).toBe(direct.pathAfter);
501
+ expect(file.diffComplete).toBe(!direct.isBinary);
502
+ expect(file.lineCounts).toEqual(direct.lineCounts);
503
+ });
504
+ });
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Unit tests for the shared DD-28 qualification rule
3
+ * ({@link qualifyTurnCommandProvenance}) — the harness-agnostic core both the
4
+ * Cursor and deep-agent adapters delegate to.
5
+ *
6
+ * These exercise the rule in isolation over synthetic `turnToolCalls` (the
7
+ * harness supplies the scoping + consent resolvers); each harness's own mapping
8
+ * is covered by its adapter suite. The rule: a turn qualifies only when every
9
+ * mutation-capable call it executed was a shell command with a provable consent
10
+ * source (direct / lease / auto_approve_all), and every uncertainty fails closed.
11
+ */
12
+
13
+ import { describe, it, expect } from "vitest";
14
+ import { create, type MessageInitShape } from "@bufbuild/protobuf";
15
+ import {
16
+ AgentMessageSchema,
17
+ ToolCallSchema,
18
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
19
+ import type { AgentMessage, ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
20
+ import {
21
+ ApprovalAction,
22
+ MessageType,
23
+ ToolCallStatus,
24
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
25
+
26
+ import { qualifyTurnCommandProvenance, findLeaseConsentId } from "../command-provenance.js";
27
+
28
+ function toolCall(overrides: MessageInitShape<typeof ToolCallSchema>): ToolCall {
29
+ return create(ToolCallSchema, {
30
+ id: "call-1",
31
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
32
+ ...overrides,
33
+ });
34
+ }
35
+
36
+ function aiMessageWith(toolCalls: ToolCall[]): AgentMessage {
37
+ return create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, toolCalls });
38
+ }
39
+
40
+ // Default resolvers for the common "no direct consent, every listed call ran"
41
+ // case — individual tests override as needed.
42
+ const executedAlways = (): boolean => true;
43
+ const noDirectConsent = (): undefined => undefined;
44
+
45
+ function shell(id: string, cmd = "seq 1 5 > out.txt"): ToolCall {
46
+ return toolCall({ id, name: "shell", args: { command: cmd } });
47
+ }
48
+
49
+ describe("qualifyTurnCommandProvenance", () => {
50
+ it("cites the direct consent id the resolver returns", () => {
51
+ const tc = shell("shell-1");
52
+ const provenance = qualifyTurnCommandProvenance({
53
+ turnToolCalls: [tc],
54
+ messages: [aiMessageWith([tc])],
55
+ isExecutedCommand: executedAlways,
56
+ resolveDirectConsent: (t) => (t.id === "shell-1" ? "consent-row" : undefined),
57
+ globalBypass: false,
58
+ });
59
+ expect(provenance).toBeDefined();
60
+ expect(provenance!.consentToolCallIds).toEqual(["consent-row"]);
61
+ expect(provenance!.authorizedByAutoApproveAll).toBe(false);
62
+ });
63
+
64
+ it("falls back to the APPROVE_ALL lease row when there is no direct consent", () => {
65
+ const leaseRow = toolCall({
66
+ id: "lease-row",
67
+ name: "shell",
68
+ status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
69
+ approvalAction: ApprovalAction.APPROVE_ALL,
70
+ });
71
+ const leased = shell("shell-leased");
72
+ const messages = [aiMessageWith([leaseRow]), aiMessageWith([leased])];
73
+ const provenance = qualifyTurnCommandProvenance({
74
+ turnToolCalls: [leased],
75
+ messages,
76
+ isExecutedCommand: executedAlways,
77
+ resolveDirectConsent: noDirectConsent,
78
+ globalBypass: false,
79
+ });
80
+ expect(provenance).toBeDefined();
81
+ expect(provenance!.consentToolCallIds).toEqual(["lease-row"]);
82
+ });
83
+
84
+ it("flags auto_approve_all when only the global bypass authorized the command", () => {
85
+ const tc = shell("shell-1");
86
+ const provenance = qualifyTurnCommandProvenance({
87
+ turnToolCalls: [tc],
88
+ messages: [aiMessageWith([tc])],
89
+ isExecutedCommand: executedAlways,
90
+ resolveDirectConsent: noDirectConsent,
91
+ globalBypass: true,
92
+ });
93
+ expect(provenance).toBeDefined();
94
+ expect(provenance!.consentToolCallIds).toEqual([]);
95
+ expect(provenance!.authorizedByAutoApproveAll).toBe(true);
96
+ });
97
+
98
+ it("prefers direct consent over auto_approve_all when both are available", () => {
99
+ const tc = shell("shell-1");
100
+ const provenance = qualifyTurnCommandProvenance({
101
+ turnToolCalls: [tc],
102
+ messages: [aiMessageWith([tc])],
103
+ isExecutedCommand: executedAlways,
104
+ resolveDirectConsent: () => "consent-row",
105
+ globalBypass: true,
106
+ });
107
+ expect(provenance!.consentToolCallIds).toEqual(["consent-row"]);
108
+ expect(provenance!.authorizedByAutoApproveAll).toBe(false);
109
+ });
110
+
111
+ it("fails closed on a file-tool call (mixed turn)", () => {
112
+ const tc = shell("shell-1");
113
+ const edit = toolCall({ id: "edit-1", name: "edit", args: { path: "a.txt" } });
114
+ const provenance = qualifyTurnCommandProvenance({
115
+ turnToolCalls: [tc, edit],
116
+ messages: [aiMessageWith([tc, edit])],
117
+ isExecutedCommand: executedAlways,
118
+ resolveDirectConsent: () => "consent-row",
119
+ globalBypass: false,
120
+ });
121
+ expect(provenance).toBeUndefined();
122
+ });
123
+
124
+ it("fails closed on an MCP tool", () => {
125
+ const tc = shell("shell-1");
126
+ const mcp = toolCall({ id: "mcp-1", name: "apply_resource", mcpServerSlug: "planton" });
127
+ const provenance = qualifyTurnCommandProvenance({
128
+ turnToolCalls: [tc, mcp],
129
+ messages: [aiMessageWith([tc, mcp])],
130
+ isExecutedCommand: executedAlways,
131
+ resolveDirectConsent: () => "consent-row",
132
+ globalBypass: false,
133
+ });
134
+ expect(provenance).toBeUndefined();
135
+ });
136
+
137
+ it("fails closed on a sub-agent delegation (task -> SUBAGENT kind)", () => {
138
+ const tc = shell("shell-1");
139
+ const task = toolCall({ id: "task-1", name: "task", args: { prompt: "go" } });
140
+ const provenance = qualifyTurnCommandProvenance({
141
+ turnToolCalls: [tc, task],
142
+ messages: [aiMessageWith([tc, task])],
143
+ isExecutedCommand: executedAlways,
144
+ resolveDirectConsent: () => "consent-row",
145
+ globalBypass: false,
146
+ });
147
+ expect(provenance).toBeUndefined();
148
+ });
149
+
150
+ it("fails closed on an unrecognized tool name", () => {
151
+ const tc = shell("shell-1");
152
+ const unknown = toolCall({ id: "u-1", name: "mystery_tool" });
153
+ const provenance = qualifyTurnCommandProvenance({
154
+ turnToolCalls: [tc, unknown],
155
+ messages: [aiMessageWith([tc, unknown])],
156
+ isExecutedCommand: executedAlways,
157
+ resolveDirectConsent: () => "consent-row",
158
+ globalBypass: false,
159
+ });
160
+ expect(provenance).toBeUndefined();
161
+ });
162
+
163
+ it("fails closed on an executed command with no provable consent source", () => {
164
+ const tc = shell("shell-1");
165
+ const provenance = qualifyTurnCommandProvenance({
166
+ turnToolCalls: [tc],
167
+ messages: [aiMessageWith([tc])],
168
+ isExecutedCommand: executedAlways,
169
+ resolveDirectConsent: noDirectConsent,
170
+ globalBypass: false,
171
+ });
172
+ expect(provenance).toBeUndefined();
173
+ });
174
+
175
+ it("returns undefined when no command executed (only a pending shell)", () => {
176
+ const pending = shell("shell-pending");
177
+ const provenance = qualifyTurnCommandProvenance({
178
+ turnToolCalls: [pending],
179
+ messages: [aiMessageWith([pending])],
180
+ isExecutedCommand: () => false, // pending / denied — never executed
181
+ resolveDirectConsent: () => "consent-row",
182
+ globalBypass: false,
183
+ });
184
+ expect(provenance).toBeUndefined();
185
+ });
186
+
187
+ it("skips read-only tools and hidden collapsed rows, then qualifies", () => {
188
+ const read = toolCall({ id: "read-1", name: "read", args: { path: "a.txt" } });
189
+ const hidden = toolCall({
190
+ id: "hidden-1",
191
+ name: "shell",
192
+ status: ToolCallStatus.TOOL_CALL_SKIPPED,
193
+ args: undefined,
194
+ });
195
+ const executed = shell("shell-executed");
196
+ const provenance = qualifyTurnCommandProvenance({
197
+ turnToolCalls: [read, hidden, executed],
198
+ messages: [aiMessageWith([read, hidden, executed])],
199
+ isExecutedCommand: executedAlways,
200
+ resolveDirectConsent: (t) => (t.id === "shell-executed" ? "consent-row" : undefined),
201
+ globalBypass: false,
202
+ });
203
+ expect(provenance).toBeDefined();
204
+ expect(provenance!.consentToolCallIds).toEqual(["consent-row"]);
205
+ });
206
+
207
+ it("dedupes consent ids across multiple commands citing the same lease", () => {
208
+ const leaseRow = toolCall({
209
+ id: "lease-row",
210
+ name: "shell",
211
+ status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
212
+ approvalAction: ApprovalAction.APPROVE_ALL,
213
+ });
214
+ const a = shell("shell-a");
215
+ const b = shell("shell-b");
216
+ const messages = [aiMessageWith([leaseRow]), aiMessageWith([a, b])];
217
+ const provenance = qualifyTurnCommandProvenance({
218
+ turnToolCalls: [a, b],
219
+ messages,
220
+ isExecutedCommand: executedAlways,
221
+ resolveDirectConsent: noDirectConsent,
222
+ globalBypass: false,
223
+ });
224
+ expect(provenance).toBeDefined();
225
+ expect(provenance!.consentToolCallIds).toEqual(["lease-row"]);
226
+ });
227
+ });
228
+
229
+ describe("findLeaseConsentId", () => {
230
+ it("returns the earliest APPROVE_ALL row of the tool's category", () => {
231
+ const first = toolCall({ id: "lease-1", name: "shell", approvalAction: ApprovalAction.APPROVE_ALL });
232
+ const second = toolCall({ id: "lease-2", name: "bash", approvalAction: ApprovalAction.APPROVE_ALL });
233
+ const messages = [aiMessageWith([first]), aiMessageWith([second])];
234
+ expect(findLeaseConsentId(messages, "execute")).toBe("lease-1");
235
+ });
236
+
237
+ it("returns undefined when no APPROVE_ALL lease exists for the category", () => {
238
+ const writeApproveAll = toolCall({
239
+ id: "w-1",
240
+ name: "write",
241
+ approvalAction: ApprovalAction.APPROVE_ALL,
242
+ });
243
+ const messages = [aiMessageWith([writeApproveAll])];
244
+ // A write-category lease does not authorize a shell command.
245
+ expect(findLeaseConsentId(messages, "shell")).toBeUndefined();
246
+ });
247
+
248
+ it("returns undefined for a non-gated (read-only) tool name", () => {
249
+ const messages = [aiMessageWith([shell("s")])];
250
+ expect(findLeaseConsentId(messages, "read")).toBeUndefined();
251
+ });
252
+ });
@@ -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
+ }