@stigmer/runner 3.1.2 → 3.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.d.ts +1 -1
  3. package/dist/activities/execute-cursor/attachment-resolver.js +1 -1
  4. package/dist/activities/execute-cursor/capture-flow.d.ts +29 -0
  5. package/dist/activities/execute-cursor/capture-flow.js +59 -0
  6. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  7. package/dist/activities/execute-cursor/command-provenance.d.ts +11 -25
  8. package/dist/activities/execute-cursor/command-provenance.js +25 -115
  9. package/dist/activities/execute-cursor/command-provenance.js.map +1 -1
  10. package/dist/activities/execute-cursor/index.js +358 -557
  11. package/dist/activities/execute-cursor/index.js.map +1 -1
  12. package/dist/activities/execute-cursor/todo-tracker.d.ts +6 -1
  13. package/dist/activities/execute-cursor/todo-tracker.js +15 -43
  14. package/dist/activities/execute-cursor/todo-tracker.js.map +1 -1
  15. package/dist/activities/execute-cursor/turn-boundary.d.ts +108 -0
  16. package/dist/activities/execute-cursor/turn-boundary.js +163 -0
  17. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -0
  18. package/dist/activities/execute-cursor/turn-stream.d.ts +141 -0
  19. package/dist/activities/execute-cursor/turn-stream.js +249 -0
  20. package/dist/activities/execute-cursor/turn-stream.js.map +1 -0
  21. package/dist/activities/execute-deep-agent/command-provenance.d.ts +61 -0
  22. package/dist/activities/execute-deep-agent/command-provenance.js +72 -0
  23. package/dist/activities/execute-deep-agent/command-provenance.js.map +1 -0
  24. package/dist/activities/execute-deep-agent/index.js +73 -4
  25. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  26. package/dist/activities/execute-deep-agent/status-builder.js +8 -1
  27. package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
  28. package/dist/activities/execute-deep-agent/streaming-v3.js +6 -1
  29. package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
  30. package/dist/activities/execute-deep-agent/streaming.d.ts +7 -0
  31. package/dist/activities/execute-deep-agent/streaming.js +5 -1
  32. package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/v3-status-builder.js +12 -1
  34. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
  35. package/dist/otel.js +10 -0
  36. package/dist/otel.js.map +1 -1
  37. package/dist/shared/filereview/capture.d.ts +18 -1
  38. package/dist/shared/filereview/capture.js +105 -28
  39. package/dist/shared/filereview/capture.js.map +1 -1
  40. package/dist/shared/filereview/cas-progress.d.ts +63 -0
  41. package/dist/shared/filereview/cas-progress.js +128 -0
  42. package/dist/shared/filereview/cas-progress.js.map +1 -0
  43. package/dist/shared/filereview/cas-substrate.d.ts +38 -0
  44. package/dist/shared/filereview/cas-substrate.js +48 -15
  45. package/dist/shared/filereview/cas-substrate.js.map +1 -1
  46. package/dist/shared/filereview/command-provenance.d.ts +93 -0
  47. package/dist/shared/filereview/command-provenance.js +132 -0
  48. package/dist/shared/filereview/command-provenance.js.map +1 -0
  49. package/dist/shared/filereview/events.d.ts +16 -0
  50. package/dist/shared/filereview/events.js +37 -0
  51. package/dist/shared/filereview/events.js.map +1 -1
  52. package/dist/shared/filereview/git-substrate.d.ts +48 -0
  53. package/dist/shared/filereview/git-substrate.js +78 -0
  54. package/dist/shared/filereview/git-substrate.js.map +1 -1
  55. package/dist/shared/filereview/index.d.ts +5 -2
  56. package/dist/shared/filereview/index.js +5 -2
  57. package/dist/shared/filereview/index.js.map +1 -1
  58. package/dist/shared/filereview/line-counts.d.ts +48 -0
  59. package/dist/shared/filereview/line-counts.js +72 -0
  60. package/dist/shared/filereview/line-counts.js.map +1 -0
  61. package/dist/shared/filereview/progress.d.ts +167 -0
  62. package/dist/shared/filereview/progress.js +196 -0
  63. package/dist/shared/filereview/progress.js.map +1 -0
  64. package/dist/shared/implement-plan-prompt.d.ts +6 -4
  65. package/dist/shared/implement-plan-prompt.js +38 -6
  66. package/dist/shared/implement-plan-prompt.js.map +1 -1
  67. package/dist/shared/plan-artifact.d.ts +59 -11
  68. package/dist/shared/plan-artifact.js +144 -16
  69. package/dist/shared/plan-artifact.js.map +1 -1
  70. package/dist/shared/plan-mode-prompt.d.ts +15 -4
  71. package/dist/shared/plan-mode-prompt.js +28 -5
  72. package/dist/shared/plan-mode-prompt.js.map +1 -1
  73. package/dist/shared/todos.d.ts +56 -0
  74. package/dist/shared/todos.js +98 -0
  75. package/dist/shared/todos.js.map +1 -0
  76. package/dist/shared/tool-row.d.ts +16 -0
  77. package/dist/shared/tool-row.js +31 -0
  78. package/dist/shared/tool-row.js.map +1 -1
  79. package/package.json +3 -2
  80. package/src/__tests__/otel-turn-span.test.ts +61 -0
  81. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +7 -0
  82. package/src/activities/execute-cursor/__tests__/delta-enricher.test.ts +68 -0
  83. package/src/activities/execute-cursor/__tests__/progress-substrate.test.ts +169 -0
  84. package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
  85. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +349 -0
  86. package/src/activities/execute-cursor/attachment-resolver.ts +1 -1
  87. package/src/activities/execute-cursor/capture-flow.ts +78 -0
  88. package/src/activities/execute-cursor/command-provenance.ts +25 -120
  89. package/src/activities/execute-cursor/index.ts +419 -602
  90. package/src/activities/execute-cursor/todo-tracker.ts +17 -59
  91. package/src/activities/execute-cursor/turn-boundary.ts +274 -0
  92. package/src/activities/execute-cursor/turn-stream.ts +418 -0
  93. package/src/activities/execute-deep-agent/__tests__/command-provenance.test.ts +252 -0
  94. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +11 -0
  95. package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +78 -0
  96. package/src/activities/execute-deep-agent/__tests__/v3-status-builder.test.ts +105 -1
  97. package/src/activities/execute-deep-agent/command-provenance.ts +102 -0
  98. package/src/activities/execute-deep-agent/index.ts +91 -4
  99. package/src/activities/execute-deep-agent/status-builder.ts +9 -0
  100. package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
  101. package/src/activities/execute-deep-agent/streaming.ts +12 -1
  102. package/src/activities/execute-deep-agent/v3-status-builder.ts +13 -0
  103. package/src/otel.ts +8 -0
  104. package/src/shared/__tests__/implement-plan-prompt.test.ts +40 -2
  105. package/src/shared/__tests__/plan-artifact.test.ts +142 -9
  106. package/src/shared/__tests__/todos.test.ts +216 -0
  107. package/src/shared/filereview/__tests__/capture.test.ts +182 -1
  108. package/src/shared/filereview/__tests__/cas-progress.test.ts +228 -0
  109. package/src/shared/filereview/__tests__/cas-substrate.test.ts +100 -0
  110. package/src/shared/filereview/__tests__/command-provenance.test.ts +252 -0
  111. package/src/shared/filereview/__tests__/events.test.ts +105 -0
  112. package/src/shared/filereview/__tests__/line-counts.test.ts +73 -0
  113. package/src/shared/filereview/__tests__/progress.test.ts +338 -0
  114. package/src/shared/filereview/capture.ts +134 -30
  115. package/src/shared/filereview/cas-progress.ts +170 -0
  116. package/src/shared/filereview/cas-substrate.ts +79 -18
  117. package/src/shared/filereview/command-provenance.ts +180 -0
  118. package/src/shared/filereview/events.ts +50 -0
  119. package/src/shared/filereview/git-substrate.ts +114 -0
  120. package/src/shared/filereview/index.ts +36 -1
  121. package/src/shared/filereview/line-counts.ts +83 -0
  122. package/src/shared/filereview/progress.ts +310 -0
  123. package/src/shared/implement-plan-prompt.ts +39 -8
  124. package/src/shared/plan-artifact.ts +152 -16
  125. package/src/shared/plan-mode-prompt.ts +28 -5
  126. package/src/shared/todos.ts +126 -0
  127. package/src/shared/tool-row.ts +34 -0
@@ -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) + CAS (blob refs) +
254
- // secret-blocked (content-less, DIFF_UNREVIEWABLE). The aggregate digest folds
255
- // all (buildCandidateCapturedEvent sorts by file digest), so the reviewed diff
256
- // and its identity span every substrate. deriveDiffCompleteness then rolls the
257
- // per-file signals up three ways: any content-less (non-binary) entry forces
258
- // PARTIAL_BLOCKED; a set blocked only by binaries is BINARY_SUMMARY_ONLY
259
- // (keepable in one acknowledged action); else COMPLETE.
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
- ...gitChanges.map((c) => buildCapturedFileChange(toCapturedChangeInput(changeSetId, c))),
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
- * Map a secret-blocked gitignored path to a content-less producer input (design
661
- * doc 12, DD-E). The bytes are deliberately never captured, so both sides are
662
- * absent (empty enforcement digests) and `diffComplete=false`. Being non-binary
663
- * incomplete with no keepable bytes, it forces the change set to PARTIAL_BLOCKED
664
- * (never BINARY_SUMMARY_ONLY) — approval is blocked and the path is surfaced
665
- * honestly, while its CONTENT never enters the ledger or storage. `blockedReason` records
666
- * the honest cause (SECRET_WITHHELD) so the review UI can say *why* rather than
667
- * showing a cause-agnostic "unavailable" (doc 15). Kind is MODIFY: the write was
668
- * blocked before it ran, so create-vs-modify is unknown and irrelevant (nothing
669
- * is ever applied or reconciled for this entry). `captureClass` is the turn's CAS
670
- * substrate class (GIT_IGNORED_CAPTURED | NON_GIT_CAS) so the blocked path is
671
- * labeled with its true provenance.
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 unreviewableChangeInput(
674
- changeSetId: string,
675
- path: string,
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: `${changeSetId}:${path}`,
680
- pathBefore: path,
681
- pathAfter: path,
682
- kind: FileChangeKind.MODIFY,
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
- // A touch that did not change the bytes is not a reviewable change.
199
- if (beforeBuf && afterBuf && beforeBuf.equals(afterBuf)) return undefined;
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: isCreate ? "" : path,
216
- pathAfter: isDelete ? "" : path,
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: !(beforeRef?.isBinary || afterRef?.isBinary),
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