loopforge 3.9.0 → 3.9.2

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 (133) hide show
  1. package/README.md +18 -0
  2. package/dist/audit.d.ts +13 -1
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +18 -3
  5. package/dist/audit.js.map +1 -1
  6. package/dist/committed-round.d.ts +29 -32
  7. package/dist/committed-round.d.ts.map +1 -1
  8. package/dist/committed-round.js +164 -157
  9. package/dist/committed-round.js.map +1 -1
  10. package/dist/contract-items.d.ts +3 -2
  11. package/dist/contract-items.d.ts.map +1 -1
  12. package/dist/contract-items.js +18 -7
  13. package/dist/contract-items.js.map +1 -1
  14. package/dist/enforcement-gate.d.ts.map +1 -1
  15. package/dist/enforcement-gate.js +13 -61
  16. package/dist/enforcement-gate.js.map +1 -1
  17. package/dist/engine.d.ts.map +1 -1
  18. package/dist/engine.js +16 -9
  19. package/dist/engine.js.map +1 -1
  20. package/dist/evidence-claims.d.ts +6 -5
  21. package/dist/evidence-claims.d.ts.map +1 -1
  22. package/dist/evidence-claims.js +21 -20
  23. package/dist/evidence-claims.js.map +1 -1
  24. package/dist/evidence-provider.d.ts +73 -42
  25. package/dist/evidence-provider.d.ts.map +1 -1
  26. package/dist/evidence-provider.js +437 -138
  27. package/dist/evidence-provider.js.map +1 -1
  28. package/dist/explain.d.ts +18 -3
  29. package/dist/explain.d.ts.map +1 -1
  30. package/dist/explain.js +26 -5
  31. package/dist/explain.js.map +1 -1
  32. package/dist/field-accounting.d.ts +29 -0
  33. package/dist/field-accounting.d.ts.map +1 -0
  34. package/dist/field-accounting.js +132 -0
  35. package/dist/field-accounting.js.map +1 -0
  36. package/dist/files-reconciliation.d.ts +18 -0
  37. package/dist/files-reconciliation.d.ts.map +1 -0
  38. package/dist/files-reconciliation.js +59 -0
  39. package/dist/files-reconciliation.js.map +1 -0
  40. package/dist/index.d.ts +7 -3
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +4 -1
  43. package/dist/index.js.map +1 -1
  44. package/dist/loop-compiler.d.ts +5 -11
  45. package/dist/loop-compiler.d.ts.map +1 -1
  46. package/dist/loop-compiler.js +33 -102
  47. package/dist/loop-compiler.js.map +1 -1
  48. package/dist/loop-store.d.ts +3 -0
  49. package/dist/loop-store.d.ts.map +1 -1
  50. package/dist/loop-store.js +21 -11
  51. package/dist/loop-store.js.map +1 -1
  52. package/dist/mcp/round-lifecycle.d.ts +26 -2
  53. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  54. package/dist/mcp/round-lifecycle.js +193 -25
  55. package/dist/mcp/round-lifecycle.js.map +1 -1
  56. package/dist/mcp/server.d.ts.map +1 -1
  57. package/dist/mcp/server.js +17 -14
  58. package/dist/mcp/server.js.map +1 -1
  59. package/dist/mcp/session.d.ts +15 -1
  60. package/dist/mcp/session.d.ts.map +1 -1
  61. package/dist/mcp/session.js +34 -1
  62. package/dist/mcp/session.js.map +1 -1
  63. package/dist/mcp/submission-boundary.d.ts +6 -3
  64. package/dist/mcp/submission-boundary.d.ts.map +1 -1
  65. package/dist/mcp/submission-boundary.js +54 -9
  66. package/dist/mcp/submission-boundary.js.map +1 -1
  67. package/dist/mcp/tools.d.ts.map +1 -1
  68. package/dist/mcp/tools.js +73 -39
  69. package/dist/mcp/tools.js.map +1 -1
  70. package/dist/policy-metrics.d.ts +18 -0
  71. package/dist/policy-metrics.d.ts.map +1 -1
  72. package/dist/policy-metrics.js +67 -1
  73. package/dist/policy-metrics.js.map +1 -1
  74. package/dist/prompt-assembler.d.ts.map +1 -1
  75. package/dist/prompt-assembler.js +63 -11
  76. package/dist/prompt-assembler.js.map +1 -1
  77. package/dist/protocol.d.ts +207 -11
  78. package/dist/protocol.d.ts.map +1 -1
  79. package/dist/protocol.js +7 -1
  80. package/dist/protocol.js.map +1 -1
  81. package/dist/replay.d.ts +3 -0
  82. package/dist/replay.d.ts.map +1 -1
  83. package/dist/replay.js +46 -7
  84. package/dist/replay.js.map +1 -1
  85. package/dist/round-contract.d.ts +5 -4
  86. package/dist/round-contract.d.ts.map +1 -1
  87. package/dist/round-contract.js +4 -4
  88. package/dist/round-contract.js.map +1 -1
  89. package/dist/round-coordinator.d.ts +2 -1
  90. package/dist/round-coordinator.d.ts.map +1 -1
  91. package/dist/round-coordinator.js +17 -9
  92. package/dist/round-coordinator.js.map +1 -1
  93. package/dist/round-driver.d.ts +4 -1
  94. package/dist/round-driver.d.ts.map +1 -1
  95. package/dist/round-driver.js +2 -0
  96. package/dist/round-driver.js.map +1 -1
  97. package/dist/round-facts.d.ts +3 -2
  98. package/dist/round-facts.d.ts.map +1 -1
  99. package/dist/round-facts.js +4 -4
  100. package/dist/round-facts.js.map +1 -1
  101. package/dist/round-transaction.d.ts +21 -10
  102. package/dist/round-transaction.d.ts.map +1 -1
  103. package/dist/round-transaction.js +81 -19
  104. package/dist/round-transaction.js.map +1 -1
  105. package/dist/self-eval.d.ts +40 -10
  106. package/dist/self-eval.d.ts.map +1 -1
  107. package/dist/self-eval.js +543 -123
  108. package/dist/self-eval.js.map +1 -1
  109. package/dist/submission-diagnostics.d.ts +12 -0
  110. package/dist/submission-diagnostics.d.ts.map +1 -0
  111. package/dist/submission-diagnostics.js +12 -0
  112. package/dist/submission-diagnostics.js.map +1 -0
  113. package/dist/submission-guidance.d.ts +137 -0
  114. package/dist/submission-guidance.d.ts.map +1 -0
  115. package/dist/submission-guidance.js +855 -0
  116. package/dist/submission-guidance.js.map +1 -0
  117. package/dist/submission-receipt.d.ts +48 -0
  118. package/dist/submission-receipt.d.ts.map +1 -0
  119. package/dist/submission-receipt.js +341 -0
  120. package/dist/submission-receipt.js.map +1 -0
  121. package/dist/token-utils.d.ts +7 -0
  122. package/dist/token-utils.d.ts.map +1 -1
  123. package/dist/token-utils.js +15 -0
  124. package/dist/token-utils.js.map +1 -1
  125. package/dist/verification-gate.d.ts +7 -6
  126. package/dist/verification-gate.d.ts.map +1 -1
  127. package/dist/verification-gate.js +100 -117
  128. package/dist/verification-gate.js.map +1 -1
  129. package/dist/version.d.ts +1 -1
  130. package/dist/version.js +1 -1
  131. package/loop_policy.json +58 -58
  132. package/package.json +2 -1
  133. package/skills/perception/SKILL.md +71 -27
@@ -10,9 +10,10 @@
10
10
  * execution (v2.0.1), plus explicitly configured shell-free command providers.
11
11
  */
12
12
  import { createHash } from "node:crypto";
13
- import { readFileSync, realpathSync, statSync } from "node:fs";
13
+ import { createReadStream, lstatSync, readFileSync, readlinkSync, realpathSync, statSync, } from "node:fs";
14
14
  import { spawn, execFile, } from "node:child_process";
15
15
  import { isAbsolute, relative, resolve, sep } from "node:path";
16
+ import { GIT_OBSERVATION_SCHEMA_VERSION } from "./protocol.js";
16
17
  import { containInWorkspace } from "./workspace.js";
17
18
  import { commandConfigHash, deriveConfiguredCapability, getPolicy, resolveStateDirectory } from "./policy.js";
18
19
  import { logEvent } from "./observability.js";
@@ -39,28 +40,30 @@ export function isProviderRegistered(name) {
39
40
  /** v3.8: The ONE machine-backed predicate. A command observation proves a
40
41
  * claim only when it is an after-phase command that PASSED and whose
41
42
  * entrypoint was not modified in the same round. Before-phase observations
42
- * are the baseline and never evidence. When no git delta is available the
43
- * entrypoint arm fails open (matching the historical behaviour).
43
+ * are the baseline and never evidence.
44
+ *
45
+ * `not_applicable` (no Git provider participated) leaves the entrypoint arm
46
+ * out; `unknown` (a Git boundary existed but its change set is unobservable)
47
+ * is NOT a pass — the boundary cannot rule out entrypoint tampering.
44
48
  *
45
49
  * v3.8 replaced two divergent implementations (the gate excluded tampered
46
50
  * commands, evidence-claims did not) with this single function. */
47
- export function isPassedAfterObservation(observation, gitChangedFiles) {
51
+ export function isPassedAfterObservation(observation, git) {
48
52
  if (observation.kind !== "command")
49
53
  return false;
50
54
  if (observation.phase !== "after")
51
55
  return false;
52
56
  if (observation.status !== "passed")
53
57
  return false;
54
- const entrypoints = observation.data.entrypointFiles ?? [];
55
- if (!gitChangedFiles || gitChangedFiles.size === 0)
56
- return true;
57
- return !entrypoints.some((file) => gitChangedFiles.has(file));
58
- }
59
- /** v3.8: The changed-file set of a round's git observation, or null when no
60
- * git observation exists (entrypoint checks then fail open). */
61
- export function gitChangedFiles(observations) {
62
- const git = observations.find((observation) => observation.providerId === "git");
63
- return git ? new Set(git.files) : null;
58
+ if (git.state === "unknown")
59
+ return false;
60
+ const entrypoints = Array.isArray(observation.data.entrypointFiles)
61
+ ? observation.data.entrypointFiles.filter((file) => typeof file === "string")
62
+ : [];
63
+ if (git.state === "observed" && entrypoints.some((file) => git.changedFiles.has(file))) {
64
+ return false;
65
+ }
66
+ return true;
64
67
  }
65
68
  /** v3.8: Live capability derived from observations. Rendered only — never
66
69
  * hashed, never persisted independently (it is a pure function of the
@@ -190,7 +193,10 @@ function unregisteredProvider(name) {
190
193
  * the machine fact; `detail` explains it for the audit trail. */
191
194
  function syntheticObservation(providerId, kind, phase, startedAt, status, detail) {
192
195
  return {
193
- schemaVersion: 1,
196
+ // v3.9.1: a failed Git capture still carries the current Git observation
197
+ // schema so the committed snapshot can be parsed; its non-observed status
198
+ // is what tells consumers the boundary is unavailable.
199
+ schemaVersion: kind === "git" ? GIT_OBSERVATION_SCHEMA_VERSION : 1,
194
200
  providerId,
195
201
  kind,
196
202
  phase,
@@ -602,18 +608,52 @@ export class CommandEvidenceProvider {
602
608
  };
603
609
  }
604
610
  }
605
- /** v2.0.1: Capture git file state using parallel async execFile.
606
- *
607
- * Runs three git commands concurrently via Promise.all. Uses a single
608
- * timeout (shared across all commands) and an optional AbortSignal for
609
- * early cancellation. Shell-free (execFile, not exec).
610
- *
611
- * On any command failure, returns null — the caller should treat git
612
- * evidence as unavailable and degrade gracefully.
613
- *
614
- * Performance: wall-clock time is max(single-command), not sum(3).
615
- * On a normal repo (~200ms/command): ~200ms vs ~600ms sequential.
616
- * On Windows with antivirus (~4s/command): ~4s vs ~12s sequential. */
611
+ class GitCaptureFailure extends Error {
612
+ status;
613
+ constructor(status, message) {
614
+ super(message);
615
+ this.status = status;
616
+ this.name = "GitCaptureFailure";
617
+ }
618
+ }
619
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024;
620
+ /** Bounded file-hash concurrency. A module constant, not a policy knob. */
621
+ const BOUNDARY_HASH_CONCURRENCY = 16;
622
+ function splitNul(text) {
623
+ return text.split("\0").filter((entry) => entry.length > 0);
624
+ }
625
+ function uniqueSorted(values) {
626
+ return [...new Set(values)].sort();
627
+ }
628
+ /** Parse `git ls-files --stage -z`. A conflicted path can have several
629
+ * stages; stage 0 wins when present, otherwise the first entry is kept. The
630
+ * worktree fingerprint is what matters, so this is only used to recognise
631
+ * gitlinks and to carry the index object id. */
632
+ function parseStageEntries(text) {
633
+ const byPath = new Map();
634
+ for (const record of splitNul(text)) {
635
+ const tab = record.indexOf("\t");
636
+ if (tab < 0)
637
+ continue;
638
+ const [mode, object, stage] = record.slice(0, tab).split(" ");
639
+ if (!mode || !object || !stage)
640
+ continue;
641
+ const path = record.slice(tab + 1);
642
+ if (!path)
643
+ continue;
644
+ const existing = byPath.get(path);
645
+ if (!existing || stage === "0") {
646
+ byPath.set(path, { entry: { path, mode, object }, stage });
647
+ }
648
+ }
649
+ return [...byPath.values()]
650
+ .map((item) => item.entry)
651
+ .sort((a, b) => a.path.localeCompare(b.path));
652
+ }
653
+ /** v3.9.1: capture the complete boundary. All required git commands run in
654
+ * one parallel batch; HEAD is best-effort (a repository with no commit is a
655
+ * valid boundary with no head). Any required-command failure returns an
656
+ * explicit status — never an empty-but-observed boundary. */
617
657
  export async function captureGitFileStateAsync(signal, timeoutMs,
618
658
  /** Workspace root; injectable so tests can capture against a temp repo.
619
659
  * Defaults to the process cwd, matching the evidence providers. */
@@ -621,53 +661,125 @@ cwd = process.cwd()) {
621
661
  const timeout = timeoutMs ?? 15000;
622
662
  const runGit = (args) => {
623
663
  return new Promise((resolveGit, reject) => {
664
+ let settled = false;
665
+ const succeed = (stdout) => {
666
+ if (settled)
667
+ return;
668
+ settled = true;
669
+ resolveGit(stdout);
670
+ };
671
+ const fail = (error) => {
672
+ if (settled)
673
+ return;
674
+ settled = true;
675
+ reject(error);
676
+ };
624
677
  // M1: core.quotePath=false — git otherwise octal-escapes every path
625
- // with bytes >= 0x80 (Chinese and other non-ASCII filenames become
626
- // "\346\226\207..."). Escaped names can't be stat/hash'd, so content
627
- // changes to such files were invisible to the evidence fingerprints.
678
+ // with bytes >= 0x80. With -z, paths are NUL-delimited raw bytes; the
679
+ // flag is retained for the HEAD/plain outputs.
628
680
  const child = execFile("git", ["-c", "core.quotePath=false", ...args], {
629
681
  cwd,
630
682
  encoding: "utf-8",
631
683
  timeout,
632
684
  signal,
685
+ maxBuffer: GIT_MAX_BUFFER,
633
686
  windowsHide: true,
634
687
  });
635
688
  let stdout = "";
689
+ let stderr = "";
636
690
  child.stdout?.on("data", (chunk) => {
637
691
  stdout += chunk;
638
692
  });
693
+ child.stderr?.on("data", (chunk) => {
694
+ stderr += chunk;
695
+ });
639
696
  child.on("close", (code) => {
640
- code === 0
641
- ? resolveGit(stdout)
642
- : reject(new Error(`git ${args[0]} exited ${code}`));
697
+ if (code === 0)
698
+ succeed(stdout);
699
+ else if (code === null) {
700
+ // A killed process can close without an exit code. If the error
701
+ // event has not settled the promise yet, the measured cause is the
702
+ // deadline.
703
+ fail(new GitCaptureFailure("timeout", `git ${args[0]} was killed`));
704
+ }
705
+ else if (/not a git repository|not a repository/i.test(stderr)) {
706
+ fail(new GitCaptureFailure("unavailable", stderr.trim() || "not a git repository"));
707
+ }
708
+ else {
709
+ fail(new GitCaptureFailure("error", `git ${args[0]} exited ${code}${stderr ? `: ${stderr.trim().slice(0, 300)}` : ""}`));
710
+ }
711
+ });
712
+ child.on("error", (error) => {
713
+ if (signal?.aborted) {
714
+ fail(new GitCaptureFailure("aborted", `git ${args[0]} aborted`));
715
+ }
716
+ else if (error.code === "ENOENT") {
717
+ fail(new GitCaptureFailure("unavailable", "git executable was not found"));
718
+ }
719
+ else if (error.killed || error.signal) {
720
+ fail(new GitCaptureFailure("timeout", `git ${args[0]} timed out after ${timeout}ms`));
721
+ }
722
+ else if (/not a git repository/i.test(error.message)) {
723
+ fail(new GitCaptureFailure("unavailable", error.message));
724
+ }
725
+ else {
726
+ fail(new GitCaptureFailure("error", error.message));
727
+ }
643
728
  });
644
- child.on("error", reject);
645
729
  });
646
730
  };
647
731
  try {
648
- const [tracked, staged, untracked] = await Promise.all([
649
- runGit(["diff", "--name-only"]),
650
- runGit(["diff", "--cached", "--name-only"]),
651
- runGit(["ls-files", "--others", "--exclude-standard"]),
732
+ // allSettled, not all: on failure the other children must still finish
733
+ // before this capture returns, or Windows keeps the workspace directory
734
+ // locked (and every caller would leak child-process handles).
735
+ const settled = await Promise.allSettled([
736
+ runGit(["ls-files", "--stage", "-z"]),
737
+ runGit(["ls-files", "--others", "--exclude-standard", "-z"]),
738
+ runGit(["diff", "--name-only", "-z"]),
739
+ runGit(["diff", "--cached", "--name-only", "-z"]),
652
740
  ]);
653
- // v2.13: Capture HEAD commit for backtrack restore (best-effort)
741
+ const failures = settled
742
+ .filter((item) => item.status === "rejected")
743
+ .map((item) => item.reason)
744
+ .filter((error) => error instanceof GitCaptureFailure);
745
+ if (failures.length > 0) {
746
+ const failure = failures.find((item) => item.status === "aborted" || item.status === "timeout") ?? failures[0];
747
+ throw failure;
748
+ }
749
+ const rejected = settled.find((item) => item.status === "rejected");
750
+ if (rejected) {
751
+ throw new GitCaptureFailure("error", String(rejected.reason));
752
+ }
753
+ const [stageOut, untrackedOut, unstagedOut, stagedOut] = settled.map((item) => item.value);
654
754
  let head;
655
755
  try {
656
- const headOut = await runGit(["rev-parse", "HEAD"]);
657
- head = headOut.trim() || undefined;
756
+ head = (await runGit(["rev-parse", "--verify", "HEAD"])).trim() || undefined;
658
757
  }
659
- catch {
660
- // Detached HEAD or non-repo — head stays undefined
758
+ catch (error) {
759
+ // A repository with no commit has no HEAD. Abort still propagates.
760
+ if (error instanceof GitCaptureFailure && error.status === "aborted")
761
+ throw error;
661
762
  }
763
+ const untracked = uniqueSorted(splitNul(untrackedOut));
662
764
  return {
663
- tracked: tracked.trim().split("\n").filter((f) => f.length > 0).sort(),
664
- staged: staged.trim().split("\n").filter((f) => f.length > 0).sort(),
665
- untracked: untracked.trim().split("\n").filter((f) => f.length > 0).sort(),
666
- head,
765
+ ok: true,
766
+ state: {
767
+ tracked: parseStageEntries(stageOut),
768
+ untracked,
769
+ dirty: uniqueSorted([
770
+ ...splitNul(unstagedOut),
771
+ ...splitNul(stagedOut),
772
+ ...untracked,
773
+ ]),
774
+ head,
775
+ },
667
776
  };
668
777
  }
669
- catch {
670
- return null;
778
+ catch (error) {
779
+ if (error instanceof GitCaptureFailure) {
780
+ return { ok: false, status: error.status, detail: error.message };
781
+ }
782
+ return { ok: false, status: "error", detail: String(error) };
671
783
  }
672
784
  }
673
785
  // ── Built-in: GitEvidenceProvider ──────────────────────────────────────────
@@ -688,11 +800,117 @@ function isInsideDirectory(file, workspace, dir) {
688
800
  const prefix = dir.endsWith(sep) ? dir : `${dir}${sep}`;
689
801
  return absolute === dir || absolute.startsWith(prefix);
690
802
  }
803
+ function hashText(text) {
804
+ return createHash("sha256").update(text).digest("hex");
805
+ }
806
+ /** Stream a regular file through SHA-256 without loading it into memory. */
807
+ function hashFileStream(abs, signal) {
808
+ return new Promise((resolveHash, reject) => {
809
+ const hash = createHash("sha256");
810
+ const stream = createReadStream(abs, { highWaterMark: 64 * 1024 });
811
+ let settled = false;
812
+ const cleanup = () => {
813
+ if (signal)
814
+ signal.removeEventListener("abort", onAbort);
815
+ };
816
+ const finish = (error) => {
817
+ if (settled)
818
+ return;
819
+ settled = true;
820
+ cleanup();
821
+ if (error)
822
+ reject(error);
823
+ else
824
+ resolveHash(hash.digest("hex"));
825
+ };
826
+ const onAbort = () => {
827
+ stream.destroy();
828
+ finish(new GitCaptureFailure("aborted", "boundary hashing aborted"));
829
+ };
830
+ if (signal?.aborted) {
831
+ onAbort();
832
+ return;
833
+ }
834
+ signal?.addEventListener("abort", onAbort, { once: true });
835
+ stream.on("data", (chunk) => hash.update(chunk));
836
+ stream.on("error", (error) => finish(error));
837
+ stream.on("end", () => finish());
838
+ });
839
+ }
840
+ /** Fingerprint one boundary path.
841
+ * - symlinks hash their LINK TEXT; the target is never read
842
+ * - regular files stream their content
843
+ * - gitlinks record the index commit (submodule contents are not covered)
844
+ * A per-file read/containment failure is an explicit `unreadable:<mode>`
845
+ * sentinel, not an omitted path. */
846
+ async function boundaryFingerprint(workspace, path, tracked, signal) {
847
+ const abs = resolve(workspace, path);
848
+ let stat;
849
+ try {
850
+ stat = lstatSync(abs);
851
+ }
852
+ catch (error) {
853
+ return error.code === "ENOENT" ? "missing" : "unreadable";
854
+ }
855
+ const mode = stat.mode;
856
+ if (stat.isSymbolicLink()) {
857
+ try {
858
+ // The parent must be contained; the final link target is NOT followed.
859
+ const parent = path.includes("/")
860
+ ? path.slice(0, path.lastIndexOf("/")) || "."
861
+ : ".";
862
+ containInWorkspace(workspace, parent);
863
+ return `link:${hashText(readlinkSync(abs))}`;
864
+ }
865
+ catch {
866
+ return `unreadable:${mode}`;
867
+ }
868
+ }
869
+ if (stat.isDirectory()) {
870
+ if (tracked?.mode === "160000")
871
+ return `gitlink:${tracked.object}`;
872
+ return `dir:${mode}`;
873
+ }
874
+ if (!stat.isFile())
875
+ return `other:${mode}`;
876
+ try {
877
+ containInWorkspace(workspace, path);
878
+ const digest = await hashFileStream(abs, signal);
879
+ return `${mode}:${digest}`;
880
+ }
881
+ catch (error) {
882
+ if (error instanceof GitCaptureFailure)
883
+ throw error;
884
+ return `unreadable:${mode}`;
885
+ }
886
+ }
887
+ /** Hash the complete boundary with bounded concurrency. */
888
+ async function fingerprintBoundary(workspace, entries, signal) {
889
+ const paths = [...entries.keys()].sort();
890
+ const fingerprints = {};
891
+ let next = 0;
892
+ const worker = async () => {
893
+ for (;;) {
894
+ if (signal?.aborted) {
895
+ throw new GitCaptureFailure("aborted", "boundary hashing aborted");
896
+ }
897
+ const index = next++;
898
+ if (index >= paths.length)
899
+ return;
900
+ const path = paths[index];
901
+ fingerprints[path] = await boundaryFingerprint(workspace, path, entries.get(path), signal);
902
+ }
903
+ };
904
+ const workers = Math.min(BOUNDARY_HASH_CONCURRENCY, Math.max(1, paths.length));
905
+ await Promise.all(Array.from({ length: workers }, () => worker()));
906
+ return fingerprints;
907
+ }
691
908
  export class GitEvidenceProvider {
692
909
  name = "git";
693
910
  kind = "git";
694
- capture(context) {
911
+ async capture(context) {
695
912
  const workspace = context?.cwd ?? process.cwd();
913
+ const phase = context?.phase ?? "after";
696
914
  const startedAt = Date.now();
697
915
  // v3.8.1: the runtime's OWN state directory is not the agent's work. The
698
916
  // vault lives inside the workspace by default, so every round's
@@ -702,80 +920,91 @@ export class GitEvidenceProvider {
702
920
  // .gitignore (which the runtime does not own and a user may not have).
703
921
  const stateDir = stateDirectory(workspace);
704
922
  const isRuntimeState = (file) => stateDir !== null && isInsideDirectory(file, workspace, stateDir);
705
- return captureGitFileStateAsync(context?.signal, context?.timeoutMs, workspace).then((state) => {
706
- if (!state)
707
- return null;
708
- const files = [...new Set([
709
- ...state.tracked,
710
- ...state.staged,
711
- ...state.untracked,
712
- ])].filter((file) => !isRuntimeState(file)).sort();
713
- // v3.7.1: paths from git are workspace-relative; the capture runs with
714
- // the workspace as cwd, so relative stat/read resolve there naturally.
715
- // An injectable cwd (tests) must resolve explicitly.
716
- const fingerprints = {};
717
- for (const file of files) {
718
- const target = isAbsolute(file) ? file : resolve(workspace, file);
719
- fingerprints[file] = fileFingerprint(target);
923
+ // The collector already races providers, but a library caller has no
924
+ // such guard. Enforce the same budget here: abort the child commands and
925
+ // the hashing loop, and report a TIMEOUT rather than a partial boundary.
926
+ const controller = new AbortController();
927
+ const external = context?.signal;
928
+ let timedOut = false;
929
+ const onExternalAbort = () => controller.abort(external?.reason);
930
+ if (external?.aborted)
931
+ controller.abort(external.reason);
932
+ else
933
+ external?.addEventListener("abort", onExternalAbort, { once: true });
934
+ const budget = context?.timeoutMs ?? 0;
935
+ const timer = budget > 0
936
+ ? setTimeout(() => {
937
+ timedOut = true;
938
+ controller.abort(new Error("boundary capture timed out"));
939
+ }, budget)
940
+ : undefined;
941
+ try {
942
+ const result = await captureGitFileStateAsync(controller.signal, context?.timeoutMs, workspace);
943
+ if (timedOut) {
944
+ return syntheticObservation(this.name, this.kind, phase, startedAt, "timeout", `boundary capture exceeded ${budget}ms`);
720
945
  }
721
- return {
722
- schemaVersion: 1,
723
- providerId: "git",
724
- kind: "git",
725
- phase: context?.phase ?? "after",
726
- startedAt,
727
- finishedAt: Date.now(),
728
- status: "observed",
729
- files,
730
- data: {
731
- tracked: state.tracked.filter((file) => !isRuntimeState(file)),
732
- staged: state.staged.filter((file) => !isRuntimeState(file)),
733
- untracked: state.untracked.filter((file) => !isRuntimeState(file)),
734
- fingerprints,
735
- head: state.head,
736
- },
737
- };
738
- });
946
+ if (!result.ok) {
947
+ return syntheticObservation(this.name, this.kind, phase, startedAt, result.status, result.detail);
948
+ }
949
+ const tracked = result.state.tracked.filter((entry) => !isRuntimeState(entry.path));
950
+ const untracked = result.state.untracked.filter((file) => !isRuntimeState(file));
951
+ const dirty = result.state.dirty.filter((file) => !isRuntimeState(file));
952
+ const boundary = new Map();
953
+ for (const entry of tracked)
954
+ boundary.set(entry.path, entry);
955
+ for (const file of untracked) {
956
+ if (!boundary.has(file)) {
957
+ boundary.set(file, { path: file, mode: "", object: "" });
958
+ }
959
+ }
960
+ try {
961
+ const fingerprints = await fingerprintBoundary(workspace, boundary, controller.signal);
962
+ if (timedOut) {
963
+ return syntheticObservation(this.name, this.kind, phase, startedAt, "timeout", `boundary capture exceeded ${budget}ms`);
964
+ }
965
+ return {
966
+ schemaVersion: GIT_OBSERVATION_SCHEMA_VERSION,
967
+ providerId: "git",
968
+ kind: "git",
969
+ phase,
970
+ startedAt,
971
+ finishedAt: Date.now(),
972
+ status: "observed",
973
+ // `files` is the dirty display list; the complete boundary lives in
974
+ // data.fingerprints and the round delta is derived from it.
975
+ files: dirty,
976
+ data: {
977
+ dirty,
978
+ fingerprints,
979
+ head: result.state.head,
980
+ coverage: "tracked_and_non_ignored_untracked",
981
+ },
982
+ };
983
+ }
984
+ catch (error) {
985
+ const status = timedOut
986
+ ? "timeout"
987
+ : error instanceof GitCaptureFailure
988
+ ? error.status
989
+ : "error";
990
+ return syntheticObservation(this.name, this.kind, phase, startedAt, status, String(error instanceof Error ? error.message : error));
991
+ }
992
+ }
993
+ finally {
994
+ if (timer)
995
+ clearTimeout(timer);
996
+ external?.removeEventListener("abort", onExternalAbort);
997
+ }
739
998
  }
740
999
  }
741
1000
  registerEvidenceProvider("git", () => new GitEvidenceProvider());
742
- // ── Utility ────────────────────────────────────────────────────────────────
743
- /** Extract merged file list from observations for backward compat with
744
- * runtimeFilesChanged (string[] | null).
745
- *
746
- * Looks for the "git" provider first; falls back to merging all providers'
747
- * files arrays (deduplicated). */
748
- export function extractFilesFromSnapshots(observations) {
749
- if (observations.length === 0)
750
- return null;
751
- const gitObservation = observations.find((item) => item.providerId === "git");
752
- if (gitObservation)
753
- return [...gitObservation.files].sort();
754
- const allFiles = new Set();
755
- for (const item of observations) {
756
- for (const file of item.files)
757
- allFiles.add(file);
758
- }
759
- return [...allFiles].sort();
760
- }
761
- /** v3.8: The round delta of two observation collections (before → after):
762
- * files that appeared, disappeared, or changed content. The full payload of
763
- * each after-observation is preserved; only `files` is narrowed. This is the
764
- * single derivation the transaction persists and every reader consumes. */
765
- export function diffSnapshotCollections(before, after) {
766
- return after.map((observation) => {
767
- const baseline = before.find((item) => item.providerId === observation.providerId);
768
- if (!baseline)
769
- return observation;
770
- return {
771
- ...observation,
772
- files: diffProviderSnapshot(baseline, observation),
773
- };
774
- });
775
- }
776
- /** Compute a diff between two observation collections (before → after). */
777
- export function diffSnapshots(before, after) {
778
- return extractFilesFromSnapshots(diffSnapshotCollections(before, after));
1001
+ const DELTA_KIND_ORDER = {
1002
+ git: 0,
1003
+ command: 1,
1004
+ custom: 2,
1005
+ };
1006
+ function deltaKey(observation) {
1007
+ return `${observation.kind}\u0000${observation.providerId}`;
779
1008
  }
780
1009
  function snapshotFingerprints(observation) {
781
1010
  if (observation.kind !== "git")
@@ -783,25 +1012,95 @@ function snapshotFingerprints(observation) {
783
1012
  const value = observation.data.fingerprints;
784
1013
  if (!value || typeof value !== "object" || Array.isArray(value))
785
1014
  return null;
786
- const fingerprints = {};
787
- for (const [file, fingerprint] of Object.entries(value)) {
788
- if (typeof fingerprint === "string")
789
- fingerprints[file] = fingerprint;
1015
+ const entries = Object.entries(value);
1016
+ // A partial map cannot produce a sound diff: an unsupported value could be
1017
+ // the one real change. Unknown is null, never a filtered subset.
1018
+ if (!entries.every(([, fingerprint]) => typeof fingerprint === "string")) {
1019
+ return null;
790
1020
  }
791
- return fingerprints;
1021
+ return Object.fromEntries(entries);
1022
+ }
1023
+ function diffFingerprints(before, after) {
1024
+ const candidates = new Set([...Object.keys(before), ...Object.keys(after)]);
1025
+ return [...candidates]
1026
+ .filter((file) => before[file] !== after[file])
1027
+ .sort();
1028
+ }
1029
+ /** The ONE before→after derivation. Delta entries are one per
1030
+ * kind+providerId in the union of both collections, in deterministic order.
1031
+ * Missing sides and duplicate keys are explicit; they never masquerade as an
1032
+ * observed no-change. */
1033
+ export function deriveObservationDeltas(before, after) {
1034
+ const beforeByKey = new Map();
1035
+ const afterByKey = new Map();
1036
+ for (const observation of before) {
1037
+ const key = deltaKey(observation);
1038
+ const bucket = beforeByKey.get(key);
1039
+ if (bucket)
1040
+ bucket.push(observation);
1041
+ else
1042
+ beforeByKey.set(key, [observation]);
1043
+ }
1044
+ for (const observation of after) {
1045
+ const key = deltaKey(observation);
1046
+ const bucket = afterByKey.get(key);
1047
+ if (bucket)
1048
+ bucket.push(observation);
1049
+ else
1050
+ afterByKey.set(key, [observation]);
1051
+ }
1052
+ const keys = [...new Set([...beforeByKey.keys(), ...afterByKey.keys()])];
1053
+ const deltas = keys.map((key) => {
1054
+ const beforeList = beforeByKey.get(key) ?? [];
1055
+ const afterList = afterByKey.get(key) ?? [];
1056
+ const beforeObservation = beforeList[0] ?? null;
1057
+ const afterObservation = afterList[0] ?? null;
1058
+ const observation = afterObservation ?? beforeObservation;
1059
+ const ambiguous = beforeList.length > 1 || afterList.length > 1;
1060
+ let changedFiles = null;
1061
+ if (!ambiguous &&
1062
+ observation.kind === "git" &&
1063
+ beforeObservation?.kind === "git" &&
1064
+ afterObservation?.kind === "git" &&
1065
+ beforeObservation.status === "observed" &&
1066
+ afterObservation.status === "observed") {
1067
+ const beforeFingerprints = snapshotFingerprints(beforeObservation);
1068
+ const afterFingerprints = snapshotFingerprints(afterObservation);
1069
+ if (beforeFingerprints && afterFingerprints) {
1070
+ changedFiles = diffFingerprints(beforeFingerprints, afterFingerprints);
1071
+ }
1072
+ }
1073
+ return {
1074
+ providerId: observation.providerId,
1075
+ kind: observation.kind,
1076
+ before: beforeObservation,
1077
+ after: afterObservation,
1078
+ changedFiles,
1079
+ };
1080
+ });
1081
+ return deltas.sort((left, right) => DELTA_KIND_ORDER[left.kind] - DELTA_KIND_ORDER[right.kind] ||
1082
+ left.providerId.localeCompare(right.providerId));
1083
+ }
1084
+ /** Resolve the round's Git change state once. No Git provider =
1085
+ * not_applicable; any Git delta with an unknown change set = unknown; else
1086
+ * the union of every observed Git change set. */
1087
+ export function gitDeltaState(deltas) {
1088
+ const git = deltas.filter((delta) => delta.kind === "git");
1089
+ if (git.length === 0)
1090
+ return { state: "not_applicable" };
1091
+ if (git.some((delta) => delta.changedFiles === null))
1092
+ return { state: "unknown" };
1093
+ const changedFiles = new Set();
1094
+ for (const delta of git) {
1095
+ for (const file of delta.changedFiles)
1096
+ changedFiles.add(file);
1097
+ }
1098
+ return { state: "observed", changedFiles };
792
1099
  }
793
- function diffProviderSnapshot(before, after) {
794
- const beforeSet = new Set(before.files);
795
- const afterSet = new Set(after.files);
796
- const beforeFingerprints = snapshotFingerprints(before);
797
- const afterFingerprints = snapshotFingerprints(after);
798
- const candidates = new Set([...before.files, ...after.files]);
799
- return [...candidates].filter((file) => {
800
- if (beforeSet.has(file) !== afterSet.has(file))
801
- return true;
802
- if (!beforeFingerprints || !afterFingerprints)
803
- return false;
804
- return beforeFingerprints[file] !== afterFingerprints[file];
805
- }).sort();
1100
+ /** The after-phase observations referenced by a delta collection. */
1101
+ export function afterObservations(deltas) {
1102
+ return deltas
1103
+ .map((delta) => delta.after)
1104
+ .filter((observation) => observation !== null);
806
1105
  }
807
1106
  //# sourceMappingURL=evidence-provider.js.map