loopforge 3.7.0 → 3.8.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/LICENSE +21 -21
- package/README.md +171 -157
- package/dist/audit.d.ts +53 -1
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +106 -22
- package/dist/audit.js.map +1 -1
- package/dist/canonical-state.d.ts +75 -69
- package/dist/canonical-state.d.ts.map +1 -1
- package/dist/canonical-state.js +276 -168
- package/dist/canonical-state.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +111 -12
- package/dist/cli.js.map +1 -1
- package/dist/cognitive-facts.d.ts +13 -0
- package/dist/cognitive-facts.d.ts.map +1 -1
- package/dist/cognitive-facts.js +30 -16
- package/dist/cognitive-facts.js.map +1 -1
- package/dist/cognitive-governance.d.ts +30 -6
- package/dist/cognitive-governance.d.ts.map +1 -1
- package/dist/cognitive-governance.js +74 -18
- package/dist/cognitive-governance.js.map +1 -1
- package/dist/committed-round.d.ts +109 -24
- package/dist/committed-round.d.ts.map +1 -1
- package/dist/committed-round.js +235 -52
- package/dist/committed-round.js.map +1 -1
- package/dist/contract-items.d.ts +55 -0
- package/dist/contract-items.d.ts.map +1 -0
- package/dist/contract-items.js +180 -0
- package/dist/contract-items.js.map +1 -0
- package/dist/enforcement-gate.d.ts +43 -20
- package/dist/enforcement-gate.d.ts.map +1 -1
- package/dist/enforcement-gate.js +269 -378
- package/dist/enforcement-gate.js.map +1 -1
- package/dist/engine.d.ts +11 -6
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +102 -67
- package/dist/engine.js.map +1 -1
- package/dist/evidence-claims.d.ts +4 -4
- package/dist/evidence-claims.d.ts.map +1 -1
- package/dist/evidence-claims.js +16 -14
- package/dist/evidence-claims.js.map +1 -1
- package/dist/evidence-provider.d.ts +91 -90
- package/dist/evidence-provider.d.ts.map +1 -1
- package/dist/evidence-provider.js +349 -160
- package/dist/evidence-provider.js.map +1 -1
- package/dist/explain.d.ts +76 -0
- package/dist/explain.d.ts.map +1 -0
- package/dist/explain.js +127 -0
- package/dist/explain.js.map +1 -0
- package/dist/generate-schema.js +250 -53
- package/dist/generate-schema.js.map +1 -1
- package/dist/index.d.ts +8 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -6
- package/dist/index.js.map +1 -1
- package/dist/loop-compiler.d.ts +41 -40
- package/dist/loop-compiler.d.ts.map +1 -1
- package/dist/loop-compiler.js +275 -515
- package/dist/loop-compiler.js.map +1 -1
- package/dist/loop-extras-parser.d.ts +0 -1
- package/dist/loop-extras-parser.d.ts.map +1 -1
- package/dist/loop-extras-parser.js +0 -4
- package/dist/loop-extras-parser.js.map +1 -1
- package/dist/loop-projection.d.ts.map +1 -1
- package/dist/loop-projection.js +3 -1
- package/dist/loop-projection.js.map +1 -1
- package/dist/mcp/round-lifecycle.d.ts +40 -21
- package/dist/mcp/round-lifecycle.d.ts.map +1 -1
- package/dist/mcp/round-lifecycle.js +173 -120
- package/dist/mcp/round-lifecycle.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +63 -10
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/session.d.ts +53 -7
- package/dist/mcp/session.d.ts.map +1 -1
- package/dist/mcp/session.js +240 -139
- package/dist/mcp/session.js.map +1 -1
- package/dist/mcp/submission-boundary.d.ts +89 -0
- package/dist/mcp/submission-boundary.d.ts.map +1 -0
- package/dist/mcp/submission-boundary.js +203 -0
- package/dist/mcp/submission-boundary.js.map +1 -0
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +377 -125
- package/dist/mcp/tools.js.map +1 -1
- package/dist/policy-metrics.js +2 -2
- package/dist/policy-metrics.js.map +1 -1
- package/dist/policy.d.ts +65 -95
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +151 -39
- package/dist/policy.js.map +1 -1
- package/dist/prompt-assembler.d.ts +7 -33
- package/dist/prompt-assembler.d.ts.map +1 -1
- package/dist/prompt-assembler.js +313 -473
- package/dist/prompt-assembler.js.map +1 -1
- package/dist/prompt-policy.d.ts +1 -4
- package/dist/prompt-policy.d.ts.map +1 -1
- package/dist/prompt-policy.js +6 -7
- package/dist/prompt-policy.js.map +1 -1
- package/dist/protocol.d.ts +411 -168
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +23 -69
- package/dist/protocol.js.map +1 -1
- package/dist/replay.js +2 -2
- package/dist/replay.js.map +1 -1
- package/dist/round-contract.d.ts +69 -57
- package/dist/round-contract.d.ts.map +1 -1
- package/dist/round-contract.js +104 -64
- package/dist/round-contract.js.map +1 -1
- package/dist/round-coordinator.d.ts +55 -16
- package/dist/round-coordinator.d.ts.map +1 -1
- package/dist/round-coordinator.js +179 -57
- package/dist/round-coordinator.js.map +1 -1
- package/dist/round-driver.d.ts +7 -5
- package/dist/round-driver.d.ts.map +1 -1
- package/dist/round-driver.js +2 -1
- package/dist/round-driver.js.map +1 -1
- package/dist/round-facts.d.ts +96 -0
- package/dist/round-facts.d.ts.map +1 -0
- package/dist/round-facts.js +216 -0
- package/dist/round-facts.js.map +1 -0
- package/dist/round-transaction.d.ts +23 -9
- package/dist/round-transaction.d.ts.map +1 -1
- package/dist/round-transaction.js +99 -22
- package/dist/round-transaction.js.map +1 -1
- package/dist/self-eval.d.ts +127 -11
- package/dist/self-eval.d.ts.map +1 -1
- package/dist/self-eval.js +517 -36
- package/dist/self-eval.js.map +1 -1
- package/dist/subgoal-state.d.ts +77 -0
- package/dist/subgoal-state.d.ts.map +1 -0
- package/dist/subgoal-state.js +198 -0
- package/dist/subgoal-state.js.map +1 -0
- package/dist/token-utils.d.ts +52 -19
- package/dist/token-utils.d.ts.map +1 -1
- package/dist/token-utils.js +83 -72
- package/dist/token-utils.js.map +1 -1
- package/dist/verification-gate.d.ts +27 -46
- package/dist/verification-gate.d.ts.map +1 -1
- package/dist/verification-gate.js +308 -557
- package/dist/verification-gate.js.map +1 -1
- package/dist/version.d.ts +5 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +5 -0
- package/dist/version.js.map +1 -0
- package/dist/workspace.d.ts +9 -0
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +17 -0
- package/dist/workspace.js.map +1 -1
- package/loop_policy.json +17 -25
- package/package.json +7 -7
- package/skills/perception/SKILL.md +266 -64
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
/** EvidenceProvider — Pluggable
|
|
1
|
+
/** EvidenceProvider — Pluggable machine-observation capture (v1.18 / v3.8).
|
|
2
2
|
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* v3.8: providers produce `MachineObservation`s, not ad-hoc snapshots. A
|
|
4
|
+
* configured provider ALWAYS yields an observation: unavailability, timeouts,
|
|
5
|
+
* errors and aborts are recorded as observations with the matching status and
|
|
6
|
+
* are never silently filtered out of the round's factual record. Only
|
|
7
|
+
* observations can create a `verified` fact — agent self-reports never can.
|
|
6
8
|
*
|
|
7
|
-
* Built-in
|
|
8
|
-
*
|
|
9
|
+
* Built-in: GitEvidenceProvider — git file state capture with parallel async
|
|
10
|
+
* execution (v2.0.1), plus explicitly configured shell-free command providers.
|
|
9
11
|
*/
|
|
10
12
|
import { createHash } from "node:crypto";
|
|
11
13
|
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
12
14
|
import { spawn, execFile, } from "node:child_process";
|
|
13
|
-
import { relative, resolve, sep } from "node:path";
|
|
15
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
14
16
|
import { containInWorkspace } from "./workspace.js";
|
|
15
|
-
import { getPolicy } from "./policy.js";
|
|
17
|
+
import { commandConfigHash, deriveConfiguredCapability, getPolicy, resolveStateDirectory } from "./policy.js";
|
|
16
18
|
import { logEvent } from "./observability.js";
|
|
17
19
|
import { policyMetrics } from "./policy-metrics.js";
|
|
18
20
|
const providerFactories = new Map();
|
|
@@ -27,29 +29,172 @@ export function unregisterEvidenceProvider(name) {
|
|
|
27
29
|
return false;
|
|
28
30
|
return providerFactories.delete(name);
|
|
29
31
|
}
|
|
32
|
+
/** v3.8: Whether a provider factory is registered for this name. Readiness
|
|
33
|
+
* of the RUNTIME (PATH resolution, executables) is a `doctor` concern and is
|
|
34
|
+
* deliberately not part of the hashed capability. */
|
|
35
|
+
export function isProviderRegistered(name) {
|
|
36
|
+
return providerFactories.has(name);
|
|
37
|
+
}
|
|
38
|
+
// ── The single machine-backed predicate ────────────────────────────────────
|
|
39
|
+
/** v3.8: The ONE machine-backed predicate. A command observation proves a
|
|
40
|
+
* claim only when it is an after-phase command that PASSED and whose
|
|
41
|
+
* 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).
|
|
44
|
+
*
|
|
45
|
+
* v3.8 replaced two divergent implementations (the gate excluded tampered
|
|
46
|
+
* commands, evidence-claims did not) with this single function. */
|
|
47
|
+
export function isPassedAfterObservation(observation, gitChangedFiles) {
|
|
48
|
+
if (observation.kind !== "command")
|
|
49
|
+
return false;
|
|
50
|
+
if (observation.phase !== "after")
|
|
51
|
+
return false;
|
|
52
|
+
if (observation.status !== "passed")
|
|
53
|
+
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;
|
|
64
|
+
}
|
|
65
|
+
/** v3.8: Live capability derived from observations. Rendered only — never
|
|
66
|
+
* hashed, never persisted independently (it is a pure function of the
|
|
67
|
+
* observations it is given). */
|
|
68
|
+
export function deriveObservedCapability(observations, configured) {
|
|
69
|
+
const latest = new Map();
|
|
70
|
+
const latestCommand = new Map();
|
|
71
|
+
for (const observation of observations) {
|
|
72
|
+
latest.set(observation.providerId, observation.status);
|
|
73
|
+
if (observation.kind === "command") {
|
|
74
|
+
latestCommand.set(observation.data.commandId, {
|
|
75
|
+
status: observation.status,
|
|
76
|
+
configHash: observation.data.configHash,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
providers: configured.providers.map((provider) => ({
|
|
82
|
+
providerId: provider.providerId,
|
|
83
|
+
status: latest.get(provider.providerId) ?? "unavailable",
|
|
84
|
+
})),
|
|
85
|
+
commands: configured.commands.map((command) => {
|
|
86
|
+
const seen = latestCommand.get(command.commandId);
|
|
87
|
+
return {
|
|
88
|
+
commandId: command.commandId,
|
|
89
|
+
status: seen?.status ?? "unavailable",
|
|
90
|
+
configHash: seen?.configHash ?? command.configHash,
|
|
91
|
+
};
|
|
92
|
+
}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** v3.8: Human-readable capability warnings for start/resume/status. A loop
|
|
96
|
+
* with no machine verification can still run — its success claims are simply
|
|
97
|
+
* recorded as `insufficient` instead of `verified`. */
|
|
98
|
+
export function capabilityWarnings(configured, observed) {
|
|
99
|
+
const warnings = [];
|
|
100
|
+
if (!configured.observationConfigured) {
|
|
101
|
+
warnings.push("No evidence provider is configured — the runtime can observe nothing " +
|
|
102
|
+
"this loop; success claims will be recorded as insufficient.");
|
|
103
|
+
}
|
|
104
|
+
if (!configured.contractVerificationAvailable) {
|
|
105
|
+
warnings.push("No enabled verification command is configured — Round Contract items " +
|
|
106
|
+
"cannot be machine-verified; contract closure will require an explicit " +
|
|
107
|
+
"blocked outcome.");
|
|
108
|
+
}
|
|
109
|
+
if (observed) {
|
|
110
|
+
const dead = observed.providers.filter((provider) => provider.status === "unavailable" || provider.status === "error");
|
|
111
|
+
if (dead.length > 0) {
|
|
112
|
+
warnings.push(`Evidence provider(s) unavailable: ${dead.map((p) => p.providerId).join(", ")}.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return warnings;
|
|
116
|
+
}
|
|
117
|
+
/** v3.8: The ONE capability derivation. Everything that speaks about
|
|
118
|
+
* capability — `RoundDriver.prepare()`, MCP start/resume/next/status, the
|
|
119
|
+
* capability warnings — reads this, so the surfaces cannot drift into
|
|
120
|
+
* different stories about what the runtime can observe.
|
|
121
|
+
*
|
|
122
|
+
* `available` is provider-REGISTRY state (code, not policy): it is rendered
|
|
123
|
+
* and reported by `doctor`, never hashed. The hashed half stays
|
|
124
|
+
* `deriveConfiguredCapability(policy)` inside the canonical state. */
|
|
125
|
+
export function deriveEvidenceCapability(policy, observations = []) {
|
|
126
|
+
const configured = deriveConfiguredCapability(policy);
|
|
127
|
+
const observed = deriveObservedCapability(observations, configured);
|
|
128
|
+
const statusByProvider = new Map(observed.providers.map((provider) => [provider.providerId, provider.status]));
|
|
129
|
+
return {
|
|
130
|
+
schemaVersion: 1,
|
|
131
|
+
providers: configured.providers.map((provider) => ({
|
|
132
|
+
providerId: provider.providerId,
|
|
133
|
+
available: isProviderRegistered(provider.providerId),
|
|
134
|
+
status: statusByProvider.get(provider.providerId) ?? "unavailable",
|
|
135
|
+
})),
|
|
136
|
+
commands: configured.commands.map((command) => ({
|
|
137
|
+
commandId: command.commandId,
|
|
138
|
+
enabled: command.enabled,
|
|
139
|
+
phase: command.phase,
|
|
140
|
+
configHash: command.configHash,
|
|
141
|
+
ready: command.enabled && (command.phase === "after" || command.phase === "both"),
|
|
142
|
+
})),
|
|
143
|
+
contractVerificationAvailable: configured.contractVerificationAvailable,
|
|
144
|
+
warnings: capabilityWarnings(configured, observed),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
30
147
|
// ── EvidenceCollector ──────────────────────────────────────────────────────
|
|
31
|
-
/**
|
|
32
|
-
*
|
|
148
|
+
/** v3.8: The stand-in for a name in `policy.evidence.providers` that no
|
|
149
|
+
* registered factory answers. Configuring a provider is a factual claim that
|
|
150
|
+
* it observes something; when the runtime has no such provider the claim must
|
|
151
|
+
* still leave a record, so this one captures `unavailable` instead of the
|
|
152
|
+
* name vanishing from the round. Registration is code state (a `doctor`
|
|
153
|
+
* concern), so the observation is the only place the gap can legitimately
|
|
154
|
+
* appear in the Vault. */
|
|
155
|
+
function unregisteredProvider(name) {
|
|
156
|
+
return {
|
|
157
|
+
name,
|
|
158
|
+
kind: "custom",
|
|
159
|
+
capture(context) {
|
|
160
|
+
return syntheticObservation(name, "custom", context?.phase ?? "after", Date.now(), "unavailable", "provider is not registered in this runtime (see `loopforge doctor`)");
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/** v3.8: Build the observation a failed capture resolves to. The status names
|
|
165
|
+
* the machine fact; `detail` explains it for the audit trail. */
|
|
166
|
+
function syntheticObservation(providerId, kind, phase, startedAt, status, detail) {
|
|
167
|
+
return {
|
|
168
|
+
schemaVersion: 1,
|
|
169
|
+
providerId,
|
|
170
|
+
kind,
|
|
171
|
+
phase,
|
|
172
|
+
startedAt,
|
|
173
|
+
finishedAt: Date.now(),
|
|
174
|
+
status,
|
|
175
|
+
files: [],
|
|
176
|
+
data: { detail },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/** Collects observations from all configured providers (always async).
|
|
33
180
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*/
|
|
181
|
+
* v3.8: every configured provider produces exactly one observation per
|
|
182
|
+
* collection. A provider that returns null, throws, times out, or is aborted
|
|
183
|
+
* yields an observation with `unavailable` / `error` / `timeout` / `aborted`
|
|
184
|
+
* — the round's factual record keeps the gap instead of hiding it. */
|
|
39
185
|
export class EvidenceCollector {
|
|
40
186
|
providers;
|
|
41
187
|
constructor(providers) {
|
|
42
188
|
this.providers = providers;
|
|
43
189
|
}
|
|
44
|
-
/** Build the collector described by loop_policy.json.
|
|
45
|
-
*
|
|
190
|
+
/** Build the collector described by loop_policy.json. Every configured name
|
|
191
|
+
* yields exactly one provider, in configuration order: a name with no
|
|
192
|
+
* registered factory gets `unregisteredProvider`, which records
|
|
193
|
+
* `unavailable` rather than letting a configured provider vanish without a
|
|
194
|
+
* trace. */
|
|
46
195
|
static fromProviderNames(providerNames) {
|
|
47
|
-
const providers =
|
|
48
|
-
|
|
49
|
-
const factory = providerFactories.get(name);
|
|
50
|
-
if (factory)
|
|
51
|
-
providers.push(factory());
|
|
52
|
-
}
|
|
196
|
+
const providers = providerNames
|
|
197
|
+
.map((name) => providerFactories.get(name)?.() ?? unregisteredProvider(name));
|
|
53
198
|
return new EvidenceCollector(providers);
|
|
54
199
|
}
|
|
55
200
|
/** Build built-ins and explicitly configured command providers. */
|
|
@@ -66,13 +211,10 @@ export class EvidenceCollector {
|
|
|
66
211
|
}
|
|
67
212
|
return new EvidenceCollector(providers);
|
|
68
213
|
}
|
|
69
|
-
// v3.7: the synchronous collect() was removed with the sync lifecycle
|
|
70
|
-
// (prepareSync / captureGitFileState) — evidence collection is always
|
|
71
|
-
// async (collectAsync). It never worked for async providers anyway: they
|
|
72
|
-
// were silently skipped and recorded as failures.
|
|
73
214
|
/** Capture all providers concurrently with per-provider timeout isolation. */
|
|
74
215
|
async collectAsync(options = {}) {
|
|
75
216
|
const timeoutMs = options.timeoutMs ?? getPolicy().evidence.timeout_ms;
|
|
217
|
+
const phase = options.phase ?? "after";
|
|
76
218
|
const captures = this.providers.map(async (provider) => {
|
|
77
219
|
const startedAt = Date.now();
|
|
78
220
|
const controller = new AbortController();
|
|
@@ -83,40 +225,43 @@ export class EvidenceCollector {
|
|
|
83
225
|
signal: controller.signal,
|
|
84
226
|
timeoutMs,
|
|
85
227
|
loopId: options.loopId,
|
|
86
|
-
phase
|
|
228
|
+
phase,
|
|
87
229
|
}));
|
|
88
|
-
const
|
|
230
|
+
const observation = timeoutMs > 0
|
|
89
231
|
? await Promise.race([
|
|
90
232
|
capture,
|
|
91
|
-
new Promise((
|
|
233
|
+
new Promise((resolveRace) => {
|
|
92
234
|
timer = setTimeout(() => {
|
|
93
235
|
timedOut = true;
|
|
94
236
|
controller.abort(new Error(`Evidence provider timed out after ${timeoutMs}ms`));
|
|
95
|
-
|
|
237
|
+
resolveRace(null);
|
|
96
238
|
}, timeoutMs);
|
|
97
239
|
}),
|
|
98
240
|
])
|
|
99
241
|
: await capture;
|
|
100
|
-
const outcome = timedOut
|
|
101
|
-
? "timeout"
|
|
102
|
-
: snapshot ? "available" : "unavailable";
|
|
103
|
-
policyMetrics.recordEvidence(provider.name, outcome, Date.now() - startedAt, options.loopId);
|
|
104
242
|
if (timedOut) {
|
|
243
|
+
policyMetrics.recordEvidence(provider.name, "timeout", Date.now() - startedAt, options.loopId);
|
|
105
244
|
logEvent("evidence_provider_timeout", { provider: provider.name, timeoutMs });
|
|
245
|
+
return syntheticObservation(provider.name, provider.kind, phase, startedAt, "timeout", `provider exceeded ${timeoutMs}ms`);
|
|
246
|
+
}
|
|
247
|
+
if (!observation) {
|
|
248
|
+
policyMetrics.recordEvidence(provider.name, "unavailable", Date.now() - startedAt, options.loopId);
|
|
249
|
+
return syntheticObservation(provider.name, provider.kind, phase, startedAt, "unavailable", "provider reported no observation");
|
|
106
250
|
}
|
|
107
|
-
|
|
251
|
+
policyMetrics.recordEvidence(provider.name, observation.status === "timeout" ? "timeout" : "available", Date.now() - startedAt, options.loopId);
|
|
252
|
+
return observation;
|
|
108
253
|
}
|
|
109
254
|
catch (error) {
|
|
110
255
|
policyMetrics.recordEvidence(provider.name, "failure", Date.now() - startedAt, options.loopId);
|
|
111
256
|
logEvent("evidence_provider_error", { provider: provider.name, error: String(error) });
|
|
112
|
-
return
|
|
257
|
+
return syntheticObservation(provider.name, provider.kind, phase, startedAt, "error", String(error));
|
|
113
258
|
}
|
|
114
259
|
finally {
|
|
115
260
|
if (timer)
|
|
116
261
|
clearTimeout(timer);
|
|
117
262
|
}
|
|
118
263
|
});
|
|
119
|
-
return
|
|
264
|
+
return await Promise.all(captures);
|
|
120
265
|
}
|
|
121
266
|
}
|
|
122
267
|
function commandCwd(configured) {
|
|
@@ -137,14 +282,40 @@ function commandCwd(configured) {
|
|
|
137
282
|
* paths are skipped. Returns forward-slash paths (git convention) so the
|
|
138
283
|
* gate can intersect them with the round's git diff. Empty result = the
|
|
139
284
|
* command's entrypoint cannot be observed (external command) — fail open. */
|
|
140
|
-
|
|
285
|
+
/** Package-manager executables, whose own configuration IS the command's
|
|
286
|
+
* harness: `npm test` runs whatever `scripts.test` names, so an edit there
|
|
287
|
+
* changes what the machine will execute. Any other command's harness is what
|
|
288
|
+
* its own command line names. */
|
|
289
|
+
const PACKAGE_MANAGER_EXECUTABLES = new Set([
|
|
290
|
+
"npm", "npm.cmd", "npm.exe",
|
|
291
|
+
"npx", "npx.cmd", "npx.exe",
|
|
292
|
+
"pnpm", "pnpm.cmd", "yarn", "yarn.cmd", "yarnpkg",
|
|
293
|
+
"bun", "bunx",
|
|
294
|
+
]);
|
|
295
|
+
function isPackageManagerCommand(executable) {
|
|
296
|
+
const base = executable.split(/[\\/]/).pop() ?? executable;
|
|
297
|
+
return PACKAGE_MANAGER_EXECUTABLES.has(base.trim().toLowerCase());
|
|
298
|
+
}
|
|
299
|
+
/** The workspace files a command's execution depends on, resolved
|
|
300
|
+
* statically: the script it names on its own command line, plus package.json
|
|
301
|
+
* when a package manager is what runs it. Exported for the pure-function
|
|
302
|
+
* test — spawning a real package manager is not portable (a bare `npm`
|
|
303
|
+
* cannot be spawned without a shell, and the runtime uses `shell: false`). */
|
|
304
|
+
export function resolveEntrypointFiles(executable, args, cwd) {
|
|
141
305
|
// v3.3.1: entrypoint containment uses the shared workspace check.
|
|
142
306
|
const workspace = process.cwd();
|
|
143
307
|
const candidates = [...args];
|
|
144
308
|
if (executable.includes("/") || executable.includes("\\")) {
|
|
145
309
|
candidates.unshift(executable);
|
|
146
310
|
}
|
|
147
|
-
|
|
311
|
+
// v3.8.1: `package.json` is a candidate only for a package-manager
|
|
312
|
+
// invocation, which is the only case where it defines what runs. Adding it
|
|
313
|
+
// unconditionally made EVERY command's entrypoint set include it, so a round
|
|
314
|
+
// that touched package.json — for any reason at all, with any command — was
|
|
315
|
+
// reported as "the verification entrypoint changed" and rejected, however
|
|
316
|
+
// unrelated the file was to what actually executed.
|
|
317
|
+
if (isPackageManagerCommand(executable))
|
|
318
|
+
candidates.push("package.json");
|
|
148
319
|
const found = [];
|
|
149
320
|
for (const candidate of candidates) {
|
|
150
321
|
try {
|
|
@@ -165,7 +336,9 @@ function resolveEntrypointFiles(executable, args, cwd) {
|
|
|
165
336
|
/** Explicit, shell-free verification command. Disabled unless configured. */
|
|
166
337
|
export class CommandEvidenceProvider {
|
|
167
338
|
name;
|
|
339
|
+
kind = "command";
|
|
168
340
|
config;
|
|
341
|
+
configHash;
|
|
169
342
|
constructor(config) {
|
|
170
343
|
const name = typeof config.name === "string" && config.name.trim()
|
|
171
344
|
? config.name.trim()
|
|
@@ -192,6 +365,7 @@ export class CommandEvidenceProvider {
|
|
|
192
365
|
: 20_000,
|
|
193
366
|
success_exit_codes: successExitCodes,
|
|
194
367
|
};
|
|
368
|
+
this.configHash = commandConfigHash(this.config);
|
|
195
369
|
this.name = `command:${name}`;
|
|
196
370
|
}
|
|
197
371
|
capture(context) {
|
|
@@ -200,21 +374,24 @@ export class CommandEvidenceProvider {
|
|
|
200
374
|
return Promise.resolve(null);
|
|
201
375
|
}
|
|
202
376
|
const startedAt = Date.now();
|
|
203
|
-
const cap = Math.max(0,
|
|
377
|
+
const cap = Math.max(0, this.config.max_output_chars);
|
|
204
378
|
const timeoutMs = Math.max(1, Math.min(this.config.timeout_ms, context?.timeoutMs || this.config.timeout_ms));
|
|
205
379
|
let cwd;
|
|
206
380
|
if (!this.config.executable) {
|
|
207
|
-
return Promise.resolve(this.
|
|
381
|
+
return Promise.resolve(this.observation(phase, "error", null, null, "", "", false, 0, [], "command executable is empty"));
|
|
208
382
|
}
|
|
209
383
|
try {
|
|
210
384
|
cwd = commandCwd(this.config.cwd);
|
|
211
385
|
}
|
|
212
386
|
catch (error) {
|
|
213
|
-
return Promise.resolve(this.
|
|
387
|
+
return Promise.resolve(this.observation(phase, "error", null, null, "", "", false, Date.now() - startedAt, [], String(error)));
|
|
214
388
|
}
|
|
215
389
|
// v3.3: Workspace entrypoint files for tamper detection at the gate.
|
|
216
390
|
const entrypointFiles = resolveEntrypointFiles(this.config.executable, this.config.args, cwd);
|
|
217
391
|
return new Promise((resolveCapture) => {
|
|
392
|
+
// v3.8: the FULL streams are hashed; the retained excerpt is capped.
|
|
393
|
+
const stdoutHash = createHash("sha256");
|
|
394
|
+
const stderrHash = createHash("sha256");
|
|
218
395
|
let stdout = "";
|
|
219
396
|
let stderr = "";
|
|
220
397
|
let truncated = false;
|
|
@@ -222,9 +399,10 @@ export class CommandEvidenceProvider {
|
|
|
222
399
|
let settled = false;
|
|
223
400
|
const append = (current, chunk) => {
|
|
224
401
|
const text = String(chunk);
|
|
402
|
+
if (text.length === 0)
|
|
403
|
+
return current;
|
|
225
404
|
if (retained >= cap) {
|
|
226
|
-
|
|
227
|
-
truncated = true;
|
|
405
|
+
truncated = true;
|
|
228
406
|
return current;
|
|
229
407
|
}
|
|
230
408
|
const remaining = cap - retained;
|
|
@@ -241,24 +419,31 @@ export class CommandEvidenceProvider {
|
|
|
241
419
|
stdio: ["ignore", "pipe", "pipe"],
|
|
242
420
|
});
|
|
243
421
|
let timer;
|
|
244
|
-
const finish = (status, exitCode, signal) => {
|
|
422
|
+
const finish = (status, exitCode, signal, failureDetail) => {
|
|
245
423
|
if (settled)
|
|
246
424
|
return;
|
|
247
425
|
settled = true;
|
|
248
426
|
if (timer)
|
|
249
427
|
clearTimeout(timer);
|
|
250
428
|
context?.signal.removeEventListener("abort", abort);
|
|
251
|
-
resolveCapture(this.
|
|
429
|
+
resolveCapture(this.observation(phase, status, exitCode, signal, stdout, stderr, truncated, Date.now() - startedAt, entrypointFiles, failureDetail, stdoutHash.digest("hex"), stderrHash.digest("hex")));
|
|
252
430
|
};
|
|
253
431
|
const abort = () => {
|
|
254
432
|
child.kill();
|
|
255
|
-
finish("aborted", null, null);
|
|
433
|
+
finish("aborted", null, null, "aborted by the collector deadline");
|
|
256
434
|
};
|
|
257
|
-
child.stdout.on("data", (chunk) => {
|
|
258
|
-
|
|
435
|
+
child.stdout.on("data", (chunk) => {
|
|
436
|
+
stdoutHash.update(String(chunk));
|
|
437
|
+
stdout = append(stdout, chunk);
|
|
438
|
+
});
|
|
439
|
+
child.stderr.on("data", (chunk) => {
|
|
440
|
+
stderrHash.update(String(chunk));
|
|
441
|
+
stderr = append(stderr, chunk);
|
|
442
|
+
});
|
|
259
443
|
child.once("error", (error) => {
|
|
444
|
+
stderrHash.update(error.message);
|
|
260
445
|
stderr = append(stderr, error.message);
|
|
261
|
-
finish(error.code === "ENOENT" ? "
|
|
446
|
+
finish(error.code === "ENOENT" ? "unavailable" : "error", null, null, error.code ?? error.message);
|
|
262
447
|
});
|
|
263
448
|
child.once("close", (code, signal) => {
|
|
264
449
|
const passed = code !== null && this.config.success_exit_codes.includes(code);
|
|
@@ -266,7 +451,7 @@ export class CommandEvidenceProvider {
|
|
|
266
451
|
});
|
|
267
452
|
timer = setTimeout(() => {
|
|
268
453
|
child.kill();
|
|
269
|
-
finish("timeout", null, null);
|
|
454
|
+
finish("timeout", null, null, `command exceeded ${timeoutMs}ms`);
|
|
270
455
|
}, timeoutMs);
|
|
271
456
|
timer.unref?.();
|
|
272
457
|
if (context?.signal.aborted)
|
|
@@ -275,22 +460,31 @@ export class CommandEvidenceProvider {
|
|
|
275
460
|
context?.signal.addEventListener("abort", abort, { once: true });
|
|
276
461
|
});
|
|
277
462
|
}
|
|
278
|
-
|
|
463
|
+
observation(phase, status, exitCode, signal, stdout, stderr, truncated, durationMs, entrypointFiles, failureDetail, stdoutSha256, stderrSha256) {
|
|
464
|
+
const finishedAt = Date.now();
|
|
279
465
|
return {
|
|
280
|
-
|
|
281
|
-
|
|
466
|
+
schemaVersion: 1,
|
|
467
|
+
providerId: this.name,
|
|
468
|
+
kind: "command",
|
|
469
|
+
phase,
|
|
470
|
+
startedAt: finishedAt - durationMs,
|
|
471
|
+
finishedAt,
|
|
472
|
+
status,
|
|
282
473
|
files: [],
|
|
283
474
|
data: {
|
|
284
|
-
|
|
285
|
-
|
|
475
|
+
commandId: this.config.name,
|
|
476
|
+
argv: [this.config.executable, ...this.config.args],
|
|
477
|
+
cwd: this.config.cwd ?? ".",
|
|
478
|
+
configHash: this.configHash,
|
|
286
479
|
required: this.config.required,
|
|
287
|
-
phase,
|
|
288
|
-
status,
|
|
289
480
|
exitCode,
|
|
290
481
|
signal,
|
|
291
482
|
durationMs,
|
|
292
|
-
|
|
293
|
-
|
|
483
|
+
...(failureDetail ? { failureDetail } : {}),
|
|
484
|
+
stdoutSha256: stdoutSha256 ?? createHash("sha256").update(stdout).digest("hex"),
|
|
485
|
+
stderrSha256: stderrSha256 ?? createHash("sha256").update(stderr).digest("hex"),
|
|
486
|
+
stdoutExcerpt: stdout,
|
|
487
|
+
stderrExcerpt: stderr,
|
|
294
488
|
truncated,
|
|
295
489
|
entrypointFiles,
|
|
296
490
|
},
|
|
@@ -309,11 +503,19 @@ export class CommandEvidenceProvider {
|
|
|
309
503
|
* Performance: wall-clock time is max(single-command), not sum(3).
|
|
310
504
|
* On a normal repo (~200ms/command): ~200ms vs ~600ms sequential.
|
|
311
505
|
* On Windows with antivirus (~4s/command): ~4s vs ~12s sequential. */
|
|
312
|
-
export async function captureGitFileStateAsync(signal, timeoutMs
|
|
506
|
+
export async function captureGitFileStateAsync(signal, timeoutMs,
|
|
507
|
+
/** Workspace root; injectable so tests can capture against a temp repo.
|
|
508
|
+
* Defaults to the process cwd, matching the evidence providers. */
|
|
509
|
+
cwd = process.cwd()) {
|
|
313
510
|
const timeout = timeoutMs ?? 15000;
|
|
314
511
|
const runGit = (args) => {
|
|
315
|
-
return new Promise((
|
|
316
|
-
|
|
512
|
+
return new Promise((resolveGit, reject) => {
|
|
513
|
+
// M1: core.quotePath=false — git otherwise octal-escapes every path
|
|
514
|
+
// with bytes >= 0x80 (Chinese and other non-ASCII filenames become
|
|
515
|
+
// "\346\226\207..."). Escaped names can't be stat/hash'd, so content
|
|
516
|
+
// changes to such files were invisible to the evidence fingerprints.
|
|
517
|
+
const child = execFile("git", ["-c", "core.quotePath=false", ...args], {
|
|
518
|
+
cwd,
|
|
317
519
|
encoding: "utf-8",
|
|
318
520
|
timeout,
|
|
319
521
|
signal,
|
|
@@ -325,7 +527,7 @@ export async function captureGitFileStateAsync(signal, timeoutMs) {
|
|
|
325
527
|
});
|
|
326
528
|
child.on("close", (code) => {
|
|
327
529
|
code === 0
|
|
328
|
-
?
|
|
530
|
+
? resolveGit(stdout)
|
|
329
531
|
: reject(new Error(`git ${args[0]} exited ${code}`));
|
|
330
532
|
});
|
|
331
533
|
child.on("error", reject);
|
|
@@ -358,42 +560,76 @@ export async function captureGitFileStateAsync(signal, timeoutMs) {
|
|
|
358
560
|
}
|
|
359
561
|
}
|
|
360
562
|
// ── Built-in: GitEvidenceProvider ──────────────────────────────────────────
|
|
361
|
-
/**
|
|
362
|
-
*
|
|
563
|
+
/** The runtime's resolved state directory, or null when it cannot be observed
|
|
564
|
+
* (configured outside the workspace, or an unresolvable path). */
|
|
565
|
+
function stateDirectory(workspace) {
|
|
566
|
+
try {
|
|
567
|
+
return resolveStateDirectory(workspace, getPolicy().backend.root_dir);
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/** True when a workspace-relative or absolute file path is the state directory
|
|
574
|
+
* or lives inside it. */
|
|
575
|
+
function isInsideDirectory(file, workspace, dir) {
|
|
576
|
+
const absolute = isAbsolute(file) ? file : resolve(workspace, file);
|
|
577
|
+
const prefix = dir.endsWith(sep) ? dir : `${dir}${sep}`;
|
|
578
|
+
return absolute === dir || absolute.startsWith(prefix);
|
|
579
|
+
}
|
|
363
580
|
export class GitEvidenceProvider {
|
|
364
581
|
name = "git";
|
|
582
|
+
kind = "git";
|
|
365
583
|
capture(context) {
|
|
366
|
-
|
|
584
|
+
const workspace = context?.cwd ?? process.cwd();
|
|
585
|
+
const startedAt = Date.now();
|
|
586
|
+
// v3.8.1: the runtime's OWN state directory is not the agent's work. The
|
|
587
|
+
// vault lives inside the workspace by default, so every round's
|
|
588
|
+
// bookkeeping writes would otherwise read as machine-observed motion —
|
|
589
|
+
// the very signal the stall evaluator and the restore checks consume. The
|
|
590
|
+
// exclusion lives here, at the fact, and not in the workspace's
|
|
591
|
+
// .gitignore (which the runtime does not own and a user may not have).
|
|
592
|
+
const stateDir = stateDirectory(workspace);
|
|
593
|
+
const isRuntimeState = (file) => stateDir !== null && isInsideDirectory(file, workspace, stateDir);
|
|
594
|
+
return captureGitFileStateAsync(context?.signal, context?.timeoutMs, workspace).then((state) => {
|
|
367
595
|
if (!state)
|
|
368
596
|
return null;
|
|
369
597
|
const files = [...new Set([
|
|
370
598
|
...state.tracked,
|
|
371
599
|
...state.staged,
|
|
372
600
|
...state.untracked,
|
|
373
|
-
])].sort();
|
|
601
|
+
])].filter((file) => !isRuntimeState(file)).sort();
|
|
602
|
+
// v3.7.1: paths from git are workspace-relative; the capture runs with
|
|
603
|
+
// the workspace as cwd, so relative stat/read resolve there naturally.
|
|
604
|
+
// An injectable cwd (tests) must resolve explicitly.
|
|
374
605
|
const fingerprints = {};
|
|
375
606
|
for (const file of files) {
|
|
376
607
|
try {
|
|
377
|
-
const
|
|
378
|
-
const
|
|
608
|
+
const target = isAbsolute(file) ? file : resolve(workspace, file);
|
|
609
|
+
const stat = statSync(target);
|
|
610
|
+
const hash = createHash("sha256").update(readFileSync(target)).digest("hex");
|
|
379
611
|
fingerprints[file] = `${stat.mode}:${hash}`;
|
|
380
612
|
}
|
|
381
613
|
catch {
|
|
382
|
-
// Deleted files are evidence too.
|
|
614
|
+
// Deleted files are evidence too. A stable sentinel lets the diff
|
|
383
615
|
// distinguish deleted/restored transitions across a round.
|
|
384
616
|
fingerprints[file] = "missing";
|
|
385
617
|
}
|
|
386
618
|
}
|
|
387
619
|
return {
|
|
388
|
-
|
|
389
|
-
|
|
620
|
+
schemaVersion: 1,
|
|
621
|
+
providerId: "git",
|
|
622
|
+
kind: "git",
|
|
623
|
+
phase: context?.phase ?? "after",
|
|
624
|
+
startedAt,
|
|
625
|
+
finishedAt: Date.now(),
|
|
626
|
+
status: "observed",
|
|
390
627
|
files,
|
|
391
628
|
data: {
|
|
392
|
-
tracked: state.tracked,
|
|
393
|
-
staged: state.staged,
|
|
394
|
-
untracked: state.untracked,
|
|
629
|
+
tracked: state.tracked.filter((file) => !isRuntimeState(file)),
|
|
630
|
+
staged: state.staged.filter((file) => !isRuntimeState(file)),
|
|
631
|
+
untracked: state.untracked.filter((file) => !isRuntimeState(file)),
|
|
395
632
|
fingerprints,
|
|
396
|
-
// v2.13: HEAD commit hash for backtrack workspace restore
|
|
397
633
|
head: state.head,
|
|
398
634
|
},
|
|
399
635
|
};
|
|
@@ -402,31 +638,47 @@ export class GitEvidenceProvider {
|
|
|
402
638
|
}
|
|
403
639
|
registerEvidenceProvider("git", () => new GitEvidenceProvider());
|
|
404
640
|
// ── Utility ────────────────────────────────────────────────────────────────
|
|
405
|
-
/** Extract merged file list from
|
|
406
|
-
*
|
|
641
|
+
/** Extract merged file list from observations for backward compat with
|
|
642
|
+
* runtimeFilesChanged (string[] | null).
|
|
407
643
|
*
|
|
408
|
-
* Looks for the "git" provider first; falls back to merging all
|
|
409
|
-
*
|
|
410
|
-
export function extractFilesFromSnapshots(
|
|
411
|
-
if (
|
|
644
|
+
* Looks for the "git" provider first; falls back to merging all providers'
|
|
645
|
+
* files arrays (deduplicated). */
|
|
646
|
+
export function extractFilesFromSnapshots(observations) {
|
|
647
|
+
if (observations.length === 0)
|
|
412
648
|
return null;
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
return [...gitSnapshot.files].sort();
|
|
417
|
-
// Fallback: merge all providers
|
|
649
|
+
const gitObservation = observations.find((item) => item.providerId === "git");
|
|
650
|
+
if (gitObservation)
|
|
651
|
+
return [...gitObservation.files].sort();
|
|
418
652
|
const allFiles = new Set();
|
|
419
|
-
for (const
|
|
420
|
-
for (const
|
|
421
|
-
allFiles.add(
|
|
653
|
+
for (const item of observations) {
|
|
654
|
+
for (const file of item.files)
|
|
655
|
+
allFiles.add(file);
|
|
422
656
|
}
|
|
423
657
|
return [...allFiles].sort();
|
|
424
658
|
}
|
|
425
|
-
/**
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
|
|
429
|
-
|
|
659
|
+
/** v3.8: The round delta of two observation collections (before → after):
|
|
660
|
+
* files that appeared, disappeared, or changed content. The full payload of
|
|
661
|
+
* each after-observation is preserved; only `files` is narrowed. This is the
|
|
662
|
+
* single derivation the transaction persists and every reader consumes. */
|
|
663
|
+
export function diffSnapshotCollections(before, after) {
|
|
664
|
+
return after.map((observation) => {
|
|
665
|
+
const baseline = before.find((item) => item.providerId === observation.providerId);
|
|
666
|
+
if (!baseline)
|
|
667
|
+
return observation;
|
|
668
|
+
return {
|
|
669
|
+
...observation,
|
|
670
|
+
files: diffProviderSnapshot(baseline, observation),
|
|
671
|
+
};
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
/** Compute a diff between two observation collections (before → after). */
|
|
675
|
+
export function diffSnapshots(before, after) {
|
|
676
|
+
return extractFilesFromSnapshots(diffSnapshotCollections(before, after));
|
|
677
|
+
}
|
|
678
|
+
function snapshotFingerprints(observation) {
|
|
679
|
+
if (observation.kind !== "git")
|
|
680
|
+
return null;
|
|
681
|
+
const value = observation.data.fingerprints;
|
|
430
682
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
431
683
|
return null;
|
|
432
684
|
const fingerprints = {};
|
|
@@ -450,67 +702,4 @@ function diffProviderSnapshot(before, after) {
|
|
|
450
702
|
return beforeFingerprints[file] !== afterFingerprints[file];
|
|
451
703
|
}).sort();
|
|
452
704
|
}
|
|
453
|
-
/** Return provider snapshots narrowed to evidence produced during this round.
|
|
454
|
-
* The full provider payload remains available in data, while files contains
|
|
455
|
-
* only added/removed/content-changed paths. */
|
|
456
|
-
export function diffSnapshotCollections(before, after) {
|
|
457
|
-
return after.map((snapshot) => {
|
|
458
|
-
const baseline = before.find((item) => item.provider === snapshot.provider);
|
|
459
|
-
if (!baseline)
|
|
460
|
-
return snapshot;
|
|
461
|
-
return {
|
|
462
|
-
...snapshot,
|
|
463
|
-
files: diffProviderSnapshot(baseline, snapshot),
|
|
464
|
-
};
|
|
465
|
-
});
|
|
466
|
-
}
|
|
467
|
-
export function diffSnapshots(before, after) {
|
|
468
|
-
return extractFilesFromSnapshots(diffSnapshotCollections(before, after));
|
|
469
|
-
}
|
|
470
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
471
|
-
// v3.3.1: Backtrack auto-restore (engine.backtrack_auto_restore)
|
|
472
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
473
|
-
/** v3.3.1: Implement the v2.13 policy switch `engine.backtrack_auto_restore` —
|
|
474
|
-
* automatically restore the workspace after a backtrack:
|
|
475
|
-
* 1. `git stash push -u` — every uncommitted change (tracked + untracked)
|
|
476
|
-
* is preserved in a stash, never destroyed (an untracked file the agent
|
|
477
|
-
* created in the failed rounds is not silently deleted).
|
|
478
|
-
* 2. `git reset --hard <restoreHead>` — when the failed rounds created
|
|
479
|
-
* commits, discard them back to the clean round's commit (the v2.12
|
|
480
|
-
* restore-point HEAD).
|
|
481
|
-
*
|
|
482
|
-
* Runs only inside the workspace (cwd, shell: false, 30s timeout), and the
|
|
483
|
-
* caller gates it behind the policy flag — DANGEROUS by design, off by
|
|
484
|
-
* default. Failures are reported, never thrown: the verification gate still
|
|
485
|
-
* checks workspace cleanliness afterwards, and the backtrack prompt already
|
|
486
|
-
* instructs manual restore as the fallback. An empty workspace ("No local
|
|
487
|
-
* changes") is not a failure. */
|
|
488
|
-
export async function runBacktrackAutoRestore(gitHead, round,
|
|
489
|
-
/** v3.3.1: workspace root; injectable so tests can run against a temp
|
|
490
|
-
* repo. Defaults to the process cwd, matching the evidence providers. */
|
|
491
|
-
cwd = process.cwd()) {
|
|
492
|
-
const run = (args) => new Promise((resolvePromise) => {
|
|
493
|
-
execFile("git", args, {
|
|
494
|
-
cwd,
|
|
495
|
-
shell: false,
|
|
496
|
-
timeout: 30_000,
|
|
497
|
-
}, (error, stdout, stderr) => {
|
|
498
|
-
if (error) {
|
|
499
|
-
resolvePromise({ ok: false, out: String(stderr || error.message) });
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
resolvePromise({ ok: true, out: String(stdout) });
|
|
503
|
-
});
|
|
504
|
-
});
|
|
505
|
-
const steps = [];
|
|
506
|
-
const stash = await run(["stash", "push", "-u", "-m", `loopforge-backtrack-round-${round}`]);
|
|
507
|
-
const workspaceWasClean = stash.out.includes("No local changes");
|
|
508
|
-
steps.push(`stash: ${workspaceWasClean ? "clean (nothing to stash)" : stash.ok ? "ok" : `failed — ${stash.out.trim()}`}`);
|
|
509
|
-
if (gitHead) {
|
|
510
|
-
const reset = await run(["reset", "--hard", gitHead]);
|
|
511
|
-
steps.push(`reset --hard ${gitHead.slice(0, 12)}: ${reset.ok ? "ok" : `failed — ${reset.out.trim()}`}`);
|
|
512
|
-
return { ok: reset.ok, detail: steps.join("; ") };
|
|
513
|
-
}
|
|
514
|
-
return { ok: workspaceWasClean || stash.ok, detail: steps.join("; ") };
|
|
515
|
-
}
|
|
516
705
|
//# sourceMappingURL=evidence-provider.js.map
|