@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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.d.ts +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.js +1 -1
- package/dist/activities/execute-cursor/capture-flow.d.ts +18 -0
- package/dist/activities/execute-cursor/capture-flow.js +21 -0
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +121 -139
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/turn-boundary.d.ts +108 -0
- package/dist/activities/execute-cursor/turn-boundary.js +163 -0
- package/dist/activities/execute-cursor/turn-boundary.js.map +1 -0
- package/dist/activities/execute-deep-agent/index.js +30 -3
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming-v3.js +6 -1
- package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming.d.ts +7 -0
- package/dist/activities/execute-deep-agent/streaming.js +5 -1
- package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
- package/dist/shared/filereview/capture.d.ts +18 -1
- package/dist/shared/filereview/capture.js +105 -28
- package/dist/shared/filereview/capture.js.map +1 -1
- package/dist/shared/filereview/cas-substrate.d.ts +9 -0
- package/dist/shared/filereview/cas-substrate.js +9 -1
- package/dist/shared/filereview/cas-substrate.js.map +1 -1
- package/dist/shared/filereview/events.d.ts +16 -0
- package/dist/shared/filereview/events.js +37 -0
- package/dist/shared/filereview/events.js.map +1 -1
- package/dist/shared/filereview/git-substrate.d.ts +42 -0
- package/dist/shared/filereview/git-substrate.js +78 -0
- package/dist/shared/filereview/git-substrate.js.map +1 -1
- package/dist/shared/filereview/index.d.ts +3 -1
- package/dist/shared/filereview/index.js +3 -1
- package/dist/shared/filereview/index.js.map +1 -1
- package/dist/shared/filereview/line-counts.d.ts +48 -0
- package/dist/shared/filereview/line-counts.js +72 -0
- package/dist/shared/filereview/line-counts.js.map +1 -0
- package/dist/shared/filereview/progress.d.ts +96 -0
- package/dist/shared/filereview/progress.js +134 -0
- package/dist/shared/filereview/progress.js.map +1 -0
- package/dist/shared/implement-plan-prompt.d.ts +6 -4
- package/dist/shared/implement-plan-prompt.js +38 -6
- package/dist/shared/implement-plan-prompt.js.map +1 -1
- package/dist/shared/plan-artifact.d.ts +59 -11
- package/dist/shared/plan-artifact.js +144 -16
- package/dist/shared/plan-artifact.js.map +1 -1
- package/dist/shared/plan-mode-prompt.d.ts +6 -4
- package/dist/shared/plan-mode-prompt.js +10 -5
- package/dist/shared/plan-mode-prompt.js.map +1 -1
- package/package.json +3 -2
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +7 -0
- package/src/activities/execute-cursor/__tests__/delta-enricher.test.ts +68 -0
- package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
- package/src/activities/execute-cursor/attachment-resolver.ts +1 -1
- package/src/activities/execute-cursor/capture-flow.ts +32 -0
- package/src/activities/execute-cursor/index.ts +130 -153
- package/src/activities/execute-cursor/turn-boundary.ts +274 -0
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +11 -0
- package/src/activities/execute-deep-agent/index.ts +34 -3
- package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
- package/src/activities/execute-deep-agent/streaming.ts +12 -1
- package/src/shared/__tests__/implement-plan-prompt.test.ts +40 -2
- package/src/shared/__tests__/plan-artifact.test.ts +142 -9
- package/src/shared/filereview/__tests__/capture.test.ts +182 -1
- package/src/shared/filereview/__tests__/cas-substrate.test.ts +34 -0
- package/src/shared/filereview/__tests__/events.test.ts +105 -0
- package/src/shared/filereview/__tests__/line-counts.test.ts +73 -0
- package/src/shared/filereview/__tests__/progress.test.ts +236 -0
- package/src/shared/filereview/capture.ts +134 -30
- package/src/shared/filereview/cas-substrate.ts +17 -1
- package/src/shared/filereview/events.ts +50 -0
- package/src/shared/filereview/git-substrate.ts +108 -0
- package/src/shared/filereview/index.ts +22 -1
- package/src/shared/filereview/line-counts.ts +83 -0
- package/src/shared/filereview/progress.ts +186 -0
- package/src/shared/implement-plan-prompt.ts +39 -8
- package/src/shared/plan-artifact.ts +152 -16
- 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: !
|
|
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 {
|
|
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
|
|
15
|
-
* The directive points the model at
|
|
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 {
|
|
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
|
|
32
|
-
*
|
|
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
|
-
|
|
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
|
}
|