loopforge 3.9.0 → 3.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/dist/audit.d.ts +13 -1
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +18 -3
- package/dist/audit.js.map +1 -1
- package/dist/committed-round.d.ts +29 -32
- package/dist/committed-round.d.ts.map +1 -1
- package/dist/committed-round.js +164 -157
- package/dist/committed-round.js.map +1 -1
- package/dist/contract-items.d.ts +3 -2
- package/dist/contract-items.d.ts.map +1 -1
- package/dist/contract-items.js +18 -7
- package/dist/contract-items.js.map +1 -1
- package/dist/enforcement-gate.d.ts.map +1 -1
- package/dist/enforcement-gate.js +13 -61
- package/dist/enforcement-gate.js.map +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +16 -9
- package/dist/engine.js.map +1 -1
- package/dist/evidence-claims.d.ts +6 -5
- package/dist/evidence-claims.d.ts.map +1 -1
- package/dist/evidence-claims.js +21 -20
- package/dist/evidence-claims.js.map +1 -1
- package/dist/evidence-provider.d.ts +73 -42
- package/dist/evidence-provider.d.ts.map +1 -1
- package/dist/evidence-provider.js +437 -138
- package/dist/evidence-provider.js.map +1 -1
- package/dist/explain.d.ts +18 -3
- package/dist/explain.d.ts.map +1 -1
- package/dist/explain.js +26 -5
- package/dist/explain.js.map +1 -1
- package/dist/field-accounting.d.ts +29 -0
- package/dist/field-accounting.d.ts.map +1 -0
- package/dist/field-accounting.js +132 -0
- package/dist/field-accounting.js.map +1 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/loop-compiler.d.ts +5 -11
- package/dist/loop-compiler.d.ts.map +1 -1
- package/dist/loop-compiler.js +29 -86
- package/dist/loop-compiler.js.map +1 -1
- package/dist/loop-store.d.ts +3 -0
- package/dist/loop-store.d.ts.map +1 -1
- package/dist/loop-store.js +21 -11
- package/dist/loop-store.js.map +1 -1
- package/dist/mcp/round-lifecycle.d.ts +26 -2
- package/dist/mcp/round-lifecycle.d.ts.map +1 -1
- package/dist/mcp/round-lifecycle.js +193 -25
- package/dist/mcp/round-lifecycle.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +17 -14
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/session.d.ts +15 -1
- package/dist/mcp/session.d.ts.map +1 -1
- package/dist/mcp/session.js +34 -1
- package/dist/mcp/session.js.map +1 -1
- package/dist/mcp/submission-boundary.d.ts +5 -2
- package/dist/mcp/submission-boundary.d.ts.map +1 -1
- package/dist/mcp/submission-boundary.js +38 -6
- package/dist/mcp/submission-boundary.js.map +1 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +73 -39
- package/dist/mcp/tools.js.map +1 -1
- package/dist/policy-metrics.d.ts +14 -0
- package/dist/policy-metrics.d.ts.map +1 -1
- package/dist/policy-metrics.js +53 -1
- package/dist/policy-metrics.js.map +1 -1
- package/dist/prompt-assembler.d.ts.map +1 -1
- package/dist/prompt-assembler.js +63 -11
- package/dist/prompt-assembler.js.map +1 -1
- package/dist/protocol.d.ts +182 -11
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +4 -1
- package/dist/protocol.js.map +1 -1
- package/dist/replay.d.ts +3 -0
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +46 -7
- package/dist/replay.js.map +1 -1
- package/dist/round-contract.d.ts +5 -4
- package/dist/round-contract.d.ts.map +1 -1
- package/dist/round-contract.js +4 -4
- package/dist/round-contract.js.map +1 -1
- package/dist/round-coordinator.d.ts +2 -1
- package/dist/round-coordinator.d.ts.map +1 -1
- package/dist/round-coordinator.js +17 -9
- package/dist/round-coordinator.js.map +1 -1
- package/dist/round-driver.d.ts +4 -1
- package/dist/round-driver.d.ts.map +1 -1
- package/dist/round-driver.js +2 -0
- package/dist/round-driver.js.map +1 -1
- package/dist/round-facts.d.ts +3 -2
- package/dist/round-facts.d.ts.map +1 -1
- package/dist/round-facts.js +4 -4
- package/dist/round-facts.js.map +1 -1
- package/dist/round-transaction.d.ts +21 -10
- package/dist/round-transaction.d.ts.map +1 -1
- package/dist/round-transaction.js +81 -19
- package/dist/round-transaction.js.map +1 -1
- package/dist/self-eval.d.ts +28 -10
- package/dist/self-eval.d.ts.map +1 -1
- package/dist/self-eval.js +522 -124
- package/dist/self-eval.js.map +1 -1
- package/dist/submission-diagnostics.d.ts +12 -0
- package/dist/submission-diagnostics.d.ts.map +1 -0
- package/dist/submission-diagnostics.js +12 -0
- package/dist/submission-diagnostics.js.map +1 -0
- package/dist/submission-guidance.d.ts +119 -0
- package/dist/submission-guidance.d.ts.map +1 -0
- package/dist/submission-guidance.js +766 -0
- package/dist/submission-guidance.js.map +1 -0
- package/dist/submission-receipt.d.ts +48 -0
- package/dist/submission-receipt.d.ts.map +1 -0
- package/dist/submission-receipt.js +336 -0
- package/dist/submission-receipt.js.map +1 -0
- package/dist/verification-gate.d.ts +10 -6
- package/dist/verification-gate.d.ts.map +1 -1
- package/dist/verification-gate.js +95 -87
- package/dist/verification-gate.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
- package/skills/perception/SKILL.md +66 -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.
|
|
43
|
-
*
|
|
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,
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
*
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
|
626
|
-
//
|
|
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
|
-
|
|
642
|
-
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
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
|
-
|
|
657
|
-
head = headOut.trim() || undefined;
|
|
756
|
+
head = (await runGit(["rev-parse", "--verify", "HEAD"])).trim() || undefined;
|
|
658
757
|
}
|
|
659
|
-
catch {
|
|
660
|
-
//
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|