@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
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { promisify } from "node:util";
16
16
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
17
- import { clone, create } from "@bufbuild/protobuf";
17
+ import { clone, create, toBinary } from "@bufbuild/protobuf";
18
18
  import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
19
19
  import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
20
20
  import {
@@ -28,6 +28,7 @@ import {
28
28
  FileCaptureClass,
29
29
  FileChangeKind,
30
30
  FileChangeSetStatus,
31
+ FileChangeType,
31
32
  FileDecisionAction,
32
33
  FileDecisionScope,
33
34
  FileReviewBlockReason,
@@ -41,7 +42,9 @@ import {
41
42
  captureBaselineToLedger,
42
43
  captureCandidateToLedger,
43
44
  deriveCaptureMode,
45
+ partitionGitChangesBySecret,
44
46
  } from "../capture.js";
47
+ import type { GitSubstrateChange } from "../git-substrate.js";
45
48
  import { casBlobReader } from "../cas-substrate.js";
46
49
 
47
50
  const execFileAsync = promisify(execFile);
@@ -609,6 +612,184 @@ describe("capture orchestration — secret-blocked DIFF_UNREVIEWABLE (DD-E)", ()
609
612
  });
610
613
  });
611
614
 
615
+ // A git-TRACKED secret-like file (a committed credentials.json, .env.example,
616
+ // ...) FLOWS in capture mode (the gate allows tracked mutations with no secret
617
+ // check), so it reaches the turn boundary in the git diff WITH real bytes. Those
618
+ // bytes must never enter the ledger: the capture seam authors it content-less
619
+ // (SECRET_WITHHELD / GIT_TRACKED), discard-only, and the git substrate reverts
620
+ // it byte-exact on reject. This is the one place that can withhold a DELETE's
621
+ // baseline (before) secret too — the gate never sees a delete's content.
622
+ // (DD-26 follow-up #3.)
623
+ describe("capture orchestration — git-tracked secret withheld (DD-26 follow-up #3)", () => {
624
+ const SECRET_PATH = "config/credentials.json";
625
+ const BASELINE_SECRET = "OLD_API_KEY=sk-live-OLDSECRET-do-not-persist-111\n";
626
+ const NEW_SECRET = "NEW_API_KEY=sk-live-NEWSECRET-do-not-persist-222\n";
627
+
628
+ async function commitBaselineSecret(): Promise<void> {
629
+ await write(SECRET_PATH, BASELINE_SECRET);
630
+ await git(["add", "-A"]);
631
+ await git(["commit", "-q", "-m", "add tracked secret"]);
632
+ }
633
+
634
+ // Serialize the WHOLE persisted status (ledger + any rows) and assert neither
635
+ // the baseline nor the new secret bytes survive anywhere — the leak-scan that
636
+ // would FAIL before this fix (the git diff carried the inline body).
637
+ function assertNoSecretBytes(status: AgentExecutionStatus): void {
638
+ const wire = Buffer.from(toBinary(AgentExecutionStatusSchema, status));
639
+ expect(wire.includes(Buffer.from(BASELINE_SECRET))).toBe(false);
640
+ expect(wire.includes(Buffer.from(NEW_SECRET))).toBe(false);
641
+ }
642
+
643
+ it("MODIFY: content-less SECRET_WITHHELD/GIT_TRACKED entry; PARTIAL_BLOCKED; nothing persisted", async () => {
644
+ await commitBaselineSecret();
645
+ const status = newStatus();
646
+ const baseline = await captureBaselineToLedger({
647
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
648
+ });
649
+ await write(SECRET_PATH, NEW_SECRET); // the agent edits the committed secret
650
+
651
+ await captureCandidateToLedger({
652
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
653
+ baselineTree: baseline, harnessId: HARNESS,
654
+ });
655
+
656
+ const entry = candidateChanges(status).find((c) => (c.pathAfter || c.pathBefore) === SECRET_PATH)!;
657
+ expect(entry).toBeDefined();
658
+ expect(entry.captureClass).toBe(FileCaptureClass.GIT_TRACKED);
659
+ expect(entry.kind).toBe(FileChangeKind.MODIFY);
660
+ expect(entry.diffComplete).toBe(false);
661
+ expect(entry.blockedReason).toBe(FileReviewBlockReason.SECRET_WITHHELD);
662
+ // No content and no enforcement digests on EITHER side (before is secret too).
663
+ expect(entry.before).toBeUndefined();
664
+ expect(entry.after).toBeUndefined();
665
+ expect(entry.beforeSha256).toBe("");
666
+ expect(entry.afterSha256).toBe("");
667
+ expect(candidateCompleteness(status)).toBe(DiffCompleteness.PARTIAL_BLOCKED);
668
+ assertNoSecretBytes(status);
669
+ });
670
+
671
+ it("CREATE: a newly-created tracked secret is content-less (kind ADD, no after bytes)", async () => {
672
+ const status = newStatus();
673
+ const baseline = await captureBaselineToLedger({
674
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
675
+ });
676
+ await write(SECRET_PATH, NEW_SECRET); // brand-new secret file this turn
677
+
678
+ await captureCandidateToLedger({
679
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
680
+ baselineTree: baseline, harnessId: HARNESS,
681
+ });
682
+
683
+ const entry = candidateChanges(status).find((c) => (c.pathAfter || c.pathBefore) === SECRET_PATH)!;
684
+ expect(entry.kind).toBe(FileChangeKind.ADD);
685
+ expect(entry.captureClass).toBe(FileCaptureClass.GIT_TRACKED);
686
+ expect(entry.diffComplete).toBe(false);
687
+ expect(entry.blockedReason).toBe(FileReviewBlockReason.SECRET_WITHHELD);
688
+ expect(entry.after).toBeUndefined();
689
+ expect(entry.afterSha256).toBe("");
690
+ expect(candidateCompleteness(status)).toBe(DiffCompleteness.PARTIAL_BLOCKED);
691
+ assertNoSecretBytes(status);
692
+ });
693
+
694
+ it("DELETE: withholds the deleted file's baseline (before) secret bytes (kind DELETE)", async () => {
695
+ await commitBaselineSecret();
696
+ const status = newStatus();
697
+ const baseline = await captureBaselineToLedger({
698
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
699
+ });
700
+ await rm(join(repo, SECRET_PATH), { force: true }); // the agent deletes the secret
701
+
702
+ await captureCandidateToLedger({
703
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
704
+ baselineTree: baseline, harnessId: HARNESS,
705
+ });
706
+
707
+ const entry = candidateChanges(status).find((c) => c.pathBefore === SECRET_PATH)!;
708
+ expect(entry).toBeDefined();
709
+ expect(entry.kind).toBe(FileChangeKind.DELETE);
710
+ expect(entry.captureClass).toBe(FileCaptureClass.GIT_TRACKED);
711
+ expect(entry.diffComplete).toBe(false);
712
+ // The DELETE's `before` side is the committed secret — the leak only this
713
+ // seam can withhold (the gate never sees a delete's content).
714
+ expect(entry.before).toBeUndefined();
715
+ expect(entry.beforeSha256).toBe("");
716
+ expect(candidateCompleteness(status)).toBe(DiffCompleteness.PARTIAL_BLOCKED);
717
+ assertNoSecretBytes(status);
718
+ });
719
+
720
+ it("mixed set: the non-secret file stays keepable; the secret reverts byte-exact on reject", async () => {
721
+ await commitBaselineSecret();
722
+ const status = newStatus();
723
+ const baseline = await captureBaselineToLedger({
724
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID, harnessId: HARNESS,
725
+ });
726
+ await write("notes.md", "planton notes\n"); // reviewable tracked change
727
+ await write(SECRET_PATH, NEW_SECRET); // withheld tracked secret change
728
+
729
+ await captureCandidateToLedger({
730
+ status, gitRoot: repo, executionId: EXEC_ID, changeSetId: CHANGE_SET_ID,
731
+ baselineTree: baseline, harnessId: HARNESS,
732
+ });
733
+
734
+ const byPath = new Map(candidateChanges(status).map((c) => [c.pathAfter || c.pathBefore, c]));
735
+ // The non-secret file is fully reviewable/keepable (content present).
736
+ expect(byPath.get("notes.md")!.diffComplete).toBe(true);
737
+ expect(byPath.get("notes.md")!.after).toBeDefined();
738
+ expect(byPath.get("notes.md")!.blockedReason).toBe(FileReviewBlockReason.UNSPECIFIED);
739
+ // The secret rides alongside content-less, forcing the set PARTIAL_BLOCKED.
740
+ expect(byPath.get(SECRET_PATH)!.diffComplete).toBe(false);
741
+ expect(byPath.get(SECRET_PATH)!.blockedReason).toBe(FileReviewBlockReason.SECRET_WITHHELD);
742
+ expect(candidateCompleteness(status)).toBe(DiffCompleteness.PARTIAL_BLOCKED);
743
+ assertNoSecretBytes(status);
744
+
745
+ // Keep the reviewable file; discard the secret. The git substrate reverts the
746
+ // secret to its committed baseline BYTE-EXACT (not deleted) and keeps notes.md.
747
+ const changeSet = decidedChangeSet(status, {
748
+ "notes.md": FileDecisionAction.APPROVE,
749
+ [SECRET_PATH]: FileDecisionAction.REJECT,
750
+ });
751
+ const result = await applyCaptureDecisions({
752
+ status, gitRoot: repo, executionId: EXEC_ID, changeSet, harnessId: HARNESS,
753
+ });
754
+
755
+ expect(result.failed).toBe(false);
756
+ expect(result.approvedPaths).toEqual(["notes.md"]);
757
+ expect(result.rejectedPaths).toEqual([SECRET_PATH]);
758
+ expect(await read("notes.md")).toBe("planton notes\n");
759
+ expect(await read(SECRET_PATH)).toBe(BASELINE_SECRET); // reverted byte-exact
760
+ // Even after reconcile, no secret bytes are anywhere in the persisted status.
761
+ assertNoSecretBytes(status);
762
+ });
763
+ });
764
+
765
+ // partitionGitChangesBySecret: the pure split the composition relies on.
766
+ describe("partitionGitChangesBySecret (pure)", () => {
767
+ const mk = (path: string): GitSubstrateChange => ({
768
+ path,
769
+ changeType: FileChangeType.MODIFY,
770
+ before: { kind: "inline", text: "a" },
771
+ after: { kind: "inline", text: "b" },
772
+ });
773
+
774
+ it("routes secret-like paths to `secret` and everything else to `safe`", () => {
775
+ const { safe, secret } = partitionGitChangesBySecret([
776
+ mk("src/main.ts"),
777
+ mk("config/credentials.json"),
778
+ mk(".env"),
779
+ mk("README.md"),
780
+ mk("keys/id_rsa"),
781
+ ]);
782
+ expect(safe.map((c) => c.path)).toEqual(["src/main.ts", "README.md"]);
783
+ expect(secret.map((c) => c.path)).toEqual(["config/credentials.json", ".env", "keys/id_rsa"]);
784
+ });
785
+
786
+ it("returns everything safe when no path is secret-like", () => {
787
+ const { safe, secret } = partitionGitChangesBySecret([mk("a.ts"), mk("b/c.md")]);
788
+ expect(safe).toHaveLength(2);
789
+ expect(secret).toHaveLength(0);
790
+ });
791
+ });
792
+
612
793
  // Slice 2a (DD-21 D2): the shared orchestration must capture and reconcile a
613
794
  // NON-GIT workspace entirely from the CAS manifest — no git refs, no whole-tree
614
795
  // snapshot, bounded to the paths the observer actually touched. These exercise
@@ -97,6 +97,40 @@ describe("snapshotCasChangeSet — classification", () => {
97
97
  expect(del.before?.sha256).toBe(sha256Bytes(bytes("BYE=1")));
98
98
  });
99
99
 
100
+ it("counts display line changes from the in-memory bytes at capture", async () => {
101
+ const { storage } = makeFakeStorage();
102
+ const { manifest } = await snapshotCasChangeSet({
103
+ storage,
104
+ executionId: EXEC,
105
+ changeSetId: CHANGE_SET,
106
+ captures: [
107
+ { path: "created.env", before: null, after: bytes("A=1\nB=2\n"), captureClass: IGNORED },
108
+ { path: "changed.env", before: bytes("OLD=1\n"), after: bytes("OLD=2\n"), captureClass: IGNORED },
109
+ // Binary side: no text diff exists, so no counts may claim one does.
110
+ { path: "blob.bin", before: null, after: new Uint8Array([0, 1, 2]), captureClass: IGNORED },
111
+ ],
112
+ });
113
+ const byPath = new Map(manifest.files.map((f) => [f.pathAfter || f.pathBefore, f]));
114
+
115
+ expect(byPath.get("created.env")!.lineCounts).toEqual({ linesAdded: 2, linesRemoved: 0 });
116
+ expect(byPath.get("changed.env")!.lineCounts).toEqual({ linesAdded: 1, linesRemoved: 1 });
117
+ expect(byPath.get("blob.bin")!.lineCounts).toBeUndefined();
118
+ });
119
+
120
+ it("keeps line counts out of the persisted manifest (display data, not an enforcement record)", async () => {
121
+ const { storage, readBlob } = makeFakeStorage();
122
+ const { ref } = await snapshotCasChangeSet({
123
+ storage,
124
+ executionId: EXEC,
125
+ changeSetId: CHANGE_SET,
126
+ captures: [
127
+ { path: "changed.env", before: bytes("OLD=1\n"), after: bytes("OLD=2\n"), captureClass: IGNORED },
128
+ ],
129
+ });
130
+ const roundTripped = await loadCasManifest({ readBlob, ref });
131
+ expect(roundTripped.files[0].lineCounts).toBeUndefined();
132
+ });
133
+
100
134
  it("drops a touched-but-unchanged path (before == after)", async () => {
101
135
  const { storage } = makeFakeStorage();
102
136
  const { manifest } = await snapshotCasChangeSet({
@@ -138,6 +138,111 @@ describe("buildCapturedFileChange", () => {
138
138
  }
139
139
  });
140
140
 
141
+ it("stamps display line counts from inline sides", () => {
142
+ const modify = buildCapturedFileChange({
143
+ id: "fc-counts",
144
+ pathBefore: "src/a.ts",
145
+ pathAfter: "src/a.ts",
146
+ kind: FileChangeKind.MODIFY,
147
+ captureClass: FileCaptureClass.GIT_TRACKED,
148
+ before: "one\ntwo\n",
149
+ after: "one\nTWO\nthree\n",
150
+ });
151
+ expect(modify.linesAdded).toBe(2);
152
+ expect(modify.linesRemoved).toBe(1);
153
+
154
+ const add = buildCapturedFileChange({
155
+ id: "fc-add-counts",
156
+ pathBefore: "",
157
+ pathAfter: "src/new.ts",
158
+ kind: FileChangeKind.ADD,
159
+ captureClass: FileCaptureClass.GIT_TRACKED,
160
+ after: "a\nb\nc\n",
161
+ });
162
+ expect(add.linesAdded).toBe(3);
163
+ expect(add.linesRemoved).toBe(0);
164
+ });
165
+
166
+ it("prefers explicit lineCounts (the CAS substrate's capture-time counts) over inline counting", () => {
167
+ const change = buildCapturedFileChange({
168
+ id: "fc-explicit",
169
+ pathBefore: "notes.txt",
170
+ pathAfter: "notes.txt",
171
+ kind: FileChangeKind.MODIFY,
172
+ captureClass: FileCaptureClass.NON_GIT_CAS,
173
+ before: {
174
+ kind: "ref", sha256: "s1", storageKey: "k1", sizeBytes: 10, isBinary: false,
175
+ },
176
+ after: {
177
+ kind: "ref", sha256: "s2", storageKey: "k2", sizeBytes: 12, isBinary: false,
178
+ },
179
+ lineCounts: { linesAdded: 7, linesRemoved: 4 },
180
+ });
181
+ expect(change.linesAdded).toBe(7);
182
+ expect(change.linesRemoved).toBe(4);
183
+ });
184
+
185
+ it("leaves counts at zero when a side is not countable (binary / ref without explicit counts / withheld)", () => {
186
+ // Binary side: no text diff exists, so no count may claim one does.
187
+ const binary = binaryChange("bin");
188
+ expect(binary.linesAdded).toBe(0);
189
+ expect(binary.linesRemoved).toBe(0);
190
+
191
+ // Ref side without explicit counts: the bytes are not here to count.
192
+ const ref = buildCapturedFileChange({
193
+ id: "fc-ref",
194
+ pathBefore: "big.txt",
195
+ pathAfter: "big.txt",
196
+ kind: FileChangeKind.MODIFY,
197
+ captureClass: FileCaptureClass.NON_GIT_CAS,
198
+ before: { kind: "ref", sha256: "s1", storageKey: "k1", sizeBytes: 9, isBinary: false },
199
+ after: { kind: "ref", sha256: "s2", storageKey: "k2", sizeBytes: 9, isBinary: false },
200
+ });
201
+ expect(ref.linesAdded).toBe(0);
202
+ expect(ref.linesRemoved).toBe(0);
203
+
204
+ // Secret-withheld: content-less by design.
205
+ const withheld = unavailableChange("secret");
206
+ expect(withheld.linesAdded).toBe(0);
207
+ expect(withheld.linesRemoved).toBe(0);
208
+ });
209
+
210
+ it("keeps line counts out of the enforcement digests (informational only)", () => {
211
+ const common = {
212
+ id: "fc-digest",
213
+ pathBefore: "a",
214
+ pathAfter: "a",
215
+ kind: FileChangeKind.MODIFY,
216
+ captureClass: FileCaptureClass.NON_GIT_CAS,
217
+ before: {
218
+ kind: "ref", sha256: "s1", storageKey: "k1", sizeBytes: 3, isBinary: false,
219
+ },
220
+ after: {
221
+ kind: "ref", sha256: "s2", storageKey: "k2", sizeBytes: 3, isBinary: false,
222
+ },
223
+ } as const;
224
+ // Identical content with and without counts must be digest-indistinguishable —
225
+ // structurally guaranteed (FileDigestInput excludes counts), locked here so a
226
+ // future refactor cannot silently fold display data into enforcement.
227
+ const withoutCounts = buildCapturedFileChange(common);
228
+ const withCounts = buildCapturedFileChange({
229
+ ...common,
230
+ lineCounts: { linesAdded: 5, linesRemoved: 2 },
231
+ });
232
+ expect(withCounts.fileDigest).toBe(withoutCounts.fileDigest);
233
+
234
+ const aggWithout = buildCandidateCapturedEvent(ctx, undefined, [withoutCounts]);
235
+ const aggWith = buildCandidateCapturedEvent(ctx, undefined, [withCounts]);
236
+ if (
237
+ aggWithout.payload.case === "candidateCaptured" &&
238
+ aggWith.payload.case === "candidateCaptured"
239
+ ) {
240
+ expect(aggWith.payload.value.aggregateDigest).toBe(
241
+ aggWithout.payload.value.aggregateDigest,
242
+ );
243
+ }
244
+ });
245
+
141
246
  it("omits the before side for an ADD and the after side for a DELETE", () => {
142
247
  const add = buildCapturedFileChange({
143
248
  id: "fc-add",
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Capture-time line counting: the `+N −M` semantics must mirror what the SDK's
3
+ * jsdiff-based diff renderer shows (same algorithm, same "\ No newline"
4
+ * exclusion), with absent sides treated as the empty document and a byte-size
5
+ * guard that skips counting rather than stalling capture.
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import { countLineChanges, LINE_COUNT_MAX_BYTES } from "../line-counts.js";
10
+
11
+ describe("countLineChanges", () => {
12
+ it("counts a plain modify", () => {
13
+ expect(countLineChanges("a\nb\nc\n", "a\nB\nc\nd\n")).toEqual({
14
+ linesAdded: 2,
15
+ linesRemoved: 1,
16
+ });
17
+ });
18
+
19
+ it("counts an ADD (absent before) as all lines added", () => {
20
+ expect(countLineChanges(undefined, "one\ntwo\nthree\n")).toEqual({
21
+ linesAdded: 3,
22
+ linesRemoved: 0,
23
+ });
24
+ });
25
+
26
+ it("counts a DELETE (absent after) as all lines removed", () => {
27
+ expect(countLineChanges("one\ntwo\n", undefined)).toEqual({
28
+ linesAdded: 0,
29
+ linesRemoved: 2,
30
+ });
31
+ });
32
+
33
+ it("returns zero counts for identical sides", () => {
34
+ expect(countLineChanges("same\n", "same\n")).toEqual({
35
+ linesAdded: 0,
36
+ linesRemoved: 0,
37
+ });
38
+ });
39
+
40
+ it("returns undefined when both sides are absent", () => {
41
+ expect(countLineChanges(undefined, undefined)).toBeUndefined();
42
+ });
43
+
44
+ it("excludes the no-trailing-newline marker from the counts", () => {
45
+ // "a" -> "a\n": jsdiff models this as the "a" line replaced (one removed,
46
+ // one added) plus "\ No newline" metadata, which must not be counted —
47
+ // exactly the population the SDK's mapPatchHunks renders.
48
+ expect(countLineChanges("a", "a\n")).toEqual({
49
+ linesAdded: 1,
50
+ linesRemoved: 1,
51
+ });
52
+ });
53
+
54
+ it("counts an added file with no trailing newline by its real line count", () => {
55
+ expect(countLineChanges(undefined, "one\ntwo")).toEqual({
56
+ linesAdded: 2,
57
+ linesRemoved: 0,
58
+ });
59
+ });
60
+
61
+ it("skips counting when a side exceeds the byte guard", () => {
62
+ const huge = "x\n".repeat(LINE_COUNT_MAX_BYTES / 2 + 1);
63
+ expect(countLineChanges(huge, "small\n")).toBeUndefined();
64
+ expect(countLineChanges("small\n", huge)).toBeUndefined();
65
+ });
66
+
67
+ it("guards on UTF-8 byte length, not character count", () => {
68
+ // Each "€" is 3 bytes; a string just over the guard in bytes while well
69
+ // under it in characters must still be skipped.
70
+ const wide = "€".repeat(Math.ceil(LINE_COUNT_MAX_BYTES / 3) + 1);
71
+ expect(countLineChanges(wide, "")).toBeUndefined();
72
+ });
73
+ });
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Unit tests for mid-run live capture (DD-32):
3
+ * - `captureProgressDelta` (git-substrate) against a REAL temp git repo — the
4
+ * only faithful test for git plumbing (kinds, counts, binary, rename→add+del,
5
+ * tree-unchanged short-circuit, excludePaths).
6
+ * - `buildFileChangeProgress` (pure) — secret zeroing, entry cap with honest
7
+ * totals, aggregate sums.
8
+ * - `shouldCaptureProgress` (pure) — the debounce floor.
9
+ */
10
+
11
+ import { execFile } from "node:child_process";
12
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { promisify } from "node:util";
16
+ import { beforeEach, afterEach, describe, expect, it } from "vitest";
17
+ import { FileChangeKind, FileChangeType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
18
+ import {
19
+ captureProgressDelta,
20
+ snapshotBaseline,
21
+ dropCaptureRefs,
22
+ type GitProgressEntry,
23
+ type ProgressDelta,
24
+ } from "../git-substrate.js";
25
+ import {
26
+ buildFileChangeProgress,
27
+ PROGRESS_MAX_ENTRIES,
28
+ shouldCaptureProgress,
29
+ } from "../progress.js";
30
+
31
+ const execFileAsync = promisify(execFile);
32
+ const EXEC_ID = "exec-progress-1";
33
+ const CHANGE_SET_ID = "exec-progress-1:0";
34
+ const RUNNER_OWNED_PATHS: readonly string[] = [".cursor/hooks.json"];
35
+
36
+ let repo: string;
37
+
38
+ async function git(args: string[]): Promise<void> {
39
+ await execFileAsync("git", args, { cwd: repo });
40
+ }
41
+
42
+ async function write(rel: string, content: string): Promise<void> {
43
+ await mkdir(join(repo, rel, ".."), { recursive: true });
44
+ await writeFile(join(repo, rel), content, "utf-8");
45
+ }
46
+
47
+ async function writeBytes(rel: string, bytes: Uint8Array): Promise<void> {
48
+ await mkdir(join(repo, rel, ".."), { recursive: true });
49
+ await writeFile(join(repo, rel), bytes);
50
+ }
51
+
52
+ function entryFor(delta: ProgressDelta, path: string): GitProgressEntry | undefined {
53
+ return delta.entries.find((e) => e.pathAfter === path || e.pathBefore === path);
54
+ }
55
+
56
+ beforeEach(async () => {
57
+ repo = await mkdtemp(join(tmpdir(), "stigmer-progress-"));
58
+ await git(["init", "-q"]);
59
+ await git(["config", "user.email", "t@t.local"]);
60
+ await git(["config", "user.name", "t"]);
61
+ await write("src/main.ts", "export const x = 1;\n");
62
+ await write("keep.txt", "line1\nline2\nline3\n");
63
+ await git(["add", "-A"]);
64
+ await git(["commit", "-q", "-m", "initial"]);
65
+ });
66
+
67
+ afterEach(async () => {
68
+ await dropCaptureRefs(repo, EXEC_ID).catch(() => {});
69
+ await rm(repo, { recursive: true, force: true });
70
+ });
71
+
72
+ describe("captureProgressDelta (real git repo)", () => {
73
+ it("reports ADD / MODIFY / DELETE with line counts and honest path sides", async () => {
74
+ const baseline = await snapshotBaseline(repo, EXEC_ID);
75
+
76
+ await write("src/new.ts", "a\nb\nc\n"); // ADD (3 added)
77
+ await write("src/main.ts", "export const x = 2;\nexport const y = 3;\n"); // MODIFY
78
+ await rm(join(repo, "keep.txt")); // DELETE (3 removed)
79
+
80
+ const delta = await captureProgressDelta(repo, EXEC_ID, baseline);
81
+ expect(delta).toBeDefined();
82
+ expect(delta!.entries).toHaveLength(3);
83
+
84
+ const added = entryFor(delta!, "src/new.ts")!;
85
+ expect(added.changeType).toBe(FileChangeType.CREATE);
86
+ expect(added.pathBefore).toBe("");
87
+ expect(added.pathAfter).toBe("src/new.ts");
88
+ expect(added.linesAdded).toBe(3);
89
+ expect(added.linesRemoved).toBe(0);
90
+
91
+ const modified = entryFor(delta!, "src/main.ts")!;
92
+ expect(modified.changeType).toBe(FileChangeType.MODIFY);
93
+ expect(modified.pathBefore).toBe("src/main.ts");
94
+ expect(modified.pathAfter).toBe("src/main.ts");
95
+ expect(modified.linesAdded).toBeGreaterThan(0);
96
+
97
+ const deleted = entryFor(delta!, "keep.txt")!;
98
+ expect(deleted.changeType).toBe(FileChangeType.DELETE);
99
+ expect(deleted.pathBefore).toBe("keep.txt");
100
+ expect(deleted.pathAfter).toBe("");
101
+ expect(deleted.linesRemoved).toBe(3);
102
+ expect(deleted.linesAdded).toBe(0);
103
+ });
104
+
105
+ it("surfaces a binary change with zero counts (numstat reports '-')", async () => {
106
+ const baseline = await snapshotBaseline(repo, EXEC_ID);
107
+ await writeBytes("blob.bin", new Uint8Array([0, 1, 2, 0, 255, 0, 3]));
108
+
109
+ const delta = await captureProgressDelta(repo, EXEC_ID, baseline);
110
+ const bin = entryFor(delta!, "blob.bin")!;
111
+ expect(bin.changeType).toBe(FileChangeType.CREATE);
112
+ expect(bin.linesAdded).toBe(0);
113
+ expect(bin.linesRemoved).toBe(0);
114
+ });
115
+
116
+ it("splits a rename into a delete + create (--no-renames)", async () => {
117
+ const baseline = await snapshotBaseline(repo, EXEC_ID);
118
+ await rm(join(repo, "keep.txt"));
119
+ await write("renamed.txt", "line1\nline2\nline3\n");
120
+
121
+ const delta = await captureProgressDelta(repo, EXEC_ID, baseline);
122
+ expect(entryFor(delta!, "keep.txt")!.changeType).toBe(FileChangeType.DELETE);
123
+ expect(entryFor(delta!, "renamed.txt")!.changeType).toBe(FileChangeType.CREATE);
124
+ });
125
+
126
+ it("short-circuits (returns undefined) when the tree is unchanged since lastTreeSha", async () => {
127
+ const baseline = await snapshotBaseline(repo, EXEC_ID);
128
+ await write("src/new.ts", "a\nb\n");
129
+
130
+ const first = await captureProgressDelta(repo, EXEC_ID, baseline);
131
+ expect(first).toBeDefined();
132
+
133
+ const second = await captureProgressDelta(repo, EXEC_ID, baseline, [], first!.afterTree);
134
+ expect(second).toBeUndefined();
135
+ });
136
+
137
+ it("recomputes (0 entries) after the agent reverts its own edits", async () => {
138
+ const baseline = await snapshotBaseline(repo, EXEC_ID);
139
+ await write("src/new.ts", "a\nb\n");
140
+ const first = await captureProgressDelta(repo, EXEC_ID, baseline);
141
+
142
+ await rm(join(repo, "src/new.ts")); // revert to clean
143
+ const second = await captureProgressDelta(repo, EXEC_ID, baseline, [], first!.afterTree);
144
+ expect(second).toBeDefined();
145
+ expect(second!.entries).toHaveLength(0);
146
+ });
147
+
148
+ it("excludes runner-owned gate files from the delta", async () => {
149
+ const baseline = await snapshotBaseline(repo, EXEC_ID, RUNNER_OWNED_PATHS);
150
+ await write(".cursor/hooks.json", "{}\n");
151
+ await write("real.ts", "x\n");
152
+
153
+ const delta = await captureProgressDelta(repo, EXEC_ID, baseline, RUNNER_OWNED_PATHS);
154
+ expect(entryFor(delta!, ".cursor/hooks.json")).toBeUndefined();
155
+ expect(entryFor(delta!, "real.ts")).toBeDefined();
156
+ });
157
+ });
158
+
159
+ describe("buildFileChangeProgress (pure)", () => {
160
+ function delta(entries: GitProgressEntry[]): ProgressDelta {
161
+ return { afterTree: "tree-sha", entries };
162
+ }
163
+ function entry(path: string, added: number, removed: number): GitProgressEntry {
164
+ return {
165
+ pathBefore: path,
166
+ pathAfter: path,
167
+ changeType: FileChangeType.MODIFY,
168
+ linesAdded: added,
169
+ linesRemoved: removed,
170
+ };
171
+ }
172
+
173
+ it("sums aggregate counts and maps kind", () => {
174
+ const progress = buildFileChangeProgress(
175
+ delta([
176
+ { pathBefore: "", pathAfter: "a.ts", changeType: FileChangeType.CREATE, linesAdded: 5, linesRemoved: 0 },
177
+ entry("b.ts", 2, 3),
178
+ ]),
179
+ CHANGE_SET_ID,
180
+ );
181
+ expect(progress.changeSetId).toBe(CHANGE_SET_ID);
182
+ expect(progress.filesChanged).toBe(2);
183
+ expect(progress.linesAdded).toBe(7);
184
+ expect(progress.linesRemoved).toBe(3);
185
+ expect(progress.entries[0].kind).toBe(FileChangeKind.ADD);
186
+ expect(progress.entries[1].kind).toBe(FileChangeKind.MODIFY);
187
+ expect(progress.capturedAt).not.toBe("");
188
+ });
189
+
190
+ it("zeroes counts for secret-like paths (path visible, magnitude withheld)", () => {
191
+ const progress = buildFileChangeProgress(
192
+ delta([entry(".env", 10, 2), entry("app.ts", 4, 1)]),
193
+ CHANGE_SET_ID,
194
+ );
195
+ // The secret path is still counted as a changed file and its path is present…
196
+ expect(progress.filesChanged).toBe(2);
197
+ const secret = progress.entries.find((e) => e.pathAfter === ".env")!;
198
+ expect(secret.linesAdded).toBe(0);
199
+ expect(secret.linesRemoved).toBe(0);
200
+ // …but its magnitude is excluded from the aggregate.
201
+ expect(progress.linesAdded).toBe(4);
202
+ expect(progress.linesRemoved).toBe(1);
203
+ });
204
+
205
+ it("caps entries but keeps files_changed and totals honest over all files", () => {
206
+ const entries: GitProgressEntry[] = [];
207
+ for (let i = 0; i < PROGRESS_MAX_ENTRIES + 25; i++) {
208
+ entries.push(entry(`f${i}.ts`, 1, 1));
209
+ }
210
+ const progress = buildFileChangeProgress(delta(entries), CHANGE_SET_ID);
211
+ expect(progress.entries).toHaveLength(PROGRESS_MAX_ENTRIES);
212
+ expect(progress.filesChanged).toBe(PROGRESS_MAX_ENTRIES + 25);
213
+ expect(progress.linesAdded).toBe(PROGRESS_MAX_ENTRIES + 25);
214
+ expect(progress.linesRemoved).toBe(PROGRESS_MAX_ENTRIES + 25);
215
+ });
216
+
217
+ it("produces a zero-file snapshot for an empty delta (revert-to-clean)", () => {
218
+ const progress = buildFileChangeProgress(delta([]), CHANGE_SET_ID);
219
+ expect(progress.filesChanged).toBe(0);
220
+ expect(progress.entries).toHaveLength(0);
221
+ });
222
+ });
223
+
224
+ describe("shouldCaptureProgress (pure)", () => {
225
+ it("always captures on the first call (lastAtMs = 0, real clock)", () => {
226
+ // The caller starts a fresh turn with lastAtMs = 0 and now = Date.now(),
227
+ // which is always far past any floor.
228
+ expect(shouldCaptureProgress(0, Date.now(), 2000)).toBe(true);
229
+ });
230
+
231
+ it("gates within the floor and allows once it elapses", () => {
232
+ expect(shouldCaptureProgress(1000, 2500, 2000)).toBe(false);
233
+ expect(shouldCaptureProgress(1000, 3000, 2000)).toBe(true);
234
+ expect(shouldCaptureProgress(1000, 3001, 2000)).toBe(true);
235
+ });
236
+ });