@stigmer/runner 3.1.2 → 3.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +29 -0
- package/dist/activities/execute-cursor/capture-flow.js +59 -0
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
- package/dist/activities/execute-cursor/command-provenance.d.ts +11 -25
- package/dist/activities/execute-cursor/command-provenance.js +25 -115
- package/dist/activities/execute-cursor/command-provenance.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +358 -557
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/todo-tracker.d.ts +6 -1
- package/dist/activities/execute-cursor/todo-tracker.js +15 -43
- package/dist/activities/execute-cursor/todo-tracker.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-cursor/turn-stream.d.ts +141 -0
- package/dist/activities/execute-cursor/turn-stream.js +249 -0
- package/dist/activities/execute-cursor/turn-stream.js.map +1 -0
- package/dist/activities/execute-deep-agent/command-provenance.d.ts +61 -0
- package/dist/activities/execute-deep-agent/command-provenance.js +72 -0
- package/dist/activities/execute-deep-agent/command-provenance.js.map +1 -0
- package/dist/activities/execute-deep-agent/index.js +73 -4
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder.js +8 -1
- package/dist/activities/execute-deep-agent/status-builder.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/activities/execute-deep-agent/v3-status-builder.js +12 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
- package/dist/otel.js +10 -0
- package/dist/otel.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-progress.d.ts +63 -0
- package/dist/shared/filereview/cas-progress.js +128 -0
- package/dist/shared/filereview/cas-progress.js.map +1 -0
- package/dist/shared/filereview/cas-substrate.d.ts +38 -0
- package/dist/shared/filereview/cas-substrate.js +48 -15
- package/dist/shared/filereview/cas-substrate.js.map +1 -1
- package/dist/shared/filereview/command-provenance.d.ts +93 -0
- package/dist/shared/filereview/command-provenance.js +132 -0
- package/dist/shared/filereview/command-provenance.js.map +1 -0
- 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 +48 -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 +5 -2
- package/dist/shared/filereview/index.js +5 -2
- 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 +167 -0
- package/dist/shared/filereview/progress.js +196 -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 +15 -4
- package/dist/shared/plan-mode-prompt.js +28 -5
- package/dist/shared/plan-mode-prompt.js.map +1 -1
- package/dist/shared/todos.d.ts +56 -0
- package/dist/shared/todos.js +98 -0
- package/dist/shared/todos.js.map +1 -0
- package/dist/shared/tool-row.d.ts +16 -0
- package/dist/shared/tool-row.js +31 -0
- package/dist/shared/tool-row.js.map +1 -1
- package/package.json +3 -2
- package/src/__tests__/otel-turn-span.test.ts +61 -0
- 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__/progress-substrate.test.ts +169 -0
- package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
- package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +349 -0
- package/src/activities/execute-cursor/attachment-resolver.ts +1 -1
- package/src/activities/execute-cursor/capture-flow.ts +78 -0
- package/src/activities/execute-cursor/command-provenance.ts +25 -120
- package/src/activities/execute-cursor/index.ts +419 -602
- package/src/activities/execute-cursor/todo-tracker.ts +17 -59
- package/src/activities/execute-cursor/turn-boundary.ts +274 -0
- package/src/activities/execute-cursor/turn-stream.ts +418 -0
- package/src/activities/execute-deep-agent/__tests__/command-provenance.test.ts +252 -0
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +11 -0
- package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +78 -0
- package/src/activities/execute-deep-agent/__tests__/v3-status-builder.test.ts +105 -1
- package/src/activities/execute-deep-agent/command-provenance.ts +102 -0
- package/src/activities/execute-deep-agent/index.ts +91 -4
- package/src/activities/execute-deep-agent/status-builder.ts +9 -0
- package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
- package/src/activities/execute-deep-agent/streaming.ts +12 -1
- package/src/activities/execute-deep-agent/v3-status-builder.ts +13 -0
- package/src/otel.ts +8 -0
- package/src/shared/__tests__/implement-plan-prompt.test.ts +40 -2
- package/src/shared/__tests__/plan-artifact.test.ts +142 -9
- package/src/shared/__tests__/todos.test.ts +216 -0
- package/src/shared/filereview/__tests__/capture.test.ts +182 -1
- package/src/shared/filereview/__tests__/cas-progress.test.ts +228 -0
- package/src/shared/filereview/__tests__/cas-substrate.test.ts +100 -0
- package/src/shared/filereview/__tests__/command-provenance.test.ts +252 -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 +338 -0
- package/src/shared/filereview/capture.ts +134 -30
- package/src/shared/filereview/cas-progress.ts +170 -0
- package/src/shared/filereview/cas-substrate.ts +79 -18
- package/src/shared/filereview/command-provenance.ts +180 -0
- package/src/shared/filereview/events.ts +50 -0
- package/src/shared/filereview/git-substrate.ts +114 -0
- package/src/shared/filereview/index.ts +36 -1
- package/src/shared/filereview/line-counts.ts +83 -0
- package/src/shared/filereview/progress.ts +310 -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 +28 -5
- package/src/shared/todos.ts +126 -0
- package/src/shared/tool-row.ts +34 -0
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
type CasPathCapture,
|
|
78
78
|
type CasSnapshotRef,
|
|
79
79
|
} from "./cas-substrate.js";
|
|
80
|
+
import { isSecretLikePath } from "./secret-paths.js";
|
|
80
81
|
|
|
81
82
|
/**
|
|
82
83
|
* Whether a turn runs in apply-then-review CAPTURE mode (file edits flow and are
|
|
@@ -244,21 +245,41 @@ export async function captureCandidateToLedger(opts: {
|
|
|
244
245
|
|
|
245
246
|
const unreviewable = unreviewablePaths ?? [];
|
|
246
247
|
|
|
248
|
+
// Secret handling is three-way across the substrates, and this is the last of
|
|
249
|
+
// the three (the other two happen upstream at the harness gate):
|
|
250
|
+
// 1. A gitignored secret WRITE never flows — hard-blocked at the gate /
|
|
251
|
+
// deny-gate (DD-12/DD-30). It arrives here only as a path in
|
|
252
|
+
// `unreviewablePaths` (recorded by the gate), authored content-less below.
|
|
253
|
+
// 2. A gitignored secret that flowed under the global bypass is withheld from
|
|
254
|
+
// CAS by `partitionIgnoredPathsBySecret` (secret-paths.ts), so its bytes
|
|
255
|
+
// never reach `casCaptures`.
|
|
256
|
+
// 3. A git-TRACKED secret DID flow (the capture-mode gate allows tracked
|
|
257
|
+
// mutations with no secret check) and is present in `gitChanges` with real
|
|
258
|
+
// bytes. We split it out here and author it content-less, so its content
|
|
259
|
+
// never reaches the ledger. Unlike a gitignored secret it HAS a reversible
|
|
260
|
+
// substrate (the git refs), so it is withheld-and-reverted rather than
|
|
261
|
+
// never-applied — same never-persisted guarantee, appropriate mechanism.
|
|
262
|
+
const { safe: safeGitChanges, secret: secretGitChanges } =
|
|
263
|
+
partitionGitChangesBySecret(gitChanges);
|
|
264
|
+
|
|
247
265
|
// A turn that changed nothing (no tracked/ignored change and no blocked path)
|
|
248
|
-
// authors no event.
|
|
266
|
+
// authors no event. A secret-only tracked turn is NOT a no-op — its file is in
|
|
267
|
+
// `gitChanges`, so the guard below is false and it authors a blocked entry.
|
|
249
268
|
if (gitChanges.length === 0 && casFiles.length === 0 && unreviewable.length === 0) {
|
|
250
269
|
return gitChanges;
|
|
251
270
|
}
|
|
252
271
|
|
|
253
|
-
// One combined change set: git-tracked (inline bodies) +
|
|
254
|
-
// secret-blocked (content-less
|
|
255
|
-
// all (buildCandidateCapturedEvent sorts by file
|
|
256
|
-
// and its identity span every substrate.
|
|
257
|
-
// per-file signals up three ways: any
|
|
258
|
-
// PARTIAL_BLOCKED; a set blocked only by
|
|
259
|
-
// (keepable in one acknowledged action); else
|
|
272
|
+
// One combined change set: git-tracked (inline bodies) + git-tracked-secret
|
|
273
|
+
// (content-less) + CAS (blob refs) + gitignored-secret-blocked (content-less).
|
|
274
|
+
// The aggregate digest folds all (buildCandidateCapturedEvent sorts by file
|
|
275
|
+
// digest), so the reviewed diff and its identity span every substrate.
|
|
276
|
+
// deriveDiffCompleteness then rolls the per-file signals up three ways: any
|
|
277
|
+
// content-less (non-binary) entry forces PARTIAL_BLOCKED; a set blocked only by
|
|
278
|
+
// binaries is BINARY_SUMMARY_ONLY (keepable in one acknowledged action); else
|
|
279
|
+
// COMPLETE.
|
|
260
280
|
const captured = [
|
|
261
|
-
...
|
|
281
|
+
...safeGitChanges.map((c) => buildCapturedFileChange(toCapturedChangeInput(changeSetId, c))),
|
|
282
|
+
...secretGitChanges.map((c) => buildCapturedFileChange(trackedSecretChangeInput(changeSetId, c))),
|
|
262
283
|
...casFiles.map((f) => buildCapturedFileChange(casToCapturedChangeInput(changeSetId, f))),
|
|
263
284
|
...unreviewable.map((p) => buildCapturedFileChange(unreviewableChangeInput(changeSetId, p, unreviewableCaptureClass))),
|
|
264
285
|
];
|
|
@@ -622,7 +643,9 @@ function candidateCasRef(changeSet: FileChangeSet): CasSnapshotRef | undefined {
|
|
|
622
643
|
* after bodies are carried as blob REFS (already offloaded to artifact storage
|
|
623
644
|
* by the CAS substrate), never re-inlined — so the CANDIDATE event stays small
|
|
624
645
|
* and the bytes are stored exactly once. Enforcement digests come from the blob
|
|
625
|
-
* content addresses, identical in form to the git path.
|
|
646
|
+
* content addresses, identical in form to the git path. The display line counts
|
|
647
|
+
* ride along explicitly — a ref side has no inline text for the producer to
|
|
648
|
+
* count, so the substrate's capture-time counts are the only honest source.
|
|
626
649
|
*/
|
|
627
650
|
function casToCapturedChangeInput(
|
|
628
651
|
changeSetId: string,
|
|
@@ -634,6 +657,7 @@ function casToCapturedChangeInput(
|
|
|
634
657
|
pathAfter: file.pathAfter,
|
|
635
658
|
kind: file.kind,
|
|
636
659
|
captureClass: file.captureClass,
|
|
660
|
+
lineCounts: file.lineCounts,
|
|
637
661
|
before: file.before
|
|
638
662
|
? {
|
|
639
663
|
kind: "ref",
|
|
@@ -657,37 +681,117 @@ function casToCapturedChangeInput(
|
|
|
657
681
|
}
|
|
658
682
|
|
|
659
683
|
/**
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
684
|
+
* The single content-less `SECRET_WITHHELD` producer input — the one shape every
|
|
685
|
+
* secret-blocked change (gitignored OR git-tracked) is authored with, so the two
|
|
686
|
+
* producers can never drift (a divergence in `diffComplete`/`blockedReason` would
|
|
687
|
+
* make the backend gate treat one kind of secret differently from the other).
|
|
688
|
+
*
|
|
689
|
+
* Both sides are absent (empty enforcement digests) and `diffComplete=false`.
|
|
690
|
+
* Being non-binary incomplete with no keepable bytes, it forces the change set to
|
|
691
|
+
* PARTIAL_BLOCKED (never BINARY_SUMMARY_ONLY) — approval is blocked and the path
|
|
692
|
+
* is surfaced honestly, while its CONTENT never enters the ledger or storage.
|
|
693
|
+
* `blockedReason=SECRET_WITHHELD` records the honest cause so the review UI can say
|
|
694
|
+
* *why* rather than a cause-agnostic "unavailable" (doc 15). Callers supply the
|
|
695
|
+
* id / paths / kind / captureClass so each producer keeps its own honest identity.
|
|
672
696
|
*/
|
|
673
|
-
function
|
|
674
|
-
|
|
675
|
-
|
|
697
|
+
function secretWithheldChangeInput(
|
|
698
|
+
id: string,
|
|
699
|
+
pathBefore: string,
|
|
700
|
+
pathAfter: string,
|
|
701
|
+
kind: FileChangeKind,
|
|
676
702
|
captureClass: FileCaptureClass,
|
|
677
703
|
): CapturedChangeInput {
|
|
678
704
|
return {
|
|
679
|
-
id
|
|
680
|
-
pathBefore
|
|
681
|
-
pathAfter
|
|
682
|
-
kind
|
|
705
|
+
id,
|
|
706
|
+
pathBefore,
|
|
707
|
+
pathAfter,
|
|
708
|
+
kind,
|
|
683
709
|
captureClass,
|
|
684
710
|
diffComplete: false,
|
|
685
711
|
blockedReason: FileReviewBlockReason.SECRET_WITHHELD,
|
|
686
712
|
};
|
|
687
713
|
}
|
|
688
714
|
|
|
715
|
+
/**
|
|
716
|
+
* Map a secret-blocked GITIGNORED path (the write never flowed — hard-blocked at
|
|
717
|
+
* the harness gate, design doc 12 / DD-E) to a content-less entry. The write was
|
|
718
|
+
* blocked before it ran, so create-vs-modify is unknown and irrelevant (nothing
|
|
719
|
+
* is ever applied or reconciled for this entry) — kind is MODIFY. `captureClass`
|
|
720
|
+
* is the turn's CAS substrate class (GIT_IGNORED_CAPTURED | NON_GIT_CAS).
|
|
721
|
+
*/
|
|
722
|
+
function unreviewableChangeInput(
|
|
723
|
+
changeSetId: string,
|
|
724
|
+
path: string,
|
|
725
|
+
captureClass: FileCaptureClass,
|
|
726
|
+
): CapturedChangeInput {
|
|
727
|
+
return secretWithheldChangeInput(
|
|
728
|
+
`${changeSetId}:${path}`,
|
|
729
|
+
path,
|
|
730
|
+
path,
|
|
731
|
+
FileChangeKind.MODIFY,
|
|
732
|
+
captureClass,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Map a secret-like GIT-TRACKED change to a content-less entry (DD-26 follow-up
|
|
738
|
+
* #3). Unlike a gitignored secret, a tracked secret write actually FLOWED (the
|
|
739
|
+
* capture-mode gate allows tracked mutations), so it is present in the git diff
|
|
740
|
+
* with real bytes — which must never be persisted into the ledger. We author it
|
|
741
|
+
* content-less here so its CONTENT never reaches the ledger / Temporal history /
|
|
742
|
+
* storage, while the honest kind + paths (a CREATE/MODIFY/DELETE, mirroring
|
|
743
|
+
* {@link toCapturedChangeInput}'s id/path derivation so the resume reconcile
|
|
744
|
+
* lookup matches) and `GIT_TRACKED` provenance are preserved. It is discard-only
|
|
745
|
+
* (PARTIAL_BLOCKED): on resume it can only be rejected, and the git substrate
|
|
746
|
+
* reverts it byte-exact from the baseline ref (the reversible substrate a
|
|
747
|
+
* gitignored secret lacks — so "withhold + revert" upholds never-persisted here
|
|
748
|
+
* without the gate hard-block). A DELETE is handled here too: the git diff
|
|
749
|
+
* resurrects the deleted file's baseline (secret) bytes as the `before` side, a
|
|
750
|
+
* leak only this seam can withhold (the gate sees no content for a delete).
|
|
751
|
+
*/
|
|
752
|
+
function trackedSecretChangeInput(
|
|
753
|
+
changeSetId: string,
|
|
754
|
+
change: GitCapturedChange,
|
|
755
|
+
): CapturedChangeInput {
|
|
756
|
+
const isCreate = change.changeType === FileChangeType.CREATE;
|
|
757
|
+
const isDelete = change.changeType === FileChangeType.DELETE;
|
|
758
|
+
const pathBefore = isCreate ? "" : change.path;
|
|
759
|
+
const pathAfter = isDelete ? "" : change.path;
|
|
760
|
+
return secretWithheldChangeInput(
|
|
761
|
+
`${changeSetId}:${pathAfter || pathBefore}`,
|
|
762
|
+
pathBefore,
|
|
763
|
+
pathAfter,
|
|
764
|
+
toFileChangeKind(change.changeType),
|
|
765
|
+
FileCaptureClass.GIT_TRACKED,
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Split a turn's git-tracked changes by the secret gate: a secret-like tracked
|
|
771
|
+
* path (a committed `credentials.json`, `.env.example`, `*.tfvars`, ...) goes to
|
|
772
|
+
* `secret` (authored content-less via {@link trackedSecretChangeInput}); every
|
|
773
|
+
* other tracked change stays in `safe` (its diff is captured normally). Pure and
|
|
774
|
+
* exported for direct unit testing. Keyed on the same {@link isSecretLikePath}
|
|
775
|
+
* classifier the CAS/gitignored path uses ({@link partitionIgnoredPathsBySecret}
|
|
776
|
+
* in secret-paths.ts), so a path is classified identically everywhere. NOTE: the
|
|
777
|
+
* classifier is path-based (DD-12 D2), so a rename to an innocuous name defeats
|
|
778
|
+
* it — an intentional, cross-substrate limitation, not introduced here.
|
|
779
|
+
*/
|
|
780
|
+
export function partitionGitChangesBySecret(changes: readonly GitCapturedChange[]): {
|
|
781
|
+
readonly safe: readonly GitCapturedChange[];
|
|
782
|
+
readonly secret: readonly GitCapturedChange[];
|
|
783
|
+
} {
|
|
784
|
+
const safe: GitCapturedChange[] = [];
|
|
785
|
+
const secret: GitCapturedChange[] = [];
|
|
786
|
+
for (const change of changes) {
|
|
787
|
+
if (isSecretLikePath(change.path)) secret.push(change);
|
|
788
|
+
else safe.push(change);
|
|
789
|
+
}
|
|
790
|
+
return { safe, secret };
|
|
791
|
+
}
|
|
792
|
+
|
|
689
793
|
/** Map the git capture kind to the file-review {@link FileChangeKind}. */
|
|
690
|
-
function toFileChangeKind(changeType: FileChangeType): FileChangeKind {
|
|
794
|
+
export function toFileChangeKind(changeType: FileChangeType): FileChangeKind {
|
|
691
795
|
switch (changeType) {
|
|
692
796
|
case FileChangeType.CREATE:
|
|
693
797
|
return FileChangeKind.ADD;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mid-run progress substrate for the CAS (content-addressed) domain — the
|
|
3
|
+
* non-git and gitignored half of DD-33. The git substrate ({@link ./progress.js}
|
|
4
|
+
* `createGitProgressSubstrate`) covers git-tracked changes cheaply via
|
|
5
|
+
* `--numstat`; this covers the paths git cannot see, sourced from the same
|
|
6
|
+
* per-turn observer the turn-boundary CAS capture reads:
|
|
7
|
+
* - a non-git workspace: EVERY tool-mediated write (the whole change set), and
|
|
8
|
+
* - a git tree's gitignored writes (the CAS half of a HYBRID turn).
|
|
9
|
+
*
|
|
10
|
+
* COST MODEL (why this is not as cheap as git)
|
|
11
|
+
* --------------------------------------------
|
|
12
|
+
* `git --numstat` yields exact `+N −M` with zero byte reads; CAS has no such free
|
|
13
|
+
* lunch — kind, no-op exclusion, and counts all need the after-bytes. So we bound
|
|
14
|
+
* the work: only the sorted read-PREFIX (up to {@link PROGRESS_MAX_ENTRIES}) has
|
|
15
|
+
* its after-bytes read and counted; the tail contributes to `totalFilesChanged`
|
|
16
|
+
* (the honest count) only. `files_changed` is therefore net-exact for a normal
|
|
17
|
+
* turn (≤ the budget) and an upper bound beyond it — matching git where git is
|
|
18
|
+
* free, diverging only where the substrate cannot be. A `size+mtime` signature
|
|
19
|
+
* (the stats are collected anyway) short-circuits an unchanged capture.
|
|
20
|
+
*
|
|
21
|
+
* CONSISTENCY & SECRET SAFETY
|
|
22
|
+
* ---------------------------
|
|
23
|
+
* Counts come from {@link classifyCasChange} — the SAME classifier the reviewed
|
|
24
|
+
* set uses — so the strip and the change set never disagree. Secret-like paths
|
|
25
|
+
* are excluded up front via {@link partitionIgnoredPathsBySecret} (and zeroed
|
|
26
|
+
* again in `buildFileChangeProgress` as a backstop); no file bodies are ever
|
|
27
|
+
* carried on the wire. Only tool-mediated writes are observed, exactly the scope
|
|
28
|
+
* of the non-git / HYBRID turn-boundary capture — no new divergence.
|
|
29
|
+
*
|
|
30
|
+
* @since File-Change HITL Redesign (non-git + hybrid mid-run progress / DD-33)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { readFile, stat } from "node:fs/promises";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import { FileChangeKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
36
|
+
import { classifyCasChange } from "./cas-substrate.js";
|
|
37
|
+
import { LINE_COUNT_MAX_BYTES } from "./line-counts.js";
|
|
38
|
+
import {
|
|
39
|
+
PROGRESS_MAX_ENTRIES,
|
|
40
|
+
type ProgressCapture,
|
|
41
|
+
type ProgressDelta,
|
|
42
|
+
type ProgressEntry,
|
|
43
|
+
type ProgressSubstrate,
|
|
44
|
+
} from "./progress.js";
|
|
45
|
+
import { partitionIgnoredPathsBySecret } from "./secret-paths.js";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A snapshot of the turn's CAS-observed paths, read on each capture:
|
|
49
|
+
* - `before`: first-touched paths' pre-turn bytes (`null` = the path did not
|
|
50
|
+
* exist before → an ADD). The KEYS are the touched-path set.
|
|
51
|
+
* - `blockedSecretPaths`: paths the gate hard-blocked as secret-like — excluded
|
|
52
|
+
* from capture.
|
|
53
|
+
*
|
|
54
|
+
* Deep-agent supplies a synchronous copy of its live `CasCaptureObserver`; Cursor
|
|
55
|
+
* supplies a reader over the hook's on-disk sidecar. The reader MUST return an
|
|
56
|
+
* atomic snapshot (copy the live map before any await) so a concurrent sub-agent
|
|
57
|
+
* write cannot mutate it mid-capture.
|
|
58
|
+
*/
|
|
59
|
+
export interface CasTouchedSnapshot {
|
|
60
|
+
readonly before: ReadonlyMap<string, Uint8Array | null>;
|
|
61
|
+
readonly blockedSecretPaths: ReadonlySet<string>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Produces a {@link CasTouchedSnapshot} for one capture. */
|
|
65
|
+
export type CasTouchedReader = () => CasTouchedSnapshot | Promise<CasTouchedSnapshot>;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The CAS progress substrate. Reads the touched set on each capture, computes
|
|
69
|
+
* kind + `+N −M` for the bounded read-prefix via {@link classifyCasChange}, and
|
|
70
|
+
* reports `totalFilesChanged` over the full capturable set. Owns a `size+mtime`
|
|
71
|
+
* signature so an unchanged capture returns `changed:false` (the cached delta is
|
|
72
|
+
* reused, letting the hybrid still merge it with a changed git slice).
|
|
73
|
+
*/
|
|
74
|
+
export function createCasProgressSubstrate(opts: {
|
|
75
|
+
readonly workspaceRoot: string;
|
|
76
|
+
readonly read: CasTouchedReader;
|
|
77
|
+
/** Read budget / display cap; defaults to {@link PROGRESS_MAX_ENTRIES}. Injectable for tests. */
|
|
78
|
+
readonly maxEntries?: number;
|
|
79
|
+
}): ProgressSubstrate {
|
|
80
|
+
const maxEntries = opts.maxEntries ?? PROGRESS_MAX_ENTRIES;
|
|
81
|
+
let cachedFull: ProgressDelta = { entries: [], totalFilesChanged: 0 };
|
|
82
|
+
let lastSignature: string | undefined;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
async capture(): Promise<ProgressCapture> {
|
|
86
|
+
const snapshot = await opts.read();
|
|
87
|
+
const { capturablePaths } = partitionIgnoredPathsBySecret(
|
|
88
|
+
snapshot.before.keys(),
|
|
89
|
+
snapshot.blockedSecretPaths,
|
|
90
|
+
);
|
|
91
|
+
// Deterministic order so the read prefix and entry order are stable.
|
|
92
|
+
const sorted = [...capturablePaths].sort();
|
|
93
|
+
const prefix = sorted.slice(0, maxEntries);
|
|
94
|
+
|
|
95
|
+
const entries: ProgressEntry[] = [];
|
|
96
|
+
// The signature keys on the capturable count (catches a new touch) plus the
|
|
97
|
+
// read prefix's size+mtime (catches a content edit to a shown file). A
|
|
98
|
+
// change to an UNshown tail file has no display effect, so it is not keyed.
|
|
99
|
+
const sigParts: string[] = [`${capturablePaths.length}`];
|
|
100
|
+
|
|
101
|
+
for (const relPath of prefix) {
|
|
102
|
+
const abs = join(opts.workspaceRoot, relPath);
|
|
103
|
+
const st = await statOrNull(abs);
|
|
104
|
+
sigParts.push(`${relPath}\u0000${st ? `${st.size}:${st.mtimeMs}` : "\u2205"}`);
|
|
105
|
+
|
|
106
|
+
const beforeBytes = snapshot.before.get(relPath) ?? null;
|
|
107
|
+
const beforeBuf = beforeBytes === null ? null : Buffer.from(beforeBytes);
|
|
108
|
+
|
|
109
|
+
// Oversized after: derive the kind without reading (bound the I/O) and
|
|
110
|
+
// leave counts at zero — the same "no stat" shape a binary/oversized side
|
|
111
|
+
// takes in the reviewed set. A same-size no-op cannot be excluded here; an
|
|
112
|
+
// accepted overstatement only for very large files.
|
|
113
|
+
if (st && st.size > LINE_COUNT_MAX_BYTES) {
|
|
114
|
+
const kind = beforeBuf === null ? FileChangeKind.ADD : FileChangeKind.MODIFY;
|
|
115
|
+
entries.push({
|
|
116
|
+
pathBefore: kind === FileChangeKind.ADD ? "" : relPath,
|
|
117
|
+
pathAfter: relPath,
|
|
118
|
+
kind,
|
|
119
|
+
linesAdded: 0,
|
|
120
|
+
linesRemoved: 0,
|
|
121
|
+
});
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const afterBytes = st ? await readFileOrNull(abs) : null;
|
|
126
|
+
const afterBuf = afterBytes === null ? null : Buffer.from(afterBytes);
|
|
127
|
+
const cls = classifyCasChange(relPath, beforeBuf, afterBuf);
|
|
128
|
+
if (!cls) continue; // no-op (unchanged touch) or both sides absent
|
|
129
|
+
entries.push({
|
|
130
|
+
pathBefore: cls.pathBefore,
|
|
131
|
+
pathAfter: cls.pathAfter,
|
|
132
|
+
kind: cls.kind,
|
|
133
|
+
linesAdded: cls.lineCounts?.linesAdded ?? 0,
|
|
134
|
+
linesRemoved: cls.lineCounts?.linesRemoved ?? 0,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Honest total: the capturable set minus the no-ops detected in the read
|
|
139
|
+
// prefix. Net-exact for a turn within the read budget; an upper bound beyond
|
|
140
|
+
// it (tail no-ops are not read, so cannot be excluded).
|
|
141
|
+
const prefixNoOps = prefix.length - entries.length;
|
|
142
|
+
const totalFilesChanged = capturablePaths.length - prefixNoOps;
|
|
143
|
+
|
|
144
|
+
const signature = sigParts.join("|");
|
|
145
|
+
if (signature === lastSignature) {
|
|
146
|
+
return { delta: cachedFull, changed: false };
|
|
147
|
+
}
|
|
148
|
+
lastSignature = signature;
|
|
149
|
+
cachedFull = { entries, totalFilesChanged };
|
|
150
|
+
return { delta: cachedFull, changed: true };
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function statOrNull(abs: string): Promise<{ size: number; mtimeMs: number } | null> {
|
|
156
|
+
try {
|
|
157
|
+
const s = await stat(abs);
|
|
158
|
+
return { size: s.size, mtimeMs: s.mtimeMs };
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function readFileOrNull(abs: string): Promise<Uint8Array | null> {
|
|
165
|
+
try {
|
|
166
|
+
return await readFile(abs);
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -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. */
|
|
@@ -180,6 +189,66 @@ export async function snapshotCasChangeSet(opts: {
|
|
|
180
189
|
};
|
|
181
190
|
}
|
|
182
191
|
|
|
192
|
+
/**
|
|
193
|
+
* The display-facing classification of one CAS path's before/after bytes: the
|
|
194
|
+
* change kind, whether either side is binary, and the `+N −M` line counts.
|
|
195
|
+
*/
|
|
196
|
+
export interface CasChangeClassification {
|
|
197
|
+
/** Path before the change (workspace-relative). Empty for ADD. */
|
|
198
|
+
readonly pathBefore: string;
|
|
199
|
+
/** Path after the change. Empty for DELETE. */
|
|
200
|
+
readonly pathAfter: string;
|
|
201
|
+
readonly kind: FileChangeKind;
|
|
202
|
+
/** A NUL byte on either side — the diff cannot render as text. */
|
|
203
|
+
readonly isBinary: boolean;
|
|
204
|
+
/** `+N −M` from the in-memory bytes; absent for a binary or oversized side. */
|
|
205
|
+
readonly lineCounts?: LineChangeCounts;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Classify one CAS path's before/after bytes. Returns `undefined` for a
|
|
210
|
+
* non-change — both sides absent, or a touch that left the bytes unchanged —
|
|
211
|
+
* exactly as the git substrate's diff omits unchanged files.
|
|
212
|
+
*
|
|
213
|
+
* This is the SINGLE classification authority for a CAS before/after pair: the
|
|
214
|
+
* turn-boundary capture ({@link buildCasCapturedFile}) and the mid-run progress
|
|
215
|
+
* producer ({@link ./cas-progress.js}) both route through it, so the live "N
|
|
216
|
+
* files changed so far" strip and the reviewed change set can never disagree on
|
|
217
|
+
* an edge case (the trailing-newline class of bug {@link ./line-counts.js}
|
|
218
|
+
* warns about). Kind, no-op detection, binary detection, and counting live here
|
|
219
|
+
* and nowhere else.
|
|
220
|
+
*/
|
|
221
|
+
export function classifyCasChange(
|
|
222
|
+
path: string,
|
|
223
|
+
beforeBuf: Buffer | null,
|
|
224
|
+
afterBuf: Buffer | null,
|
|
225
|
+
): CasChangeClassification | undefined {
|
|
226
|
+
if (beforeBuf === null && afterBuf === null) return undefined;
|
|
227
|
+
// A touch that did not change the bytes is not a reviewable change.
|
|
228
|
+
if (beforeBuf && afterBuf && beforeBuf.equals(afterBuf)) return undefined;
|
|
229
|
+
|
|
230
|
+
const kind =
|
|
231
|
+
beforeBuf === null
|
|
232
|
+
? FileChangeKind.ADD
|
|
233
|
+
: afterBuf === null
|
|
234
|
+
? FileChangeKind.DELETE
|
|
235
|
+
: FileChangeKind.MODIFY;
|
|
236
|
+
const isBinary =
|
|
237
|
+
(beforeBuf !== null && bytesLookBinary(beforeBuf)) ||
|
|
238
|
+
(afterBuf !== null && bytesLookBinary(afterBuf));
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
pathBefore: kind === FileChangeKind.ADD ? "" : path,
|
|
242
|
+
pathAfter: kind === FileChangeKind.DELETE ? "" : path,
|
|
243
|
+
kind,
|
|
244
|
+
isBinary,
|
|
245
|
+
// A binary change has no text line diff to count.
|
|
246
|
+
lineCounts: isBinary
|
|
247
|
+
? undefined
|
|
248
|
+
: countLineChanges(beforeBuf?.toString("utf8"), afterBuf?.toString("utf8")),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
183
252
|
/**
|
|
184
253
|
* Store one path's before/after blobs and classify the change. Returns
|
|
185
254
|
* `undefined` for a no-op (both sides absent, or an unchanged touch).
|
|
@@ -190,37 +259,29 @@ async function buildCasCapturedFile(
|
|
|
190
259
|
capture: CasPathCapture,
|
|
191
260
|
): Promise<CasCapturedFile | undefined> {
|
|
192
261
|
const { path, before, after, captureClass } = capture;
|
|
193
|
-
if (before === null && after === null) return undefined;
|
|
194
|
-
|
|
195
262
|
const beforeBuf = before === null ? null : Buffer.from(before);
|
|
196
263
|
const afterBuf = after === null ? null : Buffer.from(after);
|
|
197
264
|
|
|
198
|
-
|
|
199
|
-
if (
|
|
265
|
+
const classification = classifyCasChange(path, beforeBuf, afterBuf);
|
|
266
|
+
if (!classification) return undefined;
|
|
200
267
|
|
|
201
268
|
const beforeRef = beforeBuf ? await storeBlob(storage, executionId, beforeBuf) : undefined;
|
|
202
269
|
const afterRef = afterBuf ? await storeBlob(storage, executionId, afterBuf) : undefined;
|
|
203
270
|
|
|
204
|
-
const kind =
|
|
205
|
-
beforeRef === undefined
|
|
206
|
-
? FileChangeKind.ADD
|
|
207
|
-
: afterRef === undefined
|
|
208
|
-
? FileChangeKind.DELETE
|
|
209
|
-
: FileChangeKind.MODIFY;
|
|
210
|
-
|
|
211
|
-
const isCreate = kind === FileChangeKind.ADD;
|
|
212
|
-
const isDelete = kind === FileChangeKind.DELETE;
|
|
213
|
-
|
|
214
271
|
return {
|
|
215
|
-
pathBefore:
|
|
216
|
-
pathAfter:
|
|
217
|
-
kind,
|
|
272
|
+
pathBefore: classification.pathBefore,
|
|
273
|
+
pathAfter: classification.pathAfter,
|
|
274
|
+
kind: classification.kind,
|
|
218
275
|
captureClass,
|
|
219
276
|
before: beforeRef,
|
|
220
277
|
after: afterRef,
|
|
221
278
|
// Binary on either side means the diff cannot render as text; the change set
|
|
222
279
|
// then cannot be approved as complete (parity with the git substrate).
|
|
223
|
-
diffComplete: !
|
|
280
|
+
diffComplete: !classification.isBinary,
|
|
281
|
+
// Display counts are taken NOW, while the text bytes are still in memory —
|
|
282
|
+
// after this they exist only as offloaded blobs (the same counts the mid-run
|
|
283
|
+
// progress producer derives via classifyCasChange).
|
|
284
|
+
lineCounts: classification.lineCounts,
|
|
224
285
|
};
|
|
225
286
|
}
|
|
226
287
|
|