@stigmer/runner 3.1.2 → 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 (127) 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 +29 -0
  5. package/dist/activities/execute-cursor/capture-flow.js +59 -0
  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 +358 -557
  11. package/dist/activities/execute-cursor/index.js.map +1 -1
  12. package/dist/activities/execute-cursor/todo-tracker.d.ts +6 -1
  13. package/dist/activities/execute-cursor/todo-tracker.js +15 -43
  14. package/dist/activities/execute-cursor/todo-tracker.js.map +1 -1
  15. package/dist/activities/execute-cursor/turn-boundary.d.ts +108 -0
  16. package/dist/activities/execute-cursor/turn-boundary.js +163 -0
  17. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -0
  18. package/dist/activities/execute-cursor/turn-stream.d.ts +141 -0
  19. package/dist/activities/execute-cursor/turn-stream.js +249 -0
  20. package/dist/activities/execute-cursor/turn-stream.js.map +1 -0
  21. package/dist/activities/execute-deep-agent/command-provenance.d.ts +61 -0
  22. package/dist/activities/execute-deep-agent/command-provenance.js +72 -0
  23. package/dist/activities/execute-deep-agent/command-provenance.js.map +1 -0
  24. package/dist/activities/execute-deep-agent/index.js +73 -4
  25. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  26. package/dist/activities/execute-deep-agent/status-builder.js +8 -1
  27. package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
  28. package/dist/activities/execute-deep-agent/streaming-v3.js +6 -1
  29. package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
  30. package/dist/activities/execute-deep-agent/streaming.d.ts +7 -0
  31. package/dist/activities/execute-deep-agent/streaming.js +5 -1
  32. package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/v3-status-builder.js +12 -1
  34. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
  35. package/dist/otel.js +10 -0
  36. package/dist/otel.js.map +1 -1
  37. package/dist/shared/filereview/capture.d.ts +18 -1
  38. package/dist/shared/filereview/capture.js +105 -28
  39. package/dist/shared/filereview/capture.js.map +1 -1
  40. package/dist/shared/filereview/cas-progress.d.ts +63 -0
  41. package/dist/shared/filereview/cas-progress.js +128 -0
  42. package/dist/shared/filereview/cas-progress.js.map +1 -0
  43. package/dist/shared/filereview/cas-substrate.d.ts +38 -0
  44. package/dist/shared/filereview/cas-substrate.js +48 -15
  45. package/dist/shared/filereview/cas-substrate.js.map +1 -1
  46. package/dist/shared/filereview/command-provenance.d.ts +93 -0
  47. package/dist/shared/filereview/command-provenance.js +132 -0
  48. package/dist/shared/filereview/command-provenance.js.map +1 -0
  49. package/dist/shared/filereview/events.d.ts +16 -0
  50. package/dist/shared/filereview/events.js +37 -0
  51. package/dist/shared/filereview/events.js.map +1 -1
  52. package/dist/shared/filereview/git-substrate.d.ts +48 -0
  53. package/dist/shared/filereview/git-substrate.js +78 -0
  54. package/dist/shared/filereview/git-substrate.js.map +1 -1
  55. package/dist/shared/filereview/index.d.ts +5 -2
  56. package/dist/shared/filereview/index.js +5 -2
  57. package/dist/shared/filereview/index.js.map +1 -1
  58. package/dist/shared/filereview/line-counts.d.ts +48 -0
  59. package/dist/shared/filereview/line-counts.js +72 -0
  60. package/dist/shared/filereview/line-counts.js.map +1 -0
  61. package/dist/shared/filereview/progress.d.ts +167 -0
  62. package/dist/shared/filereview/progress.js +196 -0
  63. package/dist/shared/filereview/progress.js.map +1 -0
  64. package/dist/shared/implement-plan-prompt.d.ts +6 -4
  65. package/dist/shared/implement-plan-prompt.js +38 -6
  66. package/dist/shared/implement-plan-prompt.js.map +1 -1
  67. package/dist/shared/plan-artifact.d.ts +59 -11
  68. package/dist/shared/plan-artifact.js +144 -16
  69. package/dist/shared/plan-artifact.js.map +1 -1
  70. package/dist/shared/plan-mode-prompt.d.ts +15 -4
  71. package/dist/shared/plan-mode-prompt.js +28 -5
  72. package/dist/shared/plan-mode-prompt.js.map +1 -1
  73. package/dist/shared/todos.d.ts +56 -0
  74. package/dist/shared/todos.js +98 -0
  75. package/dist/shared/todos.js.map +1 -0
  76. package/dist/shared/tool-row.d.ts +16 -0
  77. package/dist/shared/tool-row.js +31 -0
  78. package/dist/shared/tool-row.js.map +1 -1
  79. package/package.json +3 -2
  80. package/src/__tests__/otel-turn-span.test.ts +61 -0
  81. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +7 -0
  82. package/src/activities/execute-cursor/__tests__/delta-enricher.test.ts +68 -0
  83. package/src/activities/execute-cursor/__tests__/progress-substrate.test.ts +169 -0
  84. package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
  85. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +349 -0
  86. package/src/activities/execute-cursor/attachment-resolver.ts +1 -1
  87. package/src/activities/execute-cursor/capture-flow.ts +78 -0
  88. package/src/activities/execute-cursor/command-provenance.ts +25 -120
  89. package/src/activities/execute-cursor/index.ts +419 -602
  90. package/src/activities/execute-cursor/todo-tracker.ts +17 -59
  91. package/src/activities/execute-cursor/turn-boundary.ts +274 -0
  92. package/src/activities/execute-cursor/turn-stream.ts +418 -0
  93. package/src/activities/execute-deep-agent/__tests__/command-provenance.test.ts +252 -0
  94. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +11 -0
  95. package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +78 -0
  96. package/src/activities/execute-deep-agent/__tests__/v3-status-builder.test.ts +105 -1
  97. package/src/activities/execute-deep-agent/command-provenance.ts +102 -0
  98. package/src/activities/execute-deep-agent/index.ts +91 -4
  99. package/src/activities/execute-deep-agent/status-builder.ts +9 -0
  100. package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
  101. package/src/activities/execute-deep-agent/streaming.ts +12 -1
  102. package/src/activities/execute-deep-agent/v3-status-builder.ts +13 -0
  103. package/src/otel.ts +8 -0
  104. package/src/shared/__tests__/implement-plan-prompt.test.ts +40 -2
  105. package/src/shared/__tests__/plan-artifact.test.ts +142 -9
  106. package/src/shared/__tests__/todos.test.ts +216 -0
  107. package/src/shared/filereview/__tests__/capture.test.ts +182 -1
  108. package/src/shared/filereview/__tests__/cas-progress.test.ts +228 -0
  109. package/src/shared/filereview/__tests__/cas-substrate.test.ts +100 -0
  110. package/src/shared/filereview/__tests__/command-provenance.test.ts +252 -0
  111. package/src/shared/filereview/__tests__/events.test.ts +105 -0
  112. package/src/shared/filereview/__tests__/line-counts.test.ts +73 -0
  113. package/src/shared/filereview/__tests__/progress.test.ts +338 -0
  114. package/src/shared/filereview/capture.ts +134 -30
  115. package/src/shared/filereview/cas-progress.ts +170 -0
  116. package/src/shared/filereview/cas-substrate.ts +79 -18
  117. package/src/shared/filereview/command-provenance.ts +180 -0
  118. package/src/shared/filereview/events.ts +50 -0
  119. package/src/shared/filereview/git-substrate.ts +114 -0
  120. package/src/shared/filereview/index.ts +36 -1
  121. package/src/shared/filereview/line-counts.ts +83 -0
  122. package/src/shared/filereview/progress.ts +310 -0
  123. package/src/shared/implement-plan-prompt.ts +39 -8
  124. package/src/shared/plan-artifact.ts +152 -16
  125. package/src/shared/plan-mode-prompt.ts +28 -5
  126. package/src/shared/todos.ts +126 -0
  127. package/src/shared/tool-row.ts +34 -0
@@ -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
+ }
@@ -42,6 +42,7 @@ import {
42
42
  FileReviewFailureKind,
43
43
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
44
44
  import { aggregateDigest, fileDigest, sha256Bytes } from "./digest.js";
45
+ import { countLineChanges, type LineChangeCounts } from "./line-counts.js";
45
46
  import { looksBinary } from "../file-change.js";
46
47
 
47
48
  // The runner is the author of capture/reconcile/failure events. "runner" (not
@@ -150,6 +151,15 @@ export interface CapturedChangeInput {
150
151
  * this UNSPECIFIED. See {@link FileReviewBlockReason}.
151
152
  */
152
153
  readonly blockedReason?: FileReviewBlockReason;
154
+ /**
155
+ * Display line counts for this change, for producers whose bodies are not
156
+ * inline here (the CAS substrate counts from the in-memory bytes before
157
+ * offloading them as blob refs). Omit for inline content —
158
+ * {@link buildCapturedFileChange} then counts the inline sides itself, with
159
+ * the same shared {@link countLineChanges}. Informational only, never folded
160
+ * into the digests.
161
+ */
162
+ readonly lineCounts?: LineChangeCounts;
153
163
  }
154
164
 
155
165
  /** Normalize the string-shorthand to a {@link CapturedContent}. */
@@ -175,6 +185,37 @@ export function contentSha256(content: CapturedContent): string {
175
185
  }
176
186
  }
177
187
 
188
+ /**
189
+ * Count the display `+N −M` from a change's inline sides, or `undefined` when
190
+ * counting is not honest here: a side that exists without inline text (a `ref`
191
+ * whose bytes were already offloaded, or a `binary` side with no body), or
192
+ * inline text that would render as binary (the UI shows "Binary file changed"
193
+ * for it, never a line diff — a count would describe a diff nobody sees).
194
+ * An absent side is fine — it is the empty document of an ADD/DELETE.
195
+ */
196
+ function countInlineSides(
197
+ before: CapturedContent | undefined,
198
+ after: CapturedContent | undefined,
199
+ ): LineChangeCounts | undefined {
200
+ const beforeText = inlineTextOrNull(before);
201
+ const afterText = inlineTextOrNull(after);
202
+ if (beforeText === null || afterText === null) return undefined;
203
+ return countLineChanges(beforeText, afterText);
204
+ }
205
+
206
+ /**
207
+ * A side's countable inline text: the text for a non-binary inline side,
208
+ * `undefined` for an absent side (countable as the empty document), and `null`
209
+ * when the side exists but cannot be counted (ref / binary / binary-looking).
210
+ */
211
+ function inlineTextOrNull(
212
+ content: CapturedContent | undefined,
213
+ ): string | undefined | null {
214
+ if (content === undefined) return undefined;
215
+ if (content.kind !== "inline" || looksBinary(content.text)) return null;
216
+ return content.text;
217
+ }
218
+
178
219
  /** Build the proto {@link FileContent} for one content side (inline, ref, or binary). */
179
220
  function toFileContent(content: CapturedContent): FileContent {
180
221
  if (content.kind === "inline") {
@@ -212,12 +253,19 @@ function toFileContent(content: CapturedContent): FileContent {
212
253
  * `file_digest`) over the captured bytes. Content may be inline (git) or a blob
213
254
  * ref (CAS) — the digests are identical either way, so the aggregate digest and
214
255
  * the reconcile enforcement compose across both substrates.
256
+ *
257
+ * Display line counts are stamped here too — the single seam every substrate
258
+ * flows through — from `input.lineCounts` when the producer counted at its own
259
+ * source (CAS), else counted from the inline sides. Counting happens BEFORE any
260
+ * persist-time offload, so the counts survive a body being elided later. They
261
+ * never enter `fileDigest` (informational, not enforcement).
215
262
  */
216
263
  export function buildCapturedFileChange(input: CapturedChangeInput): CapturedFileChange {
217
264
  const before = normalizeContent(input.before);
218
265
  const after = normalizeContent(input.after);
219
266
  const beforeSha256 = before ? contentSha256(before) : "";
220
267
  const afterSha256 = after ? contentSha256(after) : "";
268
+ const counts = input.lineCounts ?? countInlineSides(before, after);
221
269
 
222
270
  const fc = create(CapturedFileChangeSchema, {
223
271
  id: input.id,
@@ -229,6 +277,8 @@ export function buildCapturedFileChange(input: CapturedChangeInput): CapturedFil
229
277
  afterSha256,
230
278
  diffComplete: input.diffComplete ?? true,
231
279
  blockedReason: input.blockedReason ?? FileReviewBlockReason.UNSPECIFIED,
280
+ linesAdded: counts?.linesAdded ?? 0,
281
+ linesRemoved: counts?.linesRemoved ?? 0,
232
282
  fileDigest: fileDigest({
233
283
  pathBefore: input.pathBefore,
234
284
  pathAfter: input.pathAfter,
@@ -308,6 +308,87 @@ export async function captureChangeSet(
308
308
  return { baselineTree, afterTree, changes };
309
309
  }
310
310
 
311
+ /**
312
+ * One file's slim, content-free progress delta: paths + kind + line counts, NO
313
+ * bytes. The mid-run analogue of {@link GitSubstrateChange} (which carries the
314
+ * before/after {@link CapturedContent}); this deliberately carries neither side,
315
+ * so it can never leak content. `linesAdded`/`linesRemoved` are 0 when
316
+ * uncountable (a binary file — numstat reports `-`).
317
+ */
318
+ export interface GitProgressEntry {
319
+ /** Path before the change (repo-relative). Empty for a CREATE. */
320
+ readonly pathBefore: string;
321
+ /** Path after the change (repo-relative). Empty for a DELETE. */
322
+ readonly pathAfter: string;
323
+ readonly changeType: FileChangeType;
324
+ readonly linesAdded: number;
325
+ readonly linesRemoved: number;
326
+ }
327
+
328
+ /**
329
+ * Result of {@link captureProgressDelta} — the git-numstat delta. Distinct from
330
+ * the substrate-neutral `ProgressDelta` (in {@link ./progress.js}) that the
331
+ * git/cas/hybrid substrates emit: this carries the working-tree sha (for the
332
+ * short-circuit) and git's `FileChangeType` entries; the neutral one carries
333
+ * proto `FileChangeKind` entries and an honest total.
334
+ */
335
+ export interface GitProgressDelta {
336
+ /** The post-diff working-tree sha, for the caller's short-circuit cache. */
337
+ readonly afterTree: string;
338
+ /** One count-only entry per changed file (no content). */
339
+ readonly entries: readonly GitProgressEntry[];
340
+ }
341
+
342
+ /**
343
+ * Capture a NON-AUTHORITATIVE, content-free snapshot of the working-tree delta
344
+ * against `baselineTree` — the substrate for the live "N files changed so far"
345
+ * surface (mid-run live capture). Unlike {@link captureChangeSet} this reads NO
346
+ * file bytes: it stages the working tree into a temp index, writes its tree, and
347
+ * asks git for `--numstat` + `--name-status` only. Cheap enough to run on a
348
+ * debounce during the turn.
349
+ *
350
+ * Short-circuit: returns `undefined` when the working tree is unchanged since
351
+ * `lastTreeSha` (the caller passes back the previous result's `afterTree`), so a
352
+ * quiet turn pays only one `write-tree`, never a diff.
353
+ *
354
+ * Uses a dedicated temp-index label ("progress") so it never collides with the
355
+ * `baseline`/`capture`/`approved` indexes. `--no-renames` matches
356
+ * {@link captureChangeSet} (a rename surfaces as delete + create). A torn read of
357
+ * a file being written mid-turn is acceptable — the snapshot is non-authoritative
358
+ * and self-corrects on the next capture.
359
+ */
360
+ export async function captureProgressDelta(
361
+ gitRoot: string,
362
+ executionId: string,
363
+ baselineTree: string,
364
+ excludePaths: readonly string[] = [],
365
+ lastTreeSha?: string,
366
+ ): Promise<GitProgressDelta | undefined> {
367
+ const gitDir = await resolveGitDir(gitRoot);
368
+ const afterTree = await writeWorkingTree(gitRoot, gitDir, "progress", executionId, excludePaths);
369
+ if (lastTreeSha !== undefined && afterTree === lastTreeSha) return undefined;
370
+
371
+ const [nameStatusRaw, numstatRaw] = await Promise.all([
372
+ git(gitRoot, ["diff", "--no-renames", "--name-status", "-z", baselineTree, afterTree]),
373
+ git(gitRoot, ["diff", "--no-renames", "--numstat", "-z", baselineTree, afterTree]),
374
+ ]);
375
+
376
+ const counts = parseNumstatZ(numstatRaw);
377
+ const entries: GitProgressEntry[] = [];
378
+ for (const { status, path } of parseNameStatusZ(nameStatusRaw)) {
379
+ const changeType = nameStatusToChangeType(status);
380
+ const count = counts.get(path);
381
+ entries.push({
382
+ pathBefore: changeType === FileChangeType.CREATE ? "" : path,
383
+ pathAfter: changeType === FileChangeType.DELETE ? "" : path,
384
+ changeType,
385
+ linesAdded: count?.added ?? 0,
386
+ linesRemoved: count?.removed ?? 0,
387
+ });
388
+ }
389
+ return { afterTree, entries };
390
+ }
391
+
311
392
  /**
312
393
  * Restore the given files to their `baselineTree` (pre-turn) bytes. On resume
313
394
  * this reverts the REJECTED/undecided subset so a discarded change snaps back
@@ -462,6 +543,39 @@ function parseNameStatusZ(raw: string): NameStatusEntry[] {
462
543
  return entries;
463
544
  }
464
545
 
546
+ /**
547
+ * Parse `git diff --numstat -z` into a path -> {added, removed} map. Each record
548
+ * is a NUL-terminated `<added>\t<removed>\t<path>` triple; a binary file reports
549
+ * `-` for both counts, which we surface as 0 (no count) per the display contract.
550
+ */
551
+ function parseNumstatZ(raw: string): Map<string, { added: number; removed: number }> {
552
+ const counts = new Map<string, { added: number; removed: number }>();
553
+ for (const record of raw.split("\u0000")) {
554
+ if (!record) continue;
555
+ const tab = record.indexOf("\t");
556
+ const tab2 = record.indexOf("\t", tab + 1);
557
+ if (tab < 0 || tab2 < 0) continue;
558
+ const addedStr = record.slice(0, tab);
559
+ const removedStr = record.slice(tab + 1, tab2);
560
+ const path = record.slice(tab2 + 1);
561
+ if (!path) continue;
562
+ const added = addedStr === "-" ? 0 : Number.parseInt(addedStr, 10) || 0;
563
+ const removed = removedStr === "-" ? 0 : Number.parseInt(removedStr, 10) || 0;
564
+ counts.set(path, { added, removed });
565
+ }
566
+ return counts;
567
+ }
568
+
569
+ /**
570
+ * Map a `--name-status` status letter to a {@link FileChangeType} — the same
571
+ * A -> CREATE, D -> DELETE, else MODIFY rule {@link buildCapturedChange} uses.
572
+ */
573
+ function nameStatusToChangeType(status: string): FileChangeType {
574
+ if (status === "A") return FileChangeType.CREATE;
575
+ if (status === "D") return FileChangeType.DELETE;
576
+ return FileChangeType.MODIFY;
577
+ }
578
+
465
579
  /** Read a blob's exact BYTES from a tree, or `undefined` when absent. */
466
580
  async function readBlobBytes(
467
581
  gitRoot: string,
@@ -33,19 +33,54 @@ export {
33
33
  type CaptureResumeResult,
34
34
  } from "./capture.js";
35
35
 
36
- export { isGitWorkTree } from "./git-substrate.js";
36
+ export {
37
+ captureProgressDelta,
38
+ isGitWorkTree,
39
+ type GitProgressDelta,
40
+ type GitProgressEntry,
41
+ } from "./git-substrate.js";
42
+
43
+ export {
44
+ buildFileChangeProgress,
45
+ captureFileChangeProgress,
46
+ createGitProgressSubstrate,
47
+ createHybridProgressSubstrate,
48
+ newProgressCaptureState,
49
+ PROGRESS_CAPTURE_MIN_INTERVAL_MS,
50
+ PROGRESS_MAX_ENTRIES,
51
+ shouldCaptureProgress,
52
+ type ProgressCapture,
53
+ type ProgressCaptureState,
54
+ type ProgressDelta,
55
+ type ProgressEntry,
56
+ type ProgressSubstrate,
57
+ } from "./progress.js";
58
+
59
+ export {
60
+ createCasProgressSubstrate,
61
+ type CasTouchedReader,
62
+ type CasTouchedSnapshot,
63
+ } from "./cas-progress.js";
64
+
65
+ export {
66
+ countLineChanges,
67
+ LINE_COUNT_MAX_BYTES,
68
+ type LineChangeCounts,
69
+ } from "./line-counts.js";
37
70
 
38
71
  export {
39
72
  applyCasApproved,
40
73
  casBlobKey,
41
74
  casBlobReader,
42
75
  casManifestKey,
76
+ classifyCasChange,
43
77
  loadCasManifest,
44
78
  restoreCasToBaseline,
45
79
  snapshotCasChangeSet,
46
80
  type BlobReader,
47
81
  type CasBlobRef,
48
82
  type CasCapturedFile,
83
+ type CasChangeClassification,
49
84
  type CasManifest,
50
85
  type CasPathCapture,
51
86
  type CasSnapshotRef,
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Capture-time line counting for the file-review subsystem: the `+N −M` stamped
3
+ * onto each {@link CapturedFileChange} so review surfaces can decorate file
4
+ * lists without the file bodies.
5
+ *
6
+ * WHY THE RUNNER COUNTS, AND WHY WITH JSDIFF. The counts exist because the
7
+ * review UI's compact file lists must show per-file magnitude, but by the time
8
+ * the UI renders, large before/after bodies may have been offloaded to artifact
9
+ * storage (status-offload) — a client cannot count what it would first have to
10
+ * download. So the counts are computed once, at capture, where the bytes are
11
+ * always in hand. They are computed with the SAME `diff` (jsdiff) Myers
12
+ * algorithm the SDK's diff renderer uses (`computeDiff` → `structuredPatch`),
13
+ * NOT with `git diff --numstat`: two counting authorities can disagree on edge
14
+ * cases (trailing newlines being the classic), and a list that says `+37 −1`
15
+ * beside a rendered diff showing `+37 −0` is a self-contradicting UI. One
16
+ * algorithm, consistent by construction.
17
+ *
18
+ * The counts are INFORMATIONAL display data only — never an enforcement input,
19
+ * never folded into `file_digest`/`aggregate_digest` (the same contract as
20
+ * `blocked_reason`).
21
+ */
22
+
23
+ import { structuredPatch } from "diff";
24
+
25
+ /**
26
+ * Per-side byte ceiling above which counting is skipped. Myers is O(ND) and a
27
+ * pathological many-MB text pair must never stall the capture activity just to
28
+ * decorate a list; above this the counts stay absent and the UI hides the stat.
29
+ * Generous relative to the 128 KiB per-side inline-persist cap — a file can be
30
+ * several times that and still count quickly — while bounding the worst case.
31
+ */
32
+ export const LINE_COUNT_MAX_BYTES = 1024 * 1024;
33
+
34
+ /** The `+N −M` pair for one file change. */
35
+ export interface LineChangeCounts {
36
+ readonly linesAdded: number;
37
+ readonly linesRemoved: number;
38
+ }
39
+
40
+ /**
41
+ * Count the added/removed lines between two text sides, exactly as the SDK's
42
+ * diff renderer would show them.
43
+ *
44
+ * An absent side is the empty document — an ADD counts every `after` line as
45
+ * added, a DELETE every `before` line as removed — mirroring how the renderer
46
+ * diffs a create/delete. `` markers are metadata,
47
+ * not content, and are excluded (the same rule as the SDK's `mapPatchHunks`).
48
+ *
49
+ * Returns `undefined` when counting is not possible or meaningful: both sides
50
+ * absent, or either side over {@link LINE_COUNT_MAX_BYTES}. Callers leave the
51
+ * proto counts at zero in that case, which consumers render as "no stat".
52
+ */
53
+ export function countLineChanges(
54
+ before: string | undefined,
55
+ after: string | undefined,
56
+ ): LineChangeCounts | undefined {
57
+ if (before === undefined && after === undefined) return undefined;
58
+ const beforeText = before ?? "";
59
+ const afterText = after ?? "";
60
+ if (
61
+ Buffer.byteLength(beforeText, "utf8") > LINE_COUNT_MAX_BYTES ||
62
+ Buffer.byteLength(afterText, "utf8") > LINE_COUNT_MAX_BYTES
63
+ ) {
64
+ return undefined;
65
+ }
66
+
67
+ // Zero context: the +/- line population is independent of context width, and
68
+ // omitting context lines keeps the walk (and the patch object) minimal.
69
+ const patch = structuredPatch("a", "b", beforeText, afterText, "", "", {
70
+ context: 0,
71
+ });
72
+
73
+ let linesAdded = 0;
74
+ let linesRemoved = 0;
75
+ for (const hunk of patch.hunks) {
76
+ for (const line of hunk.lines) {
77
+ if (line.startsWith("\\")) continue;
78
+ if (line[0] === "+") linesAdded++;
79
+ else if (line[0] === "-") linesRemoved++;
80
+ }
81
+ }
82
+ return { linesAdded, linesRemoved };
83
+ }