@stigmer/runner 3.1.3 → 3.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.js +1 -1
  3. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  4. package/dist/activities/execute-cursor/capture-flow.d.ts +26 -15
  5. package/dist/activities/execute-cursor/capture-flow.js +56 -18
  6. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  7. package/dist/activities/execute-cursor/command-provenance.d.ts +11 -25
  8. package/dist/activities/execute-cursor/command-provenance.js +25 -115
  9. package/dist/activities/execute-cursor/command-provenance.js.map +1 -1
  10. package/dist/activities/execute-cursor/index.js +297 -478
  11. package/dist/activities/execute-cursor/index.js.map +1 -1
  12. package/dist/activities/execute-cursor/skill-resolver.js +1 -1
  13. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  14. package/dist/activities/execute-cursor/todo-tracker.d.ts +6 -1
  15. package/dist/activities/execute-cursor/todo-tracker.js +15 -43
  16. package/dist/activities/execute-cursor/todo-tracker.js.map +1 -1
  17. package/dist/activities/execute-cursor/turn-stream.d.ts +141 -0
  18. package/dist/activities/execute-cursor/turn-stream.js +249 -0
  19. package/dist/activities/execute-cursor/turn-stream.js.map +1 -0
  20. package/dist/activities/execute-deep-agent/command-provenance.d.ts +61 -0
  21. package/dist/activities/execute-deep-agent/command-provenance.js +72 -0
  22. package/dist/activities/execute-deep-agent/command-provenance.js.map +1 -0
  23. package/dist/activities/execute-deep-agent/index.js +88 -20
  24. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  25. package/dist/activities/execute-deep-agent/status-builder.js +8 -1
  26. package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
  27. package/dist/activities/execute-deep-agent/v3-status-builder.js +12 -1
  28. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
  29. package/dist/activities/execute-deep-agent/writeback-coordinator.js +5 -1
  30. package/dist/activities/execute-deep-agent/writeback-coordinator.js.map +1 -1
  31. package/dist/otel.js +10 -0
  32. package/dist/otel.js.map +1 -1
  33. package/dist/shared/filereview/cas-progress.d.ts +63 -0
  34. package/dist/shared/filereview/cas-progress.js +128 -0
  35. package/dist/shared/filereview/cas-progress.js.map +1 -0
  36. package/dist/shared/filereview/cas-substrate.d.ts +29 -0
  37. package/dist/shared/filereview/cas-substrate.js +46 -21
  38. package/dist/shared/filereview/cas-substrate.js.map +1 -1
  39. package/dist/shared/filereview/command-provenance.d.ts +93 -0
  40. package/dist/shared/filereview/command-provenance.js +132 -0
  41. package/dist/shared/filereview/command-provenance.js.map +1 -0
  42. package/dist/shared/filereview/git-substrate.d.ts +9 -3
  43. package/dist/shared/filereview/git-substrate.js +4 -0
  44. package/dist/shared/filereview/git-substrate.js.map +1 -1
  45. package/dist/shared/filereview/index.d.ts +4 -3
  46. package/dist/shared/filereview/index.js +3 -2
  47. package/dist/shared/filereview/index.js.map +1 -1
  48. package/dist/shared/filereview/progress.d.ts +105 -34
  49. package/dist/shared/filereview/progress.js +96 -34
  50. package/dist/shared/filereview/progress.js.map +1 -1
  51. package/dist/shared/plan-mode-prompt.d.ts +9 -0
  52. package/dist/shared/plan-mode-prompt.js +18 -0
  53. package/dist/shared/plan-mode-prompt.js.map +1 -1
  54. package/dist/shared/todos.d.ts +56 -0
  55. package/dist/shared/todos.js +98 -0
  56. package/dist/shared/todos.js.map +1 -0
  57. package/dist/shared/tool-row.d.ts +16 -0
  58. package/dist/shared/tool-row.js +31 -0
  59. package/dist/shared/tool-row.js.map +1 -1
  60. package/dist/shared/workspace/git-identity.d.ts +36 -0
  61. package/dist/shared/workspace/git-identity.js +39 -0
  62. package/dist/shared/workspace/git-identity.js.map +1 -0
  63. package/dist/shared/workspace/local-backend.d.ts +6 -2
  64. package/dist/shared/workspace/local-backend.js +6 -2
  65. package/dist/shared/workspace/local-backend.js.map +1 -1
  66. package/dist/shared/workspace/stigmer-link.d.ts +54 -0
  67. package/dist/shared/workspace/stigmer-link.js +92 -0
  68. package/dist/shared/workspace/stigmer-link.js.map +1 -0
  69. package/dist/shared/workspace/types.d.ts +6 -4
  70. package/package.json +2 -2
  71. package/src/__tests__/otel-turn-span.test.ts +61 -0
  72. package/src/activities/execute-cursor/__tests__/progress-substrate.test.ts +169 -0
  73. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +349 -0
  74. package/src/activities/execute-cursor/attachment-resolver.ts +1 -1
  75. package/src/activities/execute-cursor/capture-flow.ts +71 -25
  76. package/src/activities/execute-cursor/command-provenance.ts +25 -120
  77. package/src/activities/execute-cursor/index.ts +345 -505
  78. package/src/activities/execute-cursor/skill-resolver.ts +1 -1
  79. package/src/activities/execute-cursor/todo-tracker.ts +17 -59
  80. package/src/activities/execute-cursor/turn-stream.ts +418 -0
  81. package/src/activities/execute-deep-agent/__tests__/command-provenance.test.ts +252 -0
  82. package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +78 -0
  83. package/src/activities/execute-deep-agent/__tests__/v3-status-builder.test.ts +105 -1
  84. package/src/activities/execute-deep-agent/__tests__/writeback-coordinator.test.ts +30 -3
  85. package/src/activities/execute-deep-agent/command-provenance.ts +102 -0
  86. package/src/activities/execute-deep-agent/index.ts +107 -20
  87. package/src/activities/execute-deep-agent/status-builder.ts +9 -0
  88. package/src/activities/execute-deep-agent/v3-status-builder.ts +13 -0
  89. package/src/activities/execute-deep-agent/writeback-coordinator.ts +5 -1
  90. package/src/otel.ts +8 -0
  91. package/src/shared/__tests__/todos.test.ts +216 -0
  92. package/src/shared/filereview/__tests__/cas-progress.test.ts +228 -0
  93. package/src/shared/filereview/__tests__/cas-substrate.test.ts +66 -0
  94. package/src/shared/filereview/__tests__/command-provenance.test.ts +252 -0
  95. package/src/shared/filereview/__tests__/progress.test.ts +112 -10
  96. package/src/shared/filereview/cas-progress.ts +170 -0
  97. package/src/shared/filereview/cas-substrate.ts +69 -24
  98. package/src/shared/filereview/command-provenance.ts +180 -0
  99. package/src/shared/filereview/git-substrate.ts +13 -3
  100. package/src/shared/filereview/index.ts +15 -1
  101. package/src/shared/filereview/progress.ts +171 -47
  102. package/src/shared/plan-mode-prompt.ts +18 -0
  103. package/src/shared/todos.ts +126 -0
  104. package/src/shared/tool-row.ts +34 -0
  105. package/src/shared/workspace/__tests__/git-identity.test.ts +124 -0
  106. package/src/shared/workspace/__tests__/stigmer-link.test.ts +173 -0
  107. package/src/shared/workspace/git-identity.ts +41 -0
  108. package/src/shared/workspace/local-backend.ts +6 -2
  109. package/src/shared/workspace/stigmer-link.ts +97 -0
  110. package/src/shared/workspace/types.ts +6 -4
  111. package/dist/activities/execute-cursor/stigmer-link.d.ts +0 -35
  112. package/dist/activities/execute-cursor/stigmer-link.js +0 -73
  113. package/dist/activities/execute-cursor/stigmer-link.js.map +0 -1
  114. package/src/activities/execute-cursor/stigmer-link.ts +0 -78
@@ -36,20 +36,32 @@ export {
36
36
  export {
37
37
  captureProgressDelta,
38
38
  isGitWorkTree,
39
+ type GitProgressDelta,
39
40
  type GitProgressEntry,
40
- type ProgressDelta,
41
41
  } from "./git-substrate.js";
42
42
 
43
43
  export {
44
44
  buildFileChangeProgress,
45
45
  captureFileChangeProgress,
46
+ createGitProgressSubstrate,
47
+ createHybridProgressSubstrate,
46
48
  newProgressCaptureState,
47
49
  PROGRESS_CAPTURE_MIN_INTERVAL_MS,
48
50
  PROGRESS_MAX_ENTRIES,
49
51
  shouldCaptureProgress,
52
+ type ProgressCapture,
50
53
  type ProgressCaptureState,
54
+ type ProgressDelta,
55
+ type ProgressEntry,
56
+ type ProgressSubstrate,
51
57
  } from "./progress.js";
52
58
 
59
+ export {
60
+ createCasProgressSubstrate,
61
+ type CasTouchedReader,
62
+ type CasTouchedSnapshot,
63
+ } from "./cas-progress.js";
64
+
53
65
  export {
54
66
  countLineChanges,
55
67
  LINE_COUNT_MAX_BYTES,
@@ -61,12 +73,14 @@ export {
61
73
  casBlobKey,
62
74
  casBlobReader,
63
75
  casManifestKey,
76
+ classifyCasChange,
64
77
  loadCasManifest,
65
78
  restoreCasToBaseline,
66
79
  snapshotCasChangeSet,
67
80
  type BlobReader,
68
81
  type CasBlobRef,
69
82
  type CasCapturedFile,
83
+ type CasChangeClassification,
70
84
  type CasManifest,
71
85
  type CasPathCapture,
72
86
  type CasSnapshotRef,
@@ -1,8 +1,7 @@
1
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).
2
+ * Mid-run live capture — the harness-agnostic glue that turns a per-turn
3
+ * workspace delta into the transient `AgentExecutionStatus.file_change_progress`
4
+ * snapshot the "N files changed so far" strip renders (DD-32 / DD-33).
6
5
  *
7
6
  * THE MODEL
8
7
  * ---------
@@ -13,24 +12,33 @@
13
12
  * `file_change_sets`) remains the single authoritative, reviewable diff; a mid-run
14
13
  * snapshot is no more authoritative than a streamed tool-call arg.
15
14
  *
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.
15
+ * THE SUBSTRATE ABSTRACTION (DD-33)
16
+ * ---------------------------------
17
+ * Where the delta comes from differs by workspace: a git tree diffs cheaply with
18
+ * `git --numstat` (the git substrate), a non-git / gitignored workspace reads the
19
+ * CAS observer (the cas substrate, {@link ./cas-progress.js}), and a git tree
20
+ * with gitignored writes composes both (the hybrid substrate). A
21
+ * {@link ProgressSubstrate} hides that behind one `capture()` so the floor + attach
22
+ * logic below is written once. Each substrate is a per-turn object owning its own
23
+ * short-circuit cache; `capture()` always returns the FULL cumulative turn delta
24
+ * plus a `changed` flag, so the hybrid can merge both slices even when only one
25
+ * moved (a `ProgressDelta | undefined` would drop the unchanged slice).
21
26
  *
22
27
  * SECRET SAFETY
23
28
  * -------------
24
29
  * No file bodies are ever carried. A secret-like path ({@link isSecretLikePath})
25
30
  * 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.
31
+ * "path visible, content withheld" rule the ledger uses (DD-12). The cas
32
+ * substrate additionally excludes gate-blocked secrets up front
33
+ * ({@link partitionIgnoredPathsBySecret}); this zeroing is the belt-and-suspenders
34
+ * backstop. Nothing new can leak through this field.
28
35
  *
29
- * @since File-Change HITL Redesign (mid-run live capture / DD-32)
36
+ * @since File-Change HITL Redesign (mid-run live capture / DD-32; non-git + hybrid / DD-33)
30
37
  */
31
38
 
32
39
  import { create } from "@bufbuild/protobuf";
33
40
  import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
41
+ import type { FileChangeKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
34
42
  import type {
35
43
  FileChangeProgress,
36
44
  FileChangeProgressEntry,
@@ -41,22 +49,74 @@ import {
41
49
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
42
50
  import { utcTimestamp } from "../status.js";
43
51
  import { toFileChangeKind } from "./capture.js";
44
- import { captureProgressDelta, type ProgressDelta } from "./git-substrate.js";
52
+ import {
53
+ captureProgressDelta,
54
+ type GitProgressEntry,
55
+ } from "./git-substrate.js";
45
56
  import { isSecretLikePath } from "./secret-paths.js";
46
57
 
58
+ /**
59
+ * One file's slim, content-free progress row — the substrate-neutral input to
60
+ * {@link buildFileChangeProgress}. Carries the proto {@link FileChangeKind}
61
+ * directly (git and cas substrates both normalize to it), so no side re-maps.
62
+ * `linesAdded`/`linesRemoved` are 0 when uncountable (binary / oversized /
63
+ * secret-withheld).
64
+ */
65
+ export interface ProgressEntry {
66
+ /** Path before the change (workspace-relative). Empty for ADD. */
67
+ readonly pathBefore: string;
68
+ /** Path after the change (workspace-relative). Empty for DELETE. */
69
+ readonly pathAfter: string;
70
+ readonly kind: FileChangeKind;
71
+ readonly linesAdded: number;
72
+ readonly linesRemoved: number;
73
+ }
74
+
75
+ /**
76
+ * The substrate-neutral progress delta the git/cas/hybrid substrates emit.
77
+ *
78
+ * `totalFilesChanged` is the honest count of changed files this turn, which may
79
+ * EXCEED `entries.length` when a substrate caps how many files it reads (the cas
80
+ * substrate reads only a bounded prefix — DD-33). Undefined means "the entries
81
+ * ARE every changed file" (the git substrate, whose numstat is free), so
82
+ * {@link buildFileChangeProgress} falls back to `entries.length`.
83
+ */
84
+ export interface ProgressDelta {
85
+ readonly entries: readonly ProgressEntry[];
86
+ readonly totalFilesChanged?: number;
87
+ }
88
+
89
+ /** One mid-run capture: the FULL cumulative turn delta + whether it changed. */
90
+ export interface ProgressCapture {
91
+ readonly delta: ProgressDelta;
92
+ /** False ⇒ nothing moved since the last capture; the caller skips re-attach. */
93
+ readonly changed: boolean;
94
+ }
95
+
96
+ /**
97
+ * A per-turn source of the mid-run delta. Implementations own their own
98
+ * short-circuit cache and ALWAYS return the full cumulative delta (never a
99
+ * bare "unchanged" sentinel), so {@link createHybridProgressSubstrate} can merge
100
+ * a changed slice with an unchanged one without losing the latter.
101
+ */
102
+ export interface ProgressSubstrate {
103
+ capture(): Promise<ProgressCapture>;
104
+ }
105
+
47
106
  /**
48
107
  * Cap the per-file entry list so a turn touching thousands of files does not
49
108
  * bloat the persisted status (which rides Temporal / the store). `files_changed`
50
109
  * and the aggregate counts stay honest over ALL files; the strip shows "… and K
51
- * more" when the list is capped.
110
+ * more" when the list is capped. Also the cas substrate's read budget — it reads
111
+ * after-bytes for at most this many files per capture (DD-33).
52
112
  */
53
113
  export const PROGRESS_MAX_ENTRIES = 200;
54
114
 
55
115
  /**
56
116
  * 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).
117
+ * every capture stages the working tree (git) or reads the touched set (cas), so
118
+ * we bound how often that runs regardless of persist frequency. Env-tunable for
119
+ * large-repo deployments; defaults to 2s (feels live without hammering the disk).
60
120
  */
61
121
  export const PROGRESS_CAPTURE_MIN_INTERVAL_MS = readMinIntervalMs();
62
122
 
@@ -81,23 +141,22 @@ export function shouldCaptureProgress(
81
141
  }
82
142
 
83
143
  /**
84
- * Build the transient {@link FileChangeProgress} message from a content-free git
144
+ * Build the transient {@link FileChangeProgress} message from a substrate-neutral
85
145
  * 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.
146
+ * caps the entry list at {@link PROGRESS_MAX_ENTRIES}, and reports the honest
147
+ * `files_changed` (`delta.totalFilesChanged` when a substrate capped its reads,
148
+ * else the entry count). Aggregate counts sum over the emitted entries. Pure and
149
+ * exported for direct testing.
89
150
  */
90
151
  export function buildFileChangeProgress(
91
152
  delta: ProgressDelta,
92
153
  changeSetId: string,
93
154
  ): FileChangeProgress {
94
- let filesChanged = 0;
95
155
  let totalAdded = 0;
96
156
  let totalRemoved = 0;
97
157
  const entries: FileChangeProgressEntry[] = [];
98
158
 
99
159
  for (const entry of delta.entries) {
100
- filesChanged += 1;
101
160
  // A secret-like path is surfaced (path visible) but its magnitude is withheld
102
161
  // — counts zeroed, mirroring the ledger's "path visible, content withheld".
103
162
  const secret = isSecretLikePath(entry.pathAfter || entry.pathBefore);
@@ -111,7 +170,7 @@ export function buildFileChangeProgress(
111
170
  create(FileChangeProgressEntrySchema, {
112
171
  pathBefore: entry.pathBefore,
113
172
  pathAfter: entry.pathAfter,
114
- kind: toFileChangeKind(entry.changeType),
173
+ kind: entry.kind,
115
174
  linesAdded,
116
175
  linesRemoved,
117
176
  }),
@@ -121,7 +180,7 @@ export function buildFileChangeProgress(
121
180
 
122
181
  return create(FileChangeProgressSchema, {
123
182
  changeSetId,
124
- filesChanged,
183
+ filesChanged: delta.totalFilesChanged ?? delta.entries.length,
125
184
  linesAdded: totalAdded,
126
185
  linesRemoved: totalRemoved,
127
186
  entries,
@@ -130,12 +189,12 @@ export function buildFileChangeProgress(
130
189
  }
131
190
 
132
191
  /**
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 }`).
192
+ * Mutable per-turn state the caller threads across persists: the last capture
193
+ * time (the floor). Substrate-specific short-circuit state (git tree sha, cas
194
+ * signature) lives inside the {@link ProgressSubstrate}, not here. A fresh turn
195
+ * starts a fresh state (`{ lastAtMs: 0 }`).
136
196
  */
137
197
  export interface ProgressCaptureState {
138
- lastTreeSha?: string;
139
198
  lastAtMs: number;
140
199
  }
141
200
 
@@ -146,11 +205,10 @@ export function newProgressCaptureState(): ProgressCaptureState {
146
205
 
147
206
  /**
148
207
  * 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`.
208
+ * throttled by the floor. Mutates `status` and `state` in place; a no-op when the
209
+ * floor has not elapsed or the substrate reports nothing changed. Content-free and
210
+ * secret-safe (see {@link buildFileChangeProgress}). Called by both harnesses from
211
+ * their per-persist loop, each passing a substrate built for its workspace shape.
154
212
  *
155
213
  * The field is set even when the delta is now EMPTY (the agent reverted its own
156
214
  * edits) so the strip reflects the reversion (it hides at zero) rather than
@@ -159,11 +217,8 @@ export function newProgressCaptureState(): ProgressCaptureState {
159
217
  */
160
218
  export async function captureFileChangeProgress(opts: {
161
219
  readonly status: AgentExecutionStatus;
162
- readonly gitRoot: string;
163
- readonly executionId: string;
164
220
  readonly changeSetId: string;
165
- readonly baselineTree: string;
166
- readonly excludePaths?: readonly string[];
221
+ readonly substrate: ProgressSubstrate;
167
222
  readonly state: ProgressCaptureState;
168
223
  /** Injectable clock for tests; defaults to `Date.now()`. */
169
224
  readonly nowMs?: number;
@@ -172,15 +227,84 @@ export async function captureFileChangeProgress(opts: {
172
227
  if (!shouldCaptureProgress(opts.state.lastAtMs, now)) return;
173
228
  opts.state.lastAtMs = now;
174
229
 
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
230
+ const { delta, changed } = await opts.substrate.capture();
231
+ if (!changed) return;
183
232
 
184
- opts.state.lastTreeSha = delta.afterTree;
185
233
  opts.status.fileChangeProgress = buildFileChangeProgress(delta, opts.changeSetId);
186
234
  }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Substrates
238
+ // ---------------------------------------------------------------------------
239
+
240
+ function gitEntryToProgressEntry(e: GitProgressEntry): ProgressEntry {
241
+ return {
242
+ pathBefore: e.pathBefore,
243
+ pathAfter: e.pathAfter,
244
+ kind: toFileChangeKind(e.changeType),
245
+ linesAdded: e.linesAdded,
246
+ linesRemoved: e.linesRemoved,
247
+ };
248
+ }
249
+
250
+ /**
251
+ * The git substrate: the working-tree `--numstat` delta against the pinned
252
+ * baseline. `git add -A` honors `.gitignore`, so this covers exactly the
253
+ * git-TRACKED changes (from any source — tool or shell), disjoint from the cas
254
+ * substrate's gitignored set. Preserves the tree-sha short-circuit: on an
255
+ * unchanged tree it returns the cached full delta with `changed:false` (the
256
+ * caller skips the re-attach), byte-identical to the pre-DD-33 behavior.
257
+ */
258
+ export function createGitProgressSubstrate(opts: {
259
+ readonly workspaceRoot: string;
260
+ readonly executionId: string;
261
+ readonly baselineTree: string;
262
+ readonly excludePaths?: readonly string[];
263
+ }): ProgressSubstrate {
264
+ let lastTreeSha: string | undefined;
265
+ let cachedFull: ProgressDelta = { entries: [] };
266
+ return {
267
+ async capture(): Promise<ProgressCapture> {
268
+ const gitDelta = await captureProgressDelta(
269
+ opts.workspaceRoot,
270
+ opts.executionId,
271
+ opts.baselineTree,
272
+ opts.excludePaths,
273
+ lastTreeSha,
274
+ );
275
+ if (gitDelta === undefined) {
276
+ // Working tree unchanged since the last capture — reuse the cache so the
277
+ // hybrid can still merge this (unchanged) slice with a changed cas slice.
278
+ return { delta: cachedFull, changed: false };
279
+ }
280
+ lastTreeSha = gitDelta.afterTree;
281
+ cachedFull = { entries: gitDelta.entries.map(gitEntryToProgressEntry) };
282
+ return { delta: cachedFull, changed: true };
283
+ },
284
+ };
285
+ }
286
+
287
+ /**
288
+ * The hybrid substrate: a git tree whose gitignored writes are captured via CAS.
289
+ * Concatenates both slices (disjoint by the `.gitignore` boundary — numstat sees
290
+ * only tracked paths, the observer only gitignored ones) and sums the honest
291
+ * totals. `changed` is true when EITHER slice moved; because each child returns
292
+ * its full cumulative delta, the merged delta always carries both slices.
293
+ */
294
+ export function createHybridProgressSubstrate(
295
+ git: ProgressSubstrate,
296
+ cas: ProgressSubstrate,
297
+ ): ProgressSubstrate {
298
+ return {
299
+ async capture(): Promise<ProgressCapture> {
300
+ const [g, c] = await Promise.all([git.capture(), cas.capture()]);
301
+ const delta: ProgressDelta = {
302
+ entries: [...g.delta.entries, ...c.delta.entries],
303
+ totalFilesChanged:
304
+ (g.delta.totalFilesChanged ?? g.delta.entries.length) +
305
+ (c.delta.totalFilesChanged ?? c.delta.entries.length),
306
+ };
307
+ return { delta, changed: g.changed || c.changed };
308
+ },
309
+ };
310
+ }
@@ -29,6 +29,15 @@
29
29
  * message is published verbatim as a plan document (whose filename is derived
30
30
  * from the leading `#` title), so a fenced or chat-suffixed plan degrades the
31
31
  * reviewable document the user sees.
32
+ *
33
+ * Fence hygiene inside the document is part of the same contract. A plan
34
+ * frequently quotes file content that itself contains fenced code blocks
35
+ * ("insert this section into README.md"); with a same-length outer fence the
36
+ * inner block's closer terminates the outer fence early and corrupts the
37
+ * rendered document — on every client (the react SDK and the terminal
38
+ * renderer alike). And because the plan viewers render top-level ```mermaid
39
+ * fences as diagrams, the directive steers diagrams into the plan body proper
40
+ * rather than leaving them buried, unrendered, inside quoted file content.
32
41
  */
33
42
  export const PLAN_MODE_DIRECTIVE = [
34
43
  "IMPORTANT: You are in Plan mode — a read-only analysis turn whose " +
@@ -50,6 +59,15 @@ export const PLAN_MODE_DIRECTIVE = [
50
59
  "- Reference concrete file paths and describe the specific changes " +
51
60
  "planned for each.",
52
61
  "- Do NOT wrap the document in a code fence.",
62
+ "- When quoting content that itself contains fenced code blocks (e.g. a " +
63
+ "proposed file section with a code sample inside), open the outer fence " +
64
+ "with MORE backticks than any inner fence (four or more) — a same-length " +
65
+ "inner closer would terminate the outer fence early and corrupt the " +
66
+ "rendered document.",
67
+ "- Fenced ```mermaid blocks at the top level of the document render as " +
68
+ "diagrams in the plan viewer. When a diagram helps communicate the " +
69
+ "design (architecture, flows), include it directly in the plan body — " +
70
+ "not only inside quoted file content, where it stays unrendered source.",
53
71
  '- Do NOT end with conversational closers ("Let me know...", "Shall I ' +
54
72
  'proceed?") — the next step is the user\'s Build action, and trailing ' +
55
73
  "chat would be published as part of the document.",
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Todo-list projection shared by every harness's status writer.
3
+ *
4
+ * All harnesses expose an agent to-do tool — the Cursor SDK emits `TodoWrite`
5
+ * (legacy) / `updateTodos` (current), the native deepagents harness emits
6
+ * `write_todos` — and each writes the SAME `AgentExecutionStatus.todos` proto
7
+ * map that the clients (React `TodoCard`, CLI) render. This module is the single
8
+ * place that maps a raw tool payload into that map, so the Cursor `TodoTracker`
9
+ * and the native v2/v3 status builders cannot drift in how they build todos.
10
+ *
11
+ * The mapping is a superset of what any one harness emits: the Cursor SDK sends
12
+ * per-item `id`/`created_at` and can send a `merge` flag and camelCase statuses;
13
+ * deepagents sends only `{ content, status }` (three statuses, no id, no merge).
14
+ * One helper handling the superset serves all three writers.
15
+ */
16
+
17
+ import { create } from "@bufbuild/protobuf";
18
+ import { TodoItemSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/todo_pb";
19
+ import type { TodoItem } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/todo_pb";
20
+ import { TodoStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
21
+ import { utcTimestamp } from "./status.js";
22
+
23
+ /**
24
+ * A single raw todo as it arrives in a to-do tool call's arguments. Every field
25
+ * is optional and defensively typed: the Cursor `updateTodos` schema omits
26
+ * per-item `id`, deepagents omits `id`/`created_at`, and the values reach us as
27
+ * untyped JSON (a Cursor `args` string or a protobuf `JsonObject` value).
28
+ */
29
+ export interface RawTodoItem {
30
+ id?: string;
31
+ content?: string;
32
+ status?: string;
33
+ created_at?: string;
34
+ }
35
+
36
+ /**
37
+ * Raw status string -> proto `TodoStatus`. Keyed on the lowercased status so
38
+ * the current SDK's camelCase `inProgress` and the legacy snake_case
39
+ * `in_progress` both resolve. `cancelled` is only ever emitted by the Cursor
40
+ * SDK — deepagents' three-status schema never sends it — but it is kept here so
41
+ * the one shared mapping is faithful to every producer. Unknown values fall back
42
+ * to `TODO_PENDING` at the call site.
43
+ */
44
+ const STATUS_MAP: Record<string, TodoStatus> = {
45
+ pending: TodoStatus.TODO_PENDING,
46
+ in_progress: TodoStatus.TODO_IN_PROGRESS,
47
+ inprogress: TodoStatus.TODO_IN_PROGRESS,
48
+ completed: TodoStatus.TODO_COMPLETED,
49
+ cancelled: TodoStatus.TODO_CANCELLED,
50
+ };
51
+
52
+ /**
53
+ * Project a to-do tool call's payload into a `status.todos` map, in place.
54
+ * Returns whether the map changed — the caller's "dirty" / force-persist signal.
55
+ *
56
+ * Semantics (identical across all three harness writers):
57
+ * - Empty or non-array `rawTodos`: a full-replace (`!merge`) clears the map and
58
+ * is a change; a merge is a no-op (nothing to merge in) and is not.
59
+ * - Otherwise a full-replace clears first, then every item is (re)written.
60
+ * - `id` is the provided id or a stable index fallback (`todo-<i>`); order is
61
+ * the array order, so index ids are stable across full-replace updates.
62
+ * - `created_at` is preserved from the prior same-id entry ONLY on merge; a
63
+ * full-replace clears first, so it becomes `raw.created_at || now`. (The
64
+ * native harness is always full-replace and sends no `created_at`, so a
65
+ * native todo's `created_at` is `now` on each write — deliberately identical
66
+ * to the Cursor non-merge path.)
67
+ * - `updated_at` is always `now`.
68
+ *
69
+ * Note on clearing: an empty full-replace clears the in-memory map, but the Go
70
+ * `update_status` activity replaces persisted todos only when the incoming map
71
+ * is non-empty, so a settled list survives in history — the clear never reaches
72
+ * the record. This is intentional and relied upon by both harnesses.
73
+ */
74
+ export function applyTodoUpdate(
75
+ target: { [key: string]: TodoItem },
76
+ rawTodos: unknown,
77
+ opts: { merge: boolean; now?: string },
78
+ ): boolean {
79
+ const { merge } = opts;
80
+ const now = opts.now ?? utcTimestamp();
81
+
82
+ if (!Array.isArray(rawTodos) || rawTodos.length === 0) {
83
+ if (!merge) {
84
+ clearMap(target);
85
+ return true;
86
+ }
87
+ return false;
88
+ }
89
+
90
+ if (!merge) {
91
+ clearMap(target);
92
+ }
93
+
94
+ for (let i = 0; i < rawTodos.length; i++) {
95
+ const raw = coerceRawTodo(rawTodos[i]);
96
+ const id = raw.id || `todo-${i}`;
97
+ const statusStr = (raw.status ?? "pending").toLowerCase();
98
+ const status = STATUS_MAP[statusStr] ?? TodoStatus.TODO_PENDING;
99
+
100
+ // Preserve the original creation time only when merging into an existing
101
+ // entry; a full-replace cleared the map above, so `existing` is undefined.
102
+ const existing = merge ? target[id] : undefined;
103
+
104
+ target[id] = create(TodoItemSchema, {
105
+ id,
106
+ content: raw.content ?? "",
107
+ status,
108
+ createdAt: existing?.createdAt || raw.created_at || now,
109
+ updatedAt: now,
110
+ });
111
+ }
112
+
113
+ return true;
114
+ }
115
+
116
+ /** Narrow an untyped array element to a {@link RawTodoItem}; non-objects become empty. */
117
+ function coerceRawTodo(value: unknown): RawTodoItem {
118
+ return typeof value === "object" && value !== null ? (value as RawTodoItem) : {};
119
+ }
120
+
121
+ /** Delete every key from the map in place (proto map fields have no `.clear()`). */
122
+ function clearMap(target: { [key: string]: TodoItem }): void {
123
+ for (const key of Object.keys(target)) {
124
+ delete target[key];
125
+ }
126
+ }
@@ -186,3 +186,37 @@ export function collectSubAgentToolCallIds(
186
186
  }
187
187
  return ids;
188
188
  }
189
+
190
+ /** Terminal tool-call statuses — a row that has finished (however it finished). */
191
+ const TERMINAL_TOOL_CALL_STATUSES: ReadonlySet<ToolCallStatus> = new Set([
192
+ ToolCallStatus.TOOL_CALL_COMPLETED,
193
+ ToolCallStatus.TOOL_CALL_FAILED,
194
+ ToolCallStatus.TOOL_CALL_SKIPPED,
195
+ ]);
196
+
197
+ /**
198
+ * Collect the ids of tool-call rows that have already SETTLED (reached a terminal
199
+ * state: completed, failed, or skipped) in a transcript.
200
+ *
201
+ * The deep-agent turn-boundary provenance derivation (DD-28) snapshots this
202
+ * BEFORE a turn's stream to scope "this turn's tool calls" by identity: a call
203
+ * whose id is absent from the snapshot is this-turn's — either freshly streamed,
204
+ * or a prior gate that was WAITING_APPROVAL before the stream and executes now on
205
+ * resume (StatusBuilder updates the seeded row in place, so it keeps its id).
206
+ * Unlike the Cursor harness, the deep-agent cannot scope positionally — an
207
+ * approved command executes at its SEEDED position — so identity is the only
208
+ * correct scope, mirroring {@link collectSubAgentToolCallIds}. Terminal (not just
209
+ * completed) so a PRIOR turn's failed/skipped non-shell call is excluded and
210
+ * cannot spuriously disqualify this turn.
211
+ */
212
+ export function collectSettledToolCallIds(
213
+ messages: readonly AgentMessage[],
214
+ ): Set<string> {
215
+ const ids = new Set<string>();
216
+ for (const msg of messages) {
217
+ for (const tc of msg.toolCalls) {
218
+ if (TERMINAL_TOOL_CALL_STATUSES.has(tc.status)) ids.add(tc.id);
219
+ }
220
+ }
221
+ return ids;
222
+ }