@stigmer/runner 3.1.1 → 3.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.d.ts +49 -8
  3. package/dist/activities/execute-cursor/attachment-resolver.js +83 -26
  4. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  5. package/dist/activities/execute-cursor/capture-flow.d.ts +18 -0
  6. package/dist/activities/execute-cursor/capture-flow.js +21 -0
  7. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  8. package/dist/activities/execute-cursor/cas-observations.d.ts +13 -0
  9. package/dist/activities/execute-cursor/cas-observations.js +39 -5
  10. package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
  11. package/dist/activities/execute-cursor/hook-script.js +19 -1
  12. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  13. package/dist/activities/execute-cursor/index.d.ts +5 -0
  14. package/dist/activities/execute-cursor/index.js +222 -160
  15. package/dist/activities/execute-cursor/index.js.map +1 -1
  16. package/dist/activities/execute-cursor/message-translator.js +10 -1
  17. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  18. package/dist/activities/execute-cursor/prompt-builder.d.ts +32 -7
  19. package/dist/activities/execute-cursor/prompt-builder.js +38 -20
  20. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  21. package/dist/activities/execute-cursor/skill-resolver.d.ts +1 -10
  22. package/dist/activities/execute-cursor/skill-resolver.js +3 -55
  23. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  24. package/dist/activities/execute-cursor/stigmer-link.d.ts +35 -0
  25. package/dist/activities/execute-cursor/stigmer-link.js +73 -0
  26. package/dist/activities/execute-cursor/stigmer-link.js.map +1 -0
  27. package/dist/activities/execute-cursor/turn-boundary.d.ts +108 -0
  28. package/dist/activities/execute-cursor/turn-boundary.js +163 -0
  29. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -0
  30. package/dist/activities/execute-cursor/workspace-provision.d.ts +3 -2
  31. package/dist/activities/execute-cursor/workspace-provision.js +5 -3
  32. package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/index.js +89 -5
  34. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  35. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +17 -0
  36. package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
  37. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  38. package/dist/activities/execute-deep-agent/setup.js +22 -16
  39. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  40. package/dist/activities/execute-deep-agent/streaming-v3.js +6 -1
  41. package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
  42. package/dist/activities/execute-deep-agent/streaming.d.ts +7 -0
  43. package/dist/activities/execute-deep-agent/streaming.js +5 -1
  44. package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
  45. package/dist/config.d.ts +7 -0
  46. package/dist/config.js +9 -0
  47. package/dist/config.js.map +1 -1
  48. package/dist/middleware/approval-gate.js +36 -6
  49. package/dist/middleware/approval-gate.js.map +1 -1
  50. package/dist/runner-manager.d.ts +2 -0
  51. package/dist/runner-manager.js +2 -1
  52. package/dist/runner-manager.js.map +1 -1
  53. package/dist/runner.d.ts +2 -0
  54. package/dist/runner.js +2 -1
  55. package/dist/runner.js.map +1 -1
  56. package/dist/shared/artifact-storage.d.ts +32 -0
  57. package/dist/shared/artifact-storage.js +74 -1
  58. package/dist/shared/artifact-storage.js.map +1 -1
  59. package/dist/shared/filereview/capture.d.ts +18 -1
  60. package/dist/shared/filereview/capture.js +105 -28
  61. package/dist/shared/filereview/capture.js.map +1 -1
  62. package/dist/shared/filereview/cas-substrate.d.ts +9 -0
  63. package/dist/shared/filereview/cas-substrate.js +9 -1
  64. package/dist/shared/filereview/cas-substrate.js.map +1 -1
  65. package/dist/shared/filereview/events.d.ts +16 -0
  66. package/dist/shared/filereview/events.js +37 -0
  67. package/dist/shared/filereview/events.js.map +1 -1
  68. package/dist/shared/filereview/git-substrate.d.ts +42 -0
  69. package/dist/shared/filereview/git-substrate.js +78 -0
  70. package/dist/shared/filereview/git-substrate.js.map +1 -1
  71. package/dist/shared/filereview/index.d.ts +3 -1
  72. package/dist/shared/filereview/index.js +3 -1
  73. package/dist/shared/filereview/index.js.map +1 -1
  74. package/dist/shared/filereview/line-counts.d.ts +48 -0
  75. package/dist/shared/filereview/line-counts.js +72 -0
  76. package/dist/shared/filereview/line-counts.js.map +1 -0
  77. package/dist/shared/filereview/progress.d.ts +96 -0
  78. package/dist/shared/filereview/progress.js +134 -0
  79. package/dist/shared/filereview/progress.js.map +1 -0
  80. package/dist/shared/implement-plan-prompt.d.ts +44 -0
  81. package/dist/shared/implement-plan-prompt.js +98 -0
  82. package/dist/shared/implement-plan-prompt.js.map +1 -0
  83. package/dist/shared/plan-artifact.d.ts +59 -11
  84. package/dist/shared/plan-artifact.js +144 -16
  85. package/dist/shared/plan-artifact.js.map +1 -1
  86. package/dist/shared/plan-mode-prompt.d.ts +32 -0
  87. package/dist/shared/plan-mode-prompt.js +56 -0
  88. package/dist/shared/plan-mode-prompt.js.map +1 -0
  89. package/dist/shared/status.d.ts +11 -0
  90. package/dist/shared/status.js +12 -2
  91. package/dist/shared/status.js.map +1 -1
  92. package/dist/shared/tool-row.d.ts +30 -1
  93. package/dist/shared/tool-row.js +53 -4
  94. package/dist/shared/tool-row.js.map +1 -1
  95. package/dist/shared/workspace/session-root.d.ts +35 -0
  96. package/dist/shared/workspace/session-root.js +53 -0
  97. package/dist/shared/workspace/session-root.js.map +1 -0
  98. package/dist/shared/workspace/workspace-lock.d.ts +86 -0
  99. package/dist/shared/workspace/workspace-lock.js +187 -0
  100. package/dist/shared/workspace/workspace-lock.js.map +1 -0
  101. package/package.json +8 -5
  102. package/src/__tests__/config.test.ts +8 -0
  103. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  104. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  105. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +161 -0
  106. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +132 -2
  107. package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +55 -0
  108. package/src/activities/execute-cursor/__tests__/delta-enricher.test.ts +68 -0
  109. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +28 -0
  110. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +63 -4
  111. package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
  112. package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +36 -2
  113. package/src/activities/execute-cursor/attachment-resolver.ts +116 -34
  114. package/src/activities/execute-cursor/capture-flow.ts +32 -0
  115. package/src/activities/execute-cursor/cas-observations.ts +41 -5
  116. package/src/activities/execute-cursor/hook-script.ts +19 -1
  117. package/src/activities/execute-cursor/index.ts +243 -178
  118. package/src/activities/execute-cursor/message-translator.ts +9 -1
  119. package/src/activities/execute-cursor/prompt-builder.ts +60 -22
  120. package/src/activities/execute-cursor/skill-resolver.ts +3 -59
  121. package/src/activities/execute-cursor/stigmer-link.ts +78 -0
  122. package/src/activities/execute-cursor/turn-boundary.ts +274 -0
  123. package/src/activities/execute-cursor/workspace-provision.ts +5 -3
  124. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +7 -2
  125. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +7 -2
  126. package/src/activities/execute-deep-agent/__tests__/index.test.ts +4 -1
  127. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +99 -0
  128. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +7 -2
  129. package/src/activities/execute-deep-agent/index.ts +104 -5
  130. package/src/activities/execute-deep-agent/prompt-builder.ts +39 -0
  131. package/src/activities/execute-deep-agent/setup.ts +25 -17
  132. package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
  133. package/src/activities/execute-deep-agent/streaming.ts +12 -1
  134. package/src/config.ts +17 -0
  135. package/src/middleware/__tests__/approval-gate.test.ts +77 -12
  136. package/src/middleware/approval-gate.ts +39 -7
  137. package/src/runner-manager.ts +6 -1
  138. package/src/runner.ts +6 -1
  139. package/src/shared/__tests__/artifact-storage.test.ts +111 -1
  140. package/src/shared/__tests__/implement-plan-prompt.test.ts +86 -0
  141. package/src/shared/__tests__/plan-artifact.test.ts +142 -9
  142. package/src/shared/__tests__/secret-leak-scan.test.ts +105 -0
  143. package/src/shared/__tests__/status.test.ts +7 -2
  144. package/src/shared/__tests__/tool-row.test.ts +136 -1
  145. package/src/shared/artifact-storage.ts +83 -1
  146. package/src/shared/filereview/__tests__/capture.test.ts +182 -1
  147. package/src/shared/filereview/__tests__/cas-substrate.test.ts +34 -0
  148. package/src/shared/filereview/__tests__/events.test.ts +105 -0
  149. package/src/shared/filereview/__tests__/line-counts.test.ts +73 -0
  150. package/src/shared/filereview/__tests__/progress.test.ts +236 -0
  151. package/src/shared/filereview/capture.ts +134 -30
  152. package/src/shared/filereview/cas-substrate.ts +17 -1
  153. package/src/shared/filereview/events.ts +50 -0
  154. package/src/shared/filereview/git-substrate.ts +108 -0
  155. package/src/shared/filereview/index.ts +22 -1
  156. package/src/shared/filereview/line-counts.ts +83 -0
  157. package/src/shared/filereview/progress.ts +186 -0
  158. package/src/shared/implement-plan-prompt.ts +104 -0
  159. package/src/shared/plan-artifact.ts +152 -16
  160. package/src/shared/plan-mode-prompt.ts +56 -0
  161. package/src/shared/status.ts +11 -1
  162. package/src/shared/tool-row.ts +58 -5
  163. package/src/shared/workspace/__tests__/session-root.test.ts +42 -0
  164. package/src/shared/workspace/__tests__/workspace-lock.test.ts +200 -0
  165. package/src/shared/workspace/session-root.ts +60 -0
  166. package/src/shared/workspace/workspace-lock.ts +241 -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;
@@ -57,6 +57,7 @@ import {
57
57
  import type { ArtifactStorage } from "../artifact-storage.js";
58
58
  import { bytesLookBinary } from "../file-change.js";
59
59
  import { sha256Bytes, sha256Hex } from "./digest.js";
60
+ import { countLineChanges, type LineChangeCounts } from "./line-counts.js";
60
61
 
61
62
  /**
62
63
  * Reads an artifact's raw bytes back by storage key. Injected so this module
@@ -91,6 +92,14 @@ export interface CasCapturedFile {
91
92
  readonly after?: CasBlobRef;
92
93
  /** False when a side is binary — the change set cannot be approved as complete. */
93
94
  readonly diffComplete: boolean;
95
+ /**
96
+ * Display `+N −M` counted from the in-memory bytes at capture — the only
97
+ * moment a CAS change's text exists outside blob storage. Absent for binary
98
+ * or oversized sides. Carried in memory to the CANDIDATE event only, never
99
+ * persisted in the manifest: the manifest is the reconcile's source of truth,
100
+ * and display decoration does not belong in an enforcement record.
101
+ */
102
+ readonly lineCounts?: LineChangeCounts;
94
103
  }
95
104
 
96
105
  /** The durable manifest for one change set — the reconcile's source of truth. */
@@ -210,6 +219,7 @@ async function buildCasCapturedFile(
210
219
 
211
220
  const isCreate = kind === FileChangeKind.ADD;
212
221
  const isDelete = kind === FileChangeKind.DELETE;
222
+ const isBinary = beforeRef?.isBinary || afterRef?.isBinary;
213
223
 
214
224
  return {
215
225
  pathBefore: isCreate ? "" : path,
@@ -220,7 +230,13 @@ async function buildCasCapturedFile(
220
230
  after: afterRef,
221
231
  // Binary on either side means the diff cannot render as text; the change set
222
232
  // then cannot be approved as complete (parity with the git substrate).
223
- diffComplete: !(beforeRef?.isBinary || afterRef?.isBinary),
233
+ diffComplete: !isBinary,
234
+ // Display counts are taken NOW, while the text bytes are still in memory —
235
+ // after this they exist only as offloaded blobs. A binary change has no
236
+ // line diff to count.
237
+ lineCounts: isBinary
238
+ ? undefined
239
+ : countLineChanges(beforeBuf?.toString("utf8"), afterBuf?.toString("utf8")),
224
240
  };
225
241
  }
226
242
 
@@ -42,6 +42,7 @@ import {
42
42
  FileReviewFailureKind,
43
43
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
44
44
  import { aggregateDigest, fileDigest, sha256Bytes } from "./digest.js";
45
+ import { countLineChanges, type LineChangeCounts } from "./line-counts.js";
45
46
  import { looksBinary } from "../file-change.js";
46
47
 
47
48
  // The runner is the author of capture/reconcile/failure events. "runner" (not
@@ -150,6 +151,15 @@ export interface CapturedChangeInput {
150
151
  * this UNSPECIFIED. See {@link FileReviewBlockReason}.
151
152
  */
152
153
  readonly blockedReason?: FileReviewBlockReason;
154
+ /**
155
+ * Display line counts for this change, for producers whose bodies are not
156
+ * inline here (the CAS substrate counts from the in-memory bytes before
157
+ * offloading them as blob refs). Omit for inline content —
158
+ * {@link buildCapturedFileChange} then counts the inline sides itself, with
159
+ * the same shared {@link countLineChanges}. Informational only, never folded
160
+ * into the digests.
161
+ */
162
+ readonly lineCounts?: LineChangeCounts;
153
163
  }
154
164
 
155
165
  /** Normalize the string-shorthand to a {@link CapturedContent}. */
@@ -175,6 +185,37 @@ export function contentSha256(content: CapturedContent): string {
175
185
  }
176
186
  }
177
187
 
188
+ /**
189
+ * Count the display `+N −M` from a change's inline sides, or `undefined` when
190
+ * counting is not honest here: a side that exists without inline text (a `ref`
191
+ * whose bytes were already offloaded, or a `binary` side with no body), or
192
+ * inline text that would render as binary (the UI shows "Binary file changed"
193
+ * for it, never a line diff — a count would describe a diff nobody sees).
194
+ * An absent side is fine — it is the empty document of an ADD/DELETE.
195
+ */
196
+ function countInlineSides(
197
+ before: CapturedContent | undefined,
198
+ after: CapturedContent | undefined,
199
+ ): LineChangeCounts | undefined {
200
+ const beforeText = inlineTextOrNull(before);
201
+ const afterText = inlineTextOrNull(after);
202
+ if (beforeText === null || afterText === null) return undefined;
203
+ return countLineChanges(beforeText, afterText);
204
+ }
205
+
206
+ /**
207
+ * A side's countable inline text: the text for a non-binary inline side,
208
+ * `undefined` for an absent side (countable as the empty document), and `null`
209
+ * when the side exists but cannot be counted (ref / binary / binary-looking).
210
+ */
211
+ function inlineTextOrNull(
212
+ content: CapturedContent | undefined,
213
+ ): string | undefined | null {
214
+ if (content === undefined) return undefined;
215
+ if (content.kind !== "inline" || looksBinary(content.text)) return null;
216
+ return content.text;
217
+ }
218
+
178
219
  /** Build the proto {@link FileContent} for one content side (inline, ref, or binary). */
179
220
  function toFileContent(content: CapturedContent): FileContent {
180
221
  if (content.kind === "inline") {
@@ -212,12 +253,19 @@ function toFileContent(content: CapturedContent): FileContent {
212
253
  * `file_digest`) over the captured bytes. Content may be inline (git) or a blob
213
254
  * ref (CAS) — the digests are identical either way, so the aggregate digest and
214
255
  * the reconcile enforcement compose across both substrates.
256
+ *
257
+ * Display line counts are stamped here too — the single seam every substrate
258
+ * flows through — from `input.lineCounts` when the producer counted at its own
259
+ * source (CAS), else counted from the inline sides. Counting happens BEFORE any
260
+ * persist-time offload, so the counts survive a body being elided later. They
261
+ * never enter `fileDigest` (informational, not enforcement).
215
262
  */
216
263
  export function buildCapturedFileChange(input: CapturedChangeInput): CapturedFileChange {
217
264
  const before = normalizeContent(input.before);
218
265
  const after = normalizeContent(input.after);
219
266
  const beforeSha256 = before ? contentSha256(before) : "";
220
267
  const afterSha256 = after ? contentSha256(after) : "";
268
+ const counts = input.lineCounts ?? countInlineSides(before, after);
221
269
 
222
270
  const fc = create(CapturedFileChangeSchema, {
223
271
  id: input.id,
@@ -229,6 +277,8 @@ export function buildCapturedFileChange(input: CapturedChangeInput): CapturedFil
229
277
  afterSha256,
230
278
  diffComplete: input.diffComplete ?? true,
231
279
  blockedReason: input.blockedReason ?? FileReviewBlockReason.UNSPECIFIED,
280
+ linesAdded: counts?.linesAdded ?? 0,
281
+ linesRemoved: counts?.linesRemoved ?? 0,
232
282
  fileDigest: fileDigest({
233
283
  pathBefore: input.pathBefore,
234
284
  pathAfter: input.pathAfter,
@@ -308,6 +308,81 @@ export async function captureChangeSet(
308
308
  return { baselineTree, afterTree, changes };
309
309
  }
310
310
 
311
+ /**
312
+ * One file's slim, content-free progress delta: paths + kind + line counts, NO
313
+ * bytes. The mid-run analogue of {@link GitSubstrateChange} (which carries the
314
+ * before/after {@link CapturedContent}); this deliberately carries neither side,
315
+ * so it can never leak content. `linesAdded`/`linesRemoved` are 0 when
316
+ * uncountable (a binary file — numstat reports `-`).
317
+ */
318
+ export interface GitProgressEntry {
319
+ /** Path before the change (repo-relative). Empty for a CREATE. */
320
+ readonly pathBefore: string;
321
+ /** Path after the change (repo-relative). Empty for a DELETE. */
322
+ readonly pathAfter: string;
323
+ readonly changeType: FileChangeType;
324
+ readonly linesAdded: number;
325
+ readonly linesRemoved: number;
326
+ }
327
+
328
+ /** Result of {@link captureProgressDelta}. */
329
+ export interface ProgressDelta {
330
+ /** The post-diff working-tree sha, for the caller's short-circuit cache. */
331
+ readonly afterTree: string;
332
+ /** One count-only entry per changed file (no content). */
333
+ readonly entries: readonly GitProgressEntry[];
334
+ }
335
+
336
+ /**
337
+ * Capture a NON-AUTHORITATIVE, content-free snapshot of the working-tree delta
338
+ * against `baselineTree` — the substrate for the live "N files changed so far"
339
+ * surface (mid-run live capture). Unlike {@link captureChangeSet} this reads NO
340
+ * file bytes: it stages the working tree into a temp index, writes its tree, and
341
+ * asks git for `--numstat` + `--name-status` only. Cheap enough to run on a
342
+ * debounce during the turn.
343
+ *
344
+ * Short-circuit: returns `undefined` when the working tree is unchanged since
345
+ * `lastTreeSha` (the caller passes back the previous result's `afterTree`), so a
346
+ * quiet turn pays only one `write-tree`, never a diff.
347
+ *
348
+ * Uses a dedicated temp-index label ("progress") so it never collides with the
349
+ * `baseline`/`capture`/`approved` indexes. `--no-renames` matches
350
+ * {@link captureChangeSet} (a rename surfaces as delete + create). A torn read of
351
+ * a file being written mid-turn is acceptable — the snapshot is non-authoritative
352
+ * and self-corrects on the next capture.
353
+ */
354
+ export async function captureProgressDelta(
355
+ gitRoot: string,
356
+ executionId: string,
357
+ baselineTree: string,
358
+ excludePaths: readonly string[] = [],
359
+ lastTreeSha?: string,
360
+ ): Promise<ProgressDelta | undefined> {
361
+ const gitDir = await resolveGitDir(gitRoot);
362
+ const afterTree = await writeWorkingTree(gitRoot, gitDir, "progress", executionId, excludePaths);
363
+ if (lastTreeSha !== undefined && afterTree === lastTreeSha) return undefined;
364
+
365
+ const [nameStatusRaw, numstatRaw] = await Promise.all([
366
+ git(gitRoot, ["diff", "--no-renames", "--name-status", "-z", baselineTree, afterTree]),
367
+ git(gitRoot, ["diff", "--no-renames", "--numstat", "-z", baselineTree, afterTree]),
368
+ ]);
369
+
370
+ const counts = parseNumstatZ(numstatRaw);
371
+ const entries: GitProgressEntry[] = [];
372
+ for (const { status, path } of parseNameStatusZ(nameStatusRaw)) {
373
+ const changeType = nameStatusToChangeType(status);
374
+ const count = counts.get(path);
375
+ entries.push({
376
+ pathBefore: changeType === FileChangeType.CREATE ? "" : path,
377
+ pathAfter: changeType === FileChangeType.DELETE ? "" : path,
378
+ changeType,
379
+ linesAdded: count?.added ?? 0,
380
+ linesRemoved: count?.removed ?? 0,
381
+ });
382
+ }
383
+ return { afterTree, entries };
384
+ }
385
+
311
386
  /**
312
387
  * Restore the given files to their `baselineTree` (pre-turn) bytes. On resume
313
388
  * this reverts the REJECTED/undecided subset so a discarded change snaps back
@@ -462,6 +537,39 @@ function parseNameStatusZ(raw: string): NameStatusEntry[] {
462
537
  return entries;
463
538
  }
464
539
 
540
+ /**
541
+ * Parse `git diff --numstat -z` into a path -> {added, removed} map. Each record
542
+ * is a NUL-terminated `<added>\t<removed>\t<path>` triple; a binary file reports
543
+ * `-` for both counts, which we surface as 0 (no count) per the display contract.
544
+ */
545
+ function parseNumstatZ(raw: string): Map<string, { added: number; removed: number }> {
546
+ const counts = new Map<string, { added: number; removed: number }>();
547
+ for (const record of raw.split("\u0000")) {
548
+ if (!record) continue;
549
+ const tab = record.indexOf("\t");
550
+ const tab2 = record.indexOf("\t", tab + 1);
551
+ if (tab < 0 || tab2 < 0) continue;
552
+ const addedStr = record.slice(0, tab);
553
+ const removedStr = record.slice(tab + 1, tab2);
554
+ const path = record.slice(tab2 + 1);
555
+ if (!path) continue;
556
+ const added = addedStr === "-" ? 0 : Number.parseInt(addedStr, 10) || 0;
557
+ const removed = removedStr === "-" ? 0 : Number.parseInt(removedStr, 10) || 0;
558
+ counts.set(path, { added, removed });
559
+ }
560
+ return counts;
561
+ }
562
+
563
+ /**
564
+ * Map a `--name-status` status letter to a {@link FileChangeType} — the same
565
+ * A -> CREATE, D -> DELETE, else MODIFY rule {@link buildCapturedChange} uses.
566
+ */
567
+ function nameStatusToChangeType(status: string): FileChangeType {
568
+ if (status === "A") return FileChangeType.CREATE;
569
+ if (status === "D") return FileChangeType.DELETE;
570
+ return FileChangeType.MODIFY;
571
+ }
572
+
465
573
  /** Read a blob's exact BYTES from a tree, or `undefined` when absent. */
466
574
  async function readBlobBytes(
467
575
  gitRoot: string,
@@ -33,7 +33,28 @@ export {
33
33
  type CaptureResumeResult,
34
34
  } from "./capture.js";
35
35
 
36
- export { isGitWorkTree } from "./git-substrate.js";
36
+ export {
37
+ captureProgressDelta,
38
+ isGitWorkTree,
39
+ type GitProgressEntry,
40
+ type ProgressDelta,
41
+ } from "./git-substrate.js";
42
+
43
+ export {
44
+ buildFileChangeProgress,
45
+ captureFileChangeProgress,
46
+ newProgressCaptureState,
47
+ PROGRESS_CAPTURE_MIN_INTERVAL_MS,
48
+ PROGRESS_MAX_ENTRIES,
49
+ shouldCaptureProgress,
50
+ type ProgressCaptureState,
51
+ } from "./progress.js";
52
+
53
+ export {
54
+ countLineChanges,
55
+ LINE_COUNT_MAX_BYTES,
56
+ type LineChangeCounts,
57
+ } from "./line-counts.js";
37
58
 
38
59
  export {
39
60
  applyCasApproved,
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Capture-time line counting for the file-review subsystem: the `+N −M` stamped
3
+ * onto each {@link CapturedFileChange} so review surfaces can decorate file
4
+ * lists without the file bodies.
5
+ *
6
+ * WHY THE RUNNER COUNTS, AND WHY WITH JSDIFF. The counts exist because the
7
+ * review UI's compact file lists must show per-file magnitude, but by the time
8
+ * the UI renders, large before/after bodies may have been offloaded to artifact
9
+ * storage (status-offload) — a client cannot count what it would first have to
10
+ * download. So the counts are computed once, at capture, where the bytes are
11
+ * always in hand. They are computed with the SAME `diff` (jsdiff) Myers
12
+ * algorithm the SDK's diff renderer uses (`computeDiff` → `structuredPatch`),
13
+ * NOT with `git diff --numstat`: two counting authorities can disagree on edge
14
+ * cases (trailing newlines being the classic), and a list that says `+37 −1`
15
+ * beside a rendered diff showing `+37 −0` is a self-contradicting UI. One
16
+ * algorithm, consistent by construction.
17
+ *
18
+ * The counts are INFORMATIONAL display data only — never an enforcement input,
19
+ * never folded into `file_digest`/`aggregate_digest` (the same contract as
20
+ * `blocked_reason`).
21
+ */
22
+
23
+ import { structuredPatch } from "diff";
24
+
25
+ /**
26
+ * Per-side byte ceiling above which counting is skipped. Myers is O(ND) and a
27
+ * pathological many-MB text pair must never stall the capture activity just to
28
+ * decorate a list; above this the counts stay absent and the UI hides the stat.
29
+ * Generous relative to the 128 KiB per-side inline-persist cap — a file can be
30
+ * several times that and still count quickly — while bounding the worst case.
31
+ */
32
+ export const LINE_COUNT_MAX_BYTES = 1024 * 1024;
33
+
34
+ /** The `+N −M` pair for one file change. */
35
+ export interface LineChangeCounts {
36
+ readonly linesAdded: number;
37
+ readonly linesRemoved: number;
38
+ }
39
+
40
+ /**
41
+ * Count the added/removed lines between two text sides, exactly as the SDK's
42
+ * diff renderer would show them.
43
+ *
44
+ * An absent side is the empty document — an ADD counts every `after` line as
45
+ * added, a DELETE every `before` line as removed — mirroring how the renderer
46
+ * diffs a create/delete. `` markers are metadata,
47
+ * not content, and are excluded (the same rule as the SDK's `mapPatchHunks`).
48
+ *
49
+ * Returns `undefined` when counting is not possible or meaningful: both sides
50
+ * absent, or either side over {@link LINE_COUNT_MAX_BYTES}. Callers leave the
51
+ * proto counts at zero in that case, which consumers render as "no stat".
52
+ */
53
+ export function countLineChanges(
54
+ before: string | undefined,
55
+ after: string | undefined,
56
+ ): LineChangeCounts | undefined {
57
+ if (before === undefined && after === undefined) return undefined;
58
+ const beforeText = before ?? "";
59
+ const afterText = after ?? "";
60
+ if (
61
+ Buffer.byteLength(beforeText, "utf8") > LINE_COUNT_MAX_BYTES ||
62
+ Buffer.byteLength(afterText, "utf8") > LINE_COUNT_MAX_BYTES
63
+ ) {
64
+ return undefined;
65
+ }
66
+
67
+ // Zero context: the +/- line population is independent of context width, and
68
+ // omitting context lines keeps the walk (and the patch object) minimal.
69
+ const patch = structuredPatch("a", "b", beforeText, afterText, "", "", {
70
+ context: 0,
71
+ });
72
+
73
+ let linesAdded = 0;
74
+ let linesRemoved = 0;
75
+ for (const hunk of patch.hunks) {
76
+ for (const line of hunk.lines) {
77
+ if (line.startsWith("\\")) continue;
78
+ if (line[0] === "+") linesAdded++;
79
+ else if (line[0] === "-") linesRemoved++;
80
+ }
81
+ }
82
+ return { linesAdded, linesRemoved };
83
+ }