@stigmer/runner 3.1.2 → 3.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.d.ts +1 -1
  3. package/dist/activities/execute-cursor/attachment-resolver.js +1 -1
  4. package/dist/activities/execute-cursor/capture-flow.d.ts +18 -0
  5. package/dist/activities/execute-cursor/capture-flow.js +21 -0
  6. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  7. package/dist/activities/execute-cursor/index.js +121 -139
  8. package/dist/activities/execute-cursor/index.js.map +1 -1
  9. package/dist/activities/execute-cursor/turn-boundary.d.ts +108 -0
  10. package/dist/activities/execute-cursor/turn-boundary.js +163 -0
  11. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -0
  12. package/dist/activities/execute-deep-agent/index.js +30 -3
  13. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  14. package/dist/activities/execute-deep-agent/streaming-v3.js +6 -1
  15. package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
  16. package/dist/activities/execute-deep-agent/streaming.d.ts +7 -0
  17. package/dist/activities/execute-deep-agent/streaming.js +5 -1
  18. package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
  19. package/dist/shared/filereview/capture.d.ts +18 -1
  20. package/dist/shared/filereview/capture.js +105 -28
  21. package/dist/shared/filereview/capture.js.map +1 -1
  22. package/dist/shared/filereview/cas-substrate.d.ts +9 -0
  23. package/dist/shared/filereview/cas-substrate.js +9 -1
  24. package/dist/shared/filereview/cas-substrate.js.map +1 -1
  25. package/dist/shared/filereview/events.d.ts +16 -0
  26. package/dist/shared/filereview/events.js +37 -0
  27. package/dist/shared/filereview/events.js.map +1 -1
  28. package/dist/shared/filereview/git-substrate.d.ts +42 -0
  29. package/dist/shared/filereview/git-substrate.js +78 -0
  30. package/dist/shared/filereview/git-substrate.js.map +1 -1
  31. package/dist/shared/filereview/index.d.ts +3 -1
  32. package/dist/shared/filereview/index.js +3 -1
  33. package/dist/shared/filereview/index.js.map +1 -1
  34. package/dist/shared/filereview/line-counts.d.ts +48 -0
  35. package/dist/shared/filereview/line-counts.js +72 -0
  36. package/dist/shared/filereview/line-counts.js.map +1 -0
  37. package/dist/shared/filereview/progress.d.ts +96 -0
  38. package/dist/shared/filereview/progress.js +134 -0
  39. package/dist/shared/filereview/progress.js.map +1 -0
  40. package/dist/shared/implement-plan-prompt.d.ts +6 -4
  41. package/dist/shared/implement-plan-prompt.js +38 -6
  42. package/dist/shared/implement-plan-prompt.js.map +1 -1
  43. package/dist/shared/plan-artifact.d.ts +59 -11
  44. package/dist/shared/plan-artifact.js +144 -16
  45. package/dist/shared/plan-artifact.js.map +1 -1
  46. package/dist/shared/plan-mode-prompt.d.ts +6 -4
  47. package/dist/shared/plan-mode-prompt.js +10 -5
  48. package/dist/shared/plan-mode-prompt.js.map +1 -1
  49. package/package.json +3 -2
  50. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +7 -0
  51. package/src/activities/execute-cursor/__tests__/delta-enricher.test.ts +68 -0
  52. package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
  53. package/src/activities/execute-cursor/attachment-resolver.ts +1 -1
  54. package/src/activities/execute-cursor/capture-flow.ts +32 -0
  55. package/src/activities/execute-cursor/index.ts +130 -153
  56. package/src/activities/execute-cursor/turn-boundary.ts +274 -0
  57. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +11 -0
  58. package/src/activities/execute-deep-agent/index.ts +34 -3
  59. package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
  60. package/src/activities/execute-deep-agent/streaming.ts +12 -1
  61. package/src/shared/__tests__/implement-plan-prompt.test.ts +40 -2
  62. package/src/shared/__tests__/plan-artifact.test.ts +142 -9
  63. package/src/shared/filereview/__tests__/capture.test.ts +182 -1
  64. package/src/shared/filereview/__tests__/cas-substrate.test.ts +34 -0
  65. package/src/shared/filereview/__tests__/events.test.ts +105 -0
  66. package/src/shared/filereview/__tests__/line-counts.test.ts +73 -0
  67. package/src/shared/filereview/__tests__/progress.test.ts +236 -0
  68. package/src/shared/filereview/capture.ts +134 -30
  69. package/src/shared/filereview/cas-substrate.ts +17 -1
  70. package/src/shared/filereview/events.ts +50 -0
  71. package/src/shared/filereview/git-substrate.ts +108 -0
  72. package/src/shared/filereview/index.ts +22 -1
  73. package/src/shared/filereview/line-counts.ts +83 -0
  74. package/src/shared/filereview/progress.ts +186 -0
  75. package/src/shared/implement-plan-prompt.ts +39 -8
  76. package/src/shared/plan-artifact.ts +152 -16
  77. package/src/shared/plan-mode-prompt.ts +10 -5
@@ -57,6 +57,7 @@ import {
57
57
  import type { ArtifactStorage } from "../artifact-storage.js";
58
58
  import { bytesLookBinary } from "../file-change.js";
59
59
  import { sha256Bytes, sha256Hex } from "./digest.js";
60
+ import { countLineChanges, type LineChangeCounts } from "./line-counts.js";
60
61
 
61
62
  /**
62
63
  * Reads an artifact's raw bytes back by storage key. Injected so this module
@@ -91,6 +92,14 @@ export interface CasCapturedFile {
91
92
  readonly after?: CasBlobRef;
92
93
  /** False when a side is binary — the change set cannot be approved as complete. */
93
94
  readonly diffComplete: boolean;
95
+ /**
96
+ * Display `+N −M` counted from the in-memory bytes at capture — the only
97
+ * moment a CAS change's text exists outside blob storage. Absent for binary
98
+ * or oversized sides. Carried in memory to the CANDIDATE event only, never
99
+ * persisted in the manifest: the manifest is the reconcile's source of truth,
100
+ * and display decoration does not belong in an enforcement record.
101
+ */
102
+ readonly lineCounts?: LineChangeCounts;
94
103
  }
95
104
 
96
105
  /** The durable manifest for one change set — the reconcile's source of truth. */
@@ -210,6 +219,7 @@ async function buildCasCapturedFile(
210
219
 
211
220
  const isCreate = kind === FileChangeKind.ADD;
212
221
  const isDelete = kind === FileChangeKind.DELETE;
222
+ const isBinary = beforeRef?.isBinary || afterRef?.isBinary;
213
223
 
214
224
  return {
215
225
  pathBefore: isCreate ? "" : path,
@@ -220,7 +230,13 @@ async function buildCasCapturedFile(
220
230
  after: afterRef,
221
231
  // Binary on either side means the diff cannot render as text; the change set
222
232
  // then cannot be approved as complete (parity with the git substrate).
223
- diffComplete: !(beforeRef?.isBinary || afterRef?.isBinary),
233
+ diffComplete: !isBinary,
234
+ // 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")),
224
240
  };
225
241
  }
226
242
 
@@ -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,81 @@ 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
+ /** Result of {@link captureProgressDelta}. */
329
+ export interface ProgressDelta {
330
+ /** The post-diff working-tree sha, for the caller's short-circuit cache. */
331
+ readonly afterTree: string;
332
+ /** One count-only entry per changed file (no content). */
333
+ readonly entries: readonly GitProgressEntry[];
334
+ }
335
+
336
+ /**
337
+ * Capture a NON-AUTHORITATIVE, content-free snapshot of the working-tree delta
338
+ * against `baselineTree` — the substrate for the live "N files changed so far"
339
+ * surface (mid-run live capture). Unlike {@link captureChangeSet} this reads NO
340
+ * file bytes: it stages the working tree into a temp index, writes its tree, and
341
+ * asks git for `--numstat` + `--name-status` only. Cheap enough to run on a
342
+ * debounce during the turn.
343
+ *
344
+ * Short-circuit: returns `undefined` when the working tree is unchanged since
345
+ * `lastTreeSha` (the caller passes back the previous result's `afterTree`), so a
346
+ * quiet turn pays only one `write-tree`, never a diff.
347
+ *
348
+ * Uses a dedicated temp-index label ("progress") so it never collides with the
349
+ * `baseline`/`capture`/`approved` indexes. `--no-renames` matches
350
+ * {@link captureChangeSet} (a rename surfaces as delete + create). A torn read of
351
+ * a file being written mid-turn is acceptable — the snapshot is non-authoritative
352
+ * and self-corrects on the next capture.
353
+ */
354
+ export async function captureProgressDelta(
355
+ gitRoot: string,
356
+ executionId: string,
357
+ baselineTree: string,
358
+ excludePaths: readonly string[] = [],
359
+ lastTreeSha?: string,
360
+ ): Promise<ProgressDelta | undefined> {
361
+ const gitDir = await resolveGitDir(gitRoot);
362
+ const afterTree = await writeWorkingTree(gitRoot, gitDir, "progress", executionId, excludePaths);
363
+ if (lastTreeSha !== undefined && afterTree === lastTreeSha) return undefined;
364
+
365
+ const [nameStatusRaw, numstatRaw] = await Promise.all([
366
+ git(gitRoot, ["diff", "--no-renames", "--name-status", "-z", baselineTree, afterTree]),
367
+ git(gitRoot, ["diff", "--no-renames", "--numstat", "-z", baselineTree, afterTree]),
368
+ ]);
369
+
370
+ const counts = parseNumstatZ(numstatRaw);
371
+ const entries: GitProgressEntry[] = [];
372
+ for (const { status, path } of parseNameStatusZ(nameStatusRaw)) {
373
+ const changeType = nameStatusToChangeType(status);
374
+ const count = counts.get(path);
375
+ entries.push({
376
+ pathBefore: changeType === FileChangeType.CREATE ? "" : path,
377
+ pathAfter: changeType === FileChangeType.DELETE ? "" : path,
378
+ changeType,
379
+ linesAdded: count?.added ?? 0,
380
+ linesRemoved: count?.removed ?? 0,
381
+ });
382
+ }
383
+ return { afterTree, entries };
384
+ }
385
+
311
386
  /**
312
387
  * Restore the given files to their `baselineTree` (pre-turn) bytes. On resume
313
388
  * this reverts the REJECTED/undecided subset so a discarded change snaps back
@@ -462,6 +537,39 @@ function parseNameStatusZ(raw: string): NameStatusEntry[] {
462
537
  return entries;
463
538
  }
464
539
 
540
+ /**
541
+ * Parse `git diff --numstat -z` into a path -> {added, removed} map. Each record
542
+ * is a NUL-terminated `<added>\t<removed>\t<path>` triple; a binary file reports
543
+ * `-` for both counts, which we surface as 0 (no count) per the display contract.
544
+ */
545
+ function parseNumstatZ(raw: string): Map<string, { added: number; removed: number }> {
546
+ const counts = new Map<string, { added: number; removed: number }>();
547
+ for (const record of raw.split("\u0000")) {
548
+ if (!record) continue;
549
+ const tab = record.indexOf("\t");
550
+ const tab2 = record.indexOf("\t", tab + 1);
551
+ if (tab < 0 || tab2 < 0) continue;
552
+ const addedStr = record.slice(0, tab);
553
+ const removedStr = record.slice(tab + 1, tab2);
554
+ const path = record.slice(tab2 + 1);
555
+ if (!path) continue;
556
+ const added = addedStr === "-" ? 0 : Number.parseInt(addedStr, 10) || 0;
557
+ const removed = removedStr === "-" ? 0 : Number.parseInt(removedStr, 10) || 0;
558
+ counts.set(path, { added, removed });
559
+ }
560
+ return counts;
561
+ }
562
+
563
+ /**
564
+ * Map a `--name-status` status letter to a {@link FileChangeType} — the same
565
+ * A -> CREATE, D -> DELETE, else MODIFY rule {@link buildCapturedChange} uses.
566
+ */
567
+ function nameStatusToChangeType(status: string): FileChangeType {
568
+ if (status === "A") return FileChangeType.CREATE;
569
+ if (status === "D") return FileChangeType.DELETE;
570
+ return FileChangeType.MODIFY;
571
+ }
572
+
465
573
  /** Read a blob's exact BYTES from a tree, or `undefined` when absent. */
466
574
  async function readBlobBytes(
467
575
  gitRoot: string,
@@ -33,7 +33,28 @@ 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 GitProgressEntry,
40
+ type ProgressDelta,
41
+ } from "./git-substrate.js";
42
+
43
+ export {
44
+ buildFileChangeProgress,
45
+ captureFileChangeProgress,
46
+ newProgressCaptureState,
47
+ PROGRESS_CAPTURE_MIN_INTERVAL_MS,
48
+ PROGRESS_MAX_ENTRIES,
49
+ shouldCaptureProgress,
50
+ type ProgressCaptureState,
51
+ } from "./progress.js";
52
+
53
+ export {
54
+ countLineChanges,
55
+ LINE_COUNT_MAX_BYTES,
56
+ type LineChangeCounts,
57
+ } from "./line-counts.js";
37
58
 
38
59
  export {
39
60
  applyCasApproved,
@@ -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
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Mid-run live capture — the harness-agnostic glue that turns a content-free git
3
+ * delta ({@link ./git-substrate.js} `captureProgressDelta`) into the transient
4
+ * `AgentExecutionStatus.file_change_progress` snapshot the "N files changed so
5
+ * far" strip renders (DD-32).
6
+ *
7
+ * THE MODEL
8
+ * ---------
9
+ * This is category 2 of the two status patterns: a runner-owned, latest-snapshot
10
+ * DISPLAY field, NOT the event-sourced file-review ledger. It is deliberately NOT
11
+ * a `FileChangeSet` — progress is never in the ledger, carries NO file bytes or
12
+ * digests, and is NEVER decidable. The turn-boundary CANDIDATE_CAPTURED (in
13
+ * `file_change_sets`) remains the single authoritative, reviewable diff; a mid-run
14
+ * snapshot is no more authoritative than a streamed tool-call arg.
15
+ *
16
+ * Both harnesses drive this through {@link captureFileChangeProgress} from their
17
+ * per-persist loop, each passing its OWN `excludePaths` (the runner-owned gate
18
+ * files it writes into the workspace), so progress and the turn-boundary candidate
19
+ * agree on what is "the agent's change". Capture is throttled by a floor +
20
+ * tree-sha short-circuit so a quiet turn is nearly free.
21
+ *
22
+ * SECRET SAFETY
23
+ * -------------
24
+ * No file bodies are ever carried. A secret-like path ({@link isSecretLikePath})
25
+ * is still surfaced (path visible) but with its line counts ZEROED — the same
26
+ * "path visible, content withheld" rule the ledger uses (DD-12). Nothing new can
27
+ * leak through this field.
28
+ *
29
+ * @since File-Change HITL Redesign (mid-run live capture / DD-32)
30
+ */
31
+
32
+ import { create } from "@bufbuild/protobuf";
33
+ import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
34
+ import type {
35
+ FileChangeProgress,
36
+ FileChangeProgressEntry,
37
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
38
+ import {
39
+ FileChangeProgressEntrySchema,
40
+ FileChangeProgressSchema,
41
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
42
+ import { utcTimestamp } from "../status.js";
43
+ import { toFileChangeKind } from "./capture.js";
44
+ import { captureProgressDelta, type ProgressDelta } from "./git-substrate.js";
45
+ import { isSecretLikePath } from "./secret-paths.js";
46
+
47
+ /**
48
+ * Cap the per-file entry list so a turn touching thousands of files does not
49
+ * bloat the persisted status (which rides Temporal / the store). `files_changed`
50
+ * and the aggregate counts stay honest over ALL files; the strip shows "… and K
51
+ * more" when the list is capped.
52
+ */
53
+ export const PROGRESS_MAX_ENTRIES = 200;
54
+
55
+ /**
56
+ * Minimum wall-clock gap between two mid-run captures, in ms. A floor on cost:
57
+ * every capture stages the working tree (`write-tree`), so we bound how often
58
+ * that runs regardless of persist frequency. Env-tunable for large-repo
59
+ * deployments; defaults to 2s (feels live without hammering git).
60
+ */
61
+ export const PROGRESS_CAPTURE_MIN_INTERVAL_MS = readMinIntervalMs();
62
+
63
+ function readMinIntervalMs(): number {
64
+ const raw = process.env.STIGMER_PROGRESS_CAPTURE_MIN_INTERVAL_MS;
65
+ if (!raw) return 2000;
66
+ const n = Number.parseInt(raw, 10);
67
+ return Number.isFinite(n) && n >= 0 ? n : 2000;
68
+ }
69
+
70
+ /**
71
+ * Whether enough wall-clock has elapsed since the last capture to take another.
72
+ * Pure and separated for direct testing (mirrors `persist-decision.ts`). A
73
+ * `lastAtMs` of 0 (never captured) always passes.
74
+ */
75
+ export function shouldCaptureProgress(
76
+ lastAtMs: number,
77
+ nowMs: number,
78
+ minIntervalMs: number = PROGRESS_CAPTURE_MIN_INTERVAL_MS,
79
+ ): boolean {
80
+ return nowMs - lastAtMs >= minIntervalMs;
81
+ }
82
+
83
+ /**
84
+ * Build the transient {@link FileChangeProgress} message from a content-free git
85
+ * delta. Zeroes counts for secret-like paths (path visible, magnitude withheld),
86
+ * caps the entry list at {@link PROGRESS_MAX_ENTRIES} while keeping
87
+ * `files_changed`/aggregate counts honest over every file, and stamps
88
+ * `captured_at`. Pure and exported for direct testing.
89
+ */
90
+ export function buildFileChangeProgress(
91
+ delta: ProgressDelta,
92
+ changeSetId: string,
93
+ ): FileChangeProgress {
94
+ let filesChanged = 0;
95
+ let totalAdded = 0;
96
+ let totalRemoved = 0;
97
+ const entries: FileChangeProgressEntry[] = [];
98
+
99
+ for (const entry of delta.entries) {
100
+ filesChanged += 1;
101
+ // A secret-like path is surfaced (path visible) but its magnitude is withheld
102
+ // — counts zeroed, mirroring the ledger's "path visible, content withheld".
103
+ const secret = isSecretLikePath(entry.pathAfter || entry.pathBefore);
104
+ const linesAdded = secret ? 0 : entry.linesAdded;
105
+ const linesRemoved = secret ? 0 : entry.linesRemoved;
106
+ totalAdded += linesAdded;
107
+ totalRemoved += linesRemoved;
108
+
109
+ if (entries.length < PROGRESS_MAX_ENTRIES) {
110
+ entries.push(
111
+ create(FileChangeProgressEntrySchema, {
112
+ pathBefore: entry.pathBefore,
113
+ pathAfter: entry.pathAfter,
114
+ kind: toFileChangeKind(entry.changeType),
115
+ linesAdded,
116
+ linesRemoved,
117
+ }),
118
+ );
119
+ }
120
+ }
121
+
122
+ return create(FileChangeProgressSchema, {
123
+ changeSetId,
124
+ filesChanged,
125
+ linesAdded: totalAdded,
126
+ linesRemoved: totalRemoved,
127
+ entries,
128
+ capturedAt: utcTimestamp(),
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Mutable per-turn state the caller threads across persists: the last captured
134
+ * tree sha (for the short-circuit) and the last capture time (for the floor).
135
+ * A fresh turn starts a fresh state (`{ lastAtMs: 0 }`).
136
+ */
137
+ export interface ProgressCaptureState {
138
+ lastTreeSha?: string;
139
+ lastAtMs: number;
140
+ }
141
+
142
+ /** A fresh per-turn progress state. */
143
+ export function newProgressCaptureState(): ProgressCaptureState {
144
+ return { lastAtMs: 0 };
145
+ }
146
+
147
+ /**
148
+ * Capture-and-attach the live progress snapshot onto `status.file_change_progress`,
149
+ * throttled by the floor + tree-sha short-circuit. Mutates `status` and `state`
150
+ * in place; a no-op when the floor has not elapsed or the working tree is
151
+ * unchanged since the last capture. Content-free and secret-safe (see
152
+ * {@link buildFileChangeProgress}). Called by both harnesses from their per-persist
153
+ * loop; each passes its own `excludePaths`.
154
+ *
155
+ * The field is set even when the delta is now EMPTY (the agent reverted its own
156
+ * edits) so the strip reflects the reversion (it hides at zero) rather than
157
+ * showing a stale count — the server's presence-guarded merge would keep the
158
+ * stale value if the runner omitted the field.
159
+ */
160
+ export async function captureFileChangeProgress(opts: {
161
+ readonly status: AgentExecutionStatus;
162
+ readonly gitRoot: string;
163
+ readonly executionId: string;
164
+ readonly changeSetId: string;
165
+ readonly baselineTree: string;
166
+ readonly excludePaths?: readonly string[];
167
+ readonly state: ProgressCaptureState;
168
+ /** Injectable clock for tests; defaults to `Date.now()`. */
169
+ readonly nowMs?: number;
170
+ }): Promise<void> {
171
+ const now = opts.nowMs ?? Date.now();
172
+ if (!shouldCaptureProgress(opts.state.lastAtMs, now)) return;
173
+ opts.state.lastAtMs = now;
174
+
175
+ const delta = await captureProgressDelta(
176
+ opts.gitRoot,
177
+ opts.executionId,
178
+ opts.baselineTree,
179
+ opts.excludePaths,
180
+ opts.state.lastTreeSha,
181
+ );
182
+ if (!delta) return; // working tree unchanged since the last capture
183
+
184
+ opts.state.lastTreeSha = delta.afterTree;
185
+ opts.status.fileChangeProgress = buildFileChangeProgress(delta, opts.changeSetId);
186
+ }
@@ -11,8 +11,9 @@
11
11
  * The directive has two variants, chosen by whether the approved plan document
12
12
  * actually materialized in the workspace:
13
13
  * - Attached (the normal case): the client uploaded the approved plan text
14
- * (edited or not) and attached it, mounted at `.stigmer/inputs/plan.md`.
15
- * The directive points the model at that file and names it authoritative —
14
+ * (edited or not) and attached it, mounted under `.stigmer/inputs/` as the
15
+ * plan's own file (`<slug>_<id>.plan.md`). The directive points the model at
16
+ * that file and names it authoritative —
16
17
  * the user may have refined the document after the plan turn, so the
17
18
  * conversation's version can be stale.
18
19
  * - Conversation-only (the client's upload failed): the directive falls back
@@ -24,12 +25,37 @@
24
25
  * harness).
25
26
  */
26
27
 
27
- import { PLAN_ARTIFACT_NAME } from "./plan-artifact.js";
28
+ import { isPlanArtifactName } from "./plan-artifact.js";
29
+
30
+ /**
31
+ * Progress-tracking instruction shared by both directive variants (Tier 3 of
32
+ * Plan mode — plan-driven build progress). The agent's own to-do tool is the
33
+ * single writer of `status.todos`, so instructing it to derive the list from
34
+ * the approved plan is the entire plan→progress linkage: the runner's todo
35
+ * extraction and the clients' todo renderers light up unchanged.
36
+ *
37
+ * Deliberately tool-agnostic ("your to-do list"): the Cursor harness exposes
38
+ * TodoWrite/updateTodos, the native harness write_todos, and each runtime
39
+ * already teaches the model its own tool.
40
+ *
41
+ * Wording constraint: this block rides BOTH variants, and the conversation-only
42
+ * variant is pinned by tests to never contain "plan.md" (it has no plan file
43
+ * to reference) — so say "the plan", never name the file.
44
+ */
45
+ const TRACK_PROGRESS_INSTRUCTION = [
46
+ "Track your progress with your to-do list so the user can follow the " +
47
+ "build:",
48
+ "- Before you start, break the plan into a concrete, ordered to-do list — " +
49
+ "roughly one item per implementation step.",
50
+ "- As you work, keep it current: mark each item in progress when you " +
51
+ "begin it and completed when it is done.",
52
+ ].join("\n");
28
53
 
29
54
  /**
30
55
  * Find the approved plan document among the workspace paths the harness
31
- * injected for this execution's attachments. Detection keys on the canonical
32
- * plan filename the same convention the UI uses to detect the plan artifact.
56
+ * injected for this execution's attachments. Detection keys on the plan
57
+ * filename convention ({@link isPlanArtifactName} the legacy `plan.md` or any
58
+ * `*.plan.md`), the same convention the UI uses to detect the plan artifact.
33
59
  * Returns `undefined` when no plan attachment landed (upload failed, or the
34
60
  * attachment itself failed to inject), which selects the conversation-only
35
61
  * directive variant.
@@ -37,9 +63,10 @@ import { PLAN_ARTIFACT_NAME } from "./plan-artifact.js";
37
63
  export function findApprovedPlanPath(
38
64
  attachmentPaths: readonly string[],
39
65
  ): string | undefined {
40
- return attachmentPaths.find(
41
- (p) => p.split("/").pop() === PLAN_ARTIFACT_NAME,
42
- );
66
+ return attachmentPaths.find((p) => {
67
+ const name = p.split("/").pop();
68
+ return name !== undefined && isPlanArtifactName(name);
69
+ });
43
70
  }
44
71
 
45
72
  /**
@@ -61,6 +88,8 @@ export function buildImplementPlanDirective(planPath?: string): string {
61
88
  "That document is the authoritative version of the plan — the user may " +
62
89
  "have edited it after it was proposed, so where it differs from the " +
63
90
  "conversation above, follow the document.",
91
+ "",
92
+ TRACK_PROGRESS_INSTRUCTION,
64
93
  ].join("\n");
65
94
  }
66
95
 
@@ -69,5 +98,7 @@ export function buildImplementPlanDirective(planPath?: string): string {
69
98
  "APPROVED.",
70
99
  "",
71
100
  "Implement the plan proposed in the conversation above, step by step.",
101
+ "",
102
+ TRACK_PROGRESS_INSTRUCTION,
72
103
  ].join("\n");
73
104
  }