loopforge 3.7.1 → 3.8.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.
- package/README.md +84 -130
- 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 +52 -67
- package/dist/canonical-state.d.ts.map +1 -1
- package/dist/canonical-state.js +134 -147
- package/dist/canonical-state.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +103 -4
- 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 +28 -13
- package/dist/cognitive-facts.js.map +1 -1
- package/dist/committed-round.d.ts +103 -21
- package/dist/committed-round.d.ts.map +1 -1
- package/dist/committed-round.js +188 -45
- 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 +20 -2
- package/dist/enforcement-gate.d.ts.map +1 -1
- package/dist/enforcement-gate.js +197 -386
- package/dist/enforcement-gate.js.map +1 -1
- package/dist/engine.d.ts +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +53 -38
- 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 +85 -89
- package/dist/evidence-provider.d.ts.map +1 -1
- package/dist/evidence-provider.js +328 -184
- 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 +152 -16
- package/dist/generate-schema.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/loop-compiler.d.ts +40 -53
- package/dist/loop-compiler.d.ts.map +1 -1
- package/dist/loop-compiler.js +229 -553
- 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 +30 -21
- package/dist/mcp/round-lifecycle.d.ts.map +1 -1
- package/dist/mcp/round-lifecycle.js +47 -103
- package/dist/mcp/round-lifecycle.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +51 -7
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/session.d.ts +39 -23
- package/dist/mcp/session.d.ts.map +1 -1
- package/dist/mcp/session.js +148 -163
- 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 +249 -128
- 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 +55 -93
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +149 -38
- package/dist/policy.js.map +1 -1
- package/dist/prompt-assembler.d.ts +1 -32
- package/dist/prompt-assembler.d.ts.map +1 -1
- package/dist/prompt-assembler.js +282 -435
- 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 +365 -142
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +21 -66
- 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 +36 -16
- package/dist/round-coordinator.d.ts.map +1 -1
- package/dist/round-coordinator.js +166 -102
- package/dist/round-coordinator.js.map +1 -1
- package/dist/round-driver.d.ts +3 -5
- package/dist/round-driver.d.ts.map +1 -1
- package/dist/round-driver.js +0 -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 +19 -9
- package/dist/round-transaction.d.ts.map +1 -1
- package/dist/round-transaction.js +90 -22
- package/dist/round-transaction.js.map +1 -1
- package/dist/self-eval.d.ts +87 -9
- package/dist/self-eval.d.ts.map +1 -1
- package/dist/self-eval.js +396 -27
- 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 +14 -31
- package/dist/verification-gate.d.ts.map +1 -1
- package/dist/verification-gate.js +178 -496
- package/dist/verification-gate.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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 +59 -83
- package/package.json +67 -67
- package/skills/perception/SKILL.md +218 -204
|
@@ -1,19 +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
15
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
14
|
-
import { isRecord } from "./token-utils.js";
|
|
15
16
|
import { containInWorkspace } from "./workspace.js";
|
|
16
|
-
import { getPolicy } from "./policy.js";
|
|
17
|
+
import { commandConfigHash, deriveConfiguredCapability, getPolicy, resolveStateDirectory } from "./policy.js";
|
|
17
18
|
import { logEvent } from "./observability.js";
|
|
18
19
|
import { policyMetrics } from "./policy-metrics.js";
|
|
19
20
|
const providerFactories = new Map();
|
|
@@ -28,37 +29,172 @@ export function unregisterEvidenceProvider(name) {
|
|
|
28
29
|
return false;
|
|
29
30
|
return providerFactories.delete(name);
|
|
30
31
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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")
|
|
37
53
|
return false;
|
|
38
|
-
|
|
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;
|
|
39
116
|
}
|
|
40
|
-
/**
|
|
41
|
-
*
|
|
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.
|
|
42
121
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
}
|
|
147
|
+
// ── EvidenceCollector ──────────────────────────────────────────────────────
|
|
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).
|
|
180
|
+
*
|
|
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. */
|
|
48
185
|
export class EvidenceCollector {
|
|
49
186
|
providers;
|
|
50
187
|
constructor(providers) {
|
|
51
188
|
this.providers = providers;
|
|
52
189
|
}
|
|
53
|
-
/** Build the collector described by loop_policy.json.
|
|
54
|
-
*
|
|
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. */
|
|
55
195
|
static fromProviderNames(providerNames) {
|
|
56
|
-
const providers =
|
|
57
|
-
|
|
58
|
-
const factory = providerFactories.get(name);
|
|
59
|
-
if (factory)
|
|
60
|
-
providers.push(factory());
|
|
61
|
-
}
|
|
196
|
+
const providers = providerNames
|
|
197
|
+
.map((name) => providerFactories.get(name)?.() ?? unregisteredProvider(name));
|
|
62
198
|
return new EvidenceCollector(providers);
|
|
63
199
|
}
|
|
64
200
|
/** Build built-ins and explicitly configured command providers. */
|
|
@@ -75,13 +211,10 @@ export class EvidenceCollector {
|
|
|
75
211
|
}
|
|
76
212
|
return new EvidenceCollector(providers);
|
|
77
213
|
}
|
|
78
|
-
// v3.7: the synchronous collect() was removed with the sync lifecycle
|
|
79
|
-
// (prepareSync / captureGitFileState) — evidence collection is always
|
|
80
|
-
// async (collectAsync). It never worked for async providers anyway: they
|
|
81
|
-
// were silently skipped and recorded as failures.
|
|
82
214
|
/** Capture all providers concurrently with per-provider timeout isolation. */
|
|
83
215
|
async collectAsync(options = {}) {
|
|
84
216
|
const timeoutMs = options.timeoutMs ?? getPolicy().evidence.timeout_ms;
|
|
217
|
+
const phase = options.phase ?? "after";
|
|
85
218
|
const captures = this.providers.map(async (provider) => {
|
|
86
219
|
const startedAt = Date.now();
|
|
87
220
|
const controller = new AbortController();
|
|
@@ -92,46 +225,43 @@ export class EvidenceCollector {
|
|
|
92
225
|
signal: controller.signal,
|
|
93
226
|
timeoutMs,
|
|
94
227
|
loopId: options.loopId,
|
|
95
|
-
phase
|
|
228
|
+
phase,
|
|
96
229
|
}));
|
|
97
|
-
const
|
|
230
|
+
const observation = timeoutMs > 0
|
|
98
231
|
? await Promise.race([
|
|
99
232
|
capture,
|
|
100
|
-
new Promise((
|
|
233
|
+
new Promise((resolveRace) => {
|
|
101
234
|
timer = setTimeout(() => {
|
|
102
235
|
timedOut = true;
|
|
103
236
|
controller.abort(new Error(`Evidence provider timed out after ${timeoutMs}ms`));
|
|
104
|
-
|
|
237
|
+
resolveRace(null);
|
|
105
238
|
}, timeoutMs);
|
|
106
239
|
}),
|
|
107
240
|
])
|
|
108
241
|
: await capture;
|
|
109
|
-
const outcome = timedOut
|
|
110
|
-
? "timeout"
|
|
111
|
-
: snapshot
|
|
112
|
-
// L8: a command that hit its OWN deadline reports status
|
|
113
|
-
// "timeout" — count it as a timeout, not as available. Only the
|
|
114
|
-
// outer collector race previously produced "timeout", so the
|
|
115
|
-
// metric systematically undercounted command deadline hits.
|
|
116
|
-
? isTimedOutSnapshot(snapshot) ? "timeout" : "available"
|
|
117
|
-
: "unavailable";
|
|
118
|
-
policyMetrics.recordEvidence(provider.name, outcome, Date.now() - startedAt, options.loopId);
|
|
119
242
|
if (timedOut) {
|
|
243
|
+
policyMetrics.recordEvidence(provider.name, "timeout", Date.now() - startedAt, options.loopId);
|
|
120
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");
|
|
121
250
|
}
|
|
122
|
-
|
|
251
|
+
policyMetrics.recordEvidence(provider.name, observation.status === "timeout" ? "timeout" : "available", Date.now() - startedAt, options.loopId);
|
|
252
|
+
return observation;
|
|
123
253
|
}
|
|
124
254
|
catch (error) {
|
|
125
255
|
policyMetrics.recordEvidence(provider.name, "failure", Date.now() - startedAt, options.loopId);
|
|
126
256
|
logEvent("evidence_provider_error", { provider: provider.name, error: String(error) });
|
|
127
|
-
return
|
|
257
|
+
return syntheticObservation(provider.name, provider.kind, phase, startedAt, "error", String(error));
|
|
128
258
|
}
|
|
129
259
|
finally {
|
|
130
260
|
if (timer)
|
|
131
261
|
clearTimeout(timer);
|
|
132
262
|
}
|
|
133
263
|
});
|
|
134
|
-
return
|
|
264
|
+
return await Promise.all(captures);
|
|
135
265
|
}
|
|
136
266
|
}
|
|
137
267
|
function commandCwd(configured) {
|
|
@@ -152,14 +282,40 @@ function commandCwd(configured) {
|
|
|
152
282
|
* paths are skipped. Returns forward-slash paths (git convention) so the
|
|
153
283
|
* gate can intersect them with the round's git diff. Empty result = the
|
|
154
284
|
* command's entrypoint cannot be observed (external command) — fail open. */
|
|
155
|
-
|
|
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) {
|
|
156
305
|
// v3.3.1: entrypoint containment uses the shared workspace check.
|
|
157
306
|
const workspace = process.cwd();
|
|
158
307
|
const candidates = [...args];
|
|
159
308
|
if (executable.includes("/") || executable.includes("\\")) {
|
|
160
309
|
candidates.unshift(executable);
|
|
161
310
|
}
|
|
162
|
-
|
|
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");
|
|
163
319
|
const found = [];
|
|
164
320
|
for (const candidate of candidates) {
|
|
165
321
|
try {
|
|
@@ -180,7 +336,9 @@ function resolveEntrypointFiles(executable, args, cwd) {
|
|
|
180
336
|
/** Explicit, shell-free verification command. Disabled unless configured. */
|
|
181
337
|
export class CommandEvidenceProvider {
|
|
182
338
|
name;
|
|
339
|
+
kind = "command";
|
|
183
340
|
config;
|
|
341
|
+
configHash;
|
|
184
342
|
constructor(config) {
|
|
185
343
|
const name = typeof config.name === "string" && config.name.trim()
|
|
186
344
|
? config.name.trim()
|
|
@@ -207,6 +365,7 @@ export class CommandEvidenceProvider {
|
|
|
207
365
|
: 20_000,
|
|
208
366
|
success_exit_codes: successExitCodes,
|
|
209
367
|
};
|
|
368
|
+
this.configHash = commandConfigHash(this.config);
|
|
210
369
|
this.name = `command:${name}`;
|
|
211
370
|
}
|
|
212
371
|
capture(context) {
|
|
@@ -215,25 +374,24 @@ export class CommandEvidenceProvider {
|
|
|
215
374
|
return Promise.resolve(null);
|
|
216
375
|
}
|
|
217
376
|
const startedAt = Date.now();
|
|
218
|
-
// L8: honor the CONFIGURED output cap. The old Math.min(20_000, ...)
|
|
219
|
-
// silently clamped every larger configured value — a policy that says
|
|
220
|
-
// max_output_chars: 50000 quietly retained only 20k. (The constructor
|
|
221
|
-
// already defaults the cap to 20_000 when unset.)
|
|
222
377
|
const cap = Math.max(0, this.config.max_output_chars);
|
|
223
378
|
const timeoutMs = Math.max(1, Math.min(this.config.timeout_ms, context?.timeoutMs || this.config.timeout_ms));
|
|
224
379
|
let cwd;
|
|
225
380
|
if (!this.config.executable) {
|
|
226
|
-
return Promise.resolve(this.
|
|
381
|
+
return Promise.resolve(this.observation(phase, "error", null, null, "", "", false, 0, [], "command executable is empty"));
|
|
227
382
|
}
|
|
228
383
|
try {
|
|
229
384
|
cwd = commandCwd(this.config.cwd);
|
|
230
385
|
}
|
|
231
386
|
catch (error) {
|
|
232
|
-
return Promise.resolve(this.
|
|
387
|
+
return Promise.resolve(this.observation(phase, "error", null, null, "", "", false, Date.now() - startedAt, [], String(error)));
|
|
233
388
|
}
|
|
234
389
|
// v3.3: Workspace entrypoint files for tamper detection at the gate.
|
|
235
390
|
const entrypointFiles = resolveEntrypointFiles(this.config.executable, this.config.args, cwd);
|
|
236
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");
|
|
237
395
|
let stdout = "";
|
|
238
396
|
let stderr = "";
|
|
239
397
|
let truncated = false;
|
|
@@ -241,9 +399,10 @@ export class CommandEvidenceProvider {
|
|
|
241
399
|
let settled = false;
|
|
242
400
|
const append = (current, chunk) => {
|
|
243
401
|
const text = String(chunk);
|
|
402
|
+
if (text.length === 0)
|
|
403
|
+
return current;
|
|
244
404
|
if (retained >= cap) {
|
|
245
|
-
|
|
246
|
-
truncated = true;
|
|
405
|
+
truncated = true;
|
|
247
406
|
return current;
|
|
248
407
|
}
|
|
249
408
|
const remaining = cap - retained;
|
|
@@ -260,24 +419,31 @@ export class CommandEvidenceProvider {
|
|
|
260
419
|
stdio: ["ignore", "pipe", "pipe"],
|
|
261
420
|
});
|
|
262
421
|
let timer;
|
|
263
|
-
const finish = (status, exitCode, signal) => {
|
|
422
|
+
const finish = (status, exitCode, signal, failureDetail) => {
|
|
264
423
|
if (settled)
|
|
265
424
|
return;
|
|
266
425
|
settled = true;
|
|
267
426
|
if (timer)
|
|
268
427
|
clearTimeout(timer);
|
|
269
428
|
context?.signal.removeEventListener("abort", abort);
|
|
270
|
-
resolveCapture(this.
|
|
429
|
+
resolveCapture(this.observation(phase, status, exitCode, signal, stdout, stderr, truncated, Date.now() - startedAt, entrypointFiles, failureDetail, stdoutHash.digest("hex"), stderrHash.digest("hex")));
|
|
271
430
|
};
|
|
272
431
|
const abort = () => {
|
|
273
432
|
child.kill();
|
|
274
|
-
finish("aborted", null, null);
|
|
433
|
+
finish("aborted", null, null, "aborted by the collector deadline");
|
|
275
434
|
};
|
|
276
|
-
child.stdout.on("data", (chunk) => {
|
|
277
|
-
|
|
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
|
+
});
|
|
278
443
|
child.once("error", (error) => {
|
|
444
|
+
stderrHash.update(error.message);
|
|
279
445
|
stderr = append(stderr, error.message);
|
|
280
|
-
finish(error.code === "ENOENT" ? "
|
|
446
|
+
finish(error.code === "ENOENT" ? "unavailable" : "error", null, null, error.code ?? error.message);
|
|
281
447
|
});
|
|
282
448
|
child.once("close", (code, signal) => {
|
|
283
449
|
const passed = code !== null && this.config.success_exit_codes.includes(code);
|
|
@@ -285,7 +451,7 @@ export class CommandEvidenceProvider {
|
|
|
285
451
|
});
|
|
286
452
|
timer = setTimeout(() => {
|
|
287
453
|
child.kill();
|
|
288
|
-
finish("timeout", null, null);
|
|
454
|
+
finish("timeout", null, null, `command exceeded ${timeoutMs}ms`);
|
|
289
455
|
}, timeoutMs);
|
|
290
456
|
timer.unref?.();
|
|
291
457
|
if (context?.signal.aborted)
|
|
@@ -294,22 +460,31 @@ export class CommandEvidenceProvider {
|
|
|
294
460
|
context?.signal.addEventListener("abort", abort, { once: true });
|
|
295
461
|
});
|
|
296
462
|
}
|
|
297
|
-
|
|
463
|
+
observation(phase, status, exitCode, signal, stdout, stderr, truncated, durationMs, entrypointFiles, failureDetail, stdoutSha256, stderrSha256) {
|
|
464
|
+
const finishedAt = Date.now();
|
|
298
465
|
return {
|
|
299
|
-
|
|
300
|
-
|
|
466
|
+
schemaVersion: 1,
|
|
467
|
+
providerId: this.name,
|
|
468
|
+
kind: "command",
|
|
469
|
+
phase,
|
|
470
|
+
startedAt: finishedAt - durationMs,
|
|
471
|
+
finishedAt,
|
|
472
|
+
status,
|
|
301
473
|
files: [],
|
|
302
474
|
data: {
|
|
303
|
-
|
|
304
|
-
|
|
475
|
+
commandId: this.config.name,
|
|
476
|
+
argv: [this.config.executable, ...this.config.args],
|
|
477
|
+
cwd: this.config.cwd ?? ".",
|
|
478
|
+
configHash: this.configHash,
|
|
305
479
|
required: this.config.required,
|
|
306
|
-
phase,
|
|
307
|
-
status,
|
|
308
480
|
exitCode,
|
|
309
481
|
signal,
|
|
310
482
|
durationMs,
|
|
311
|
-
|
|
312
|
-
|
|
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,
|
|
313
488
|
truncated,
|
|
314
489
|
entrypointFiles,
|
|
315
490
|
},
|
|
@@ -334,7 +509,7 @@ export async function captureGitFileStateAsync(signal, timeoutMs,
|
|
|
334
509
|
cwd = process.cwd()) {
|
|
335
510
|
const timeout = timeoutMs ?? 15000;
|
|
336
511
|
const runGit = (args) => {
|
|
337
|
-
return new Promise((
|
|
512
|
+
return new Promise((resolveGit, reject) => {
|
|
338
513
|
// M1: core.quotePath=false — git otherwise octal-escapes every path
|
|
339
514
|
// with bytes >= 0x80 (Chinese and other non-ASCII filenames become
|
|
340
515
|
// "\346\226\207..."). Escaped names can't be stat/hash'd, so content
|
|
@@ -352,7 +527,7 @@ cwd = process.cwd()) {
|
|
|
352
527
|
});
|
|
353
528
|
child.on("close", (code) => {
|
|
354
529
|
code === 0
|
|
355
|
-
?
|
|
530
|
+
? resolveGit(stdout)
|
|
356
531
|
: reject(new Error(`git ${args[0]} exited ${code}`));
|
|
357
532
|
});
|
|
358
533
|
child.on("error", reject);
|
|
@@ -385,12 +560,37 @@ cwd = process.cwd()) {
|
|
|
385
560
|
}
|
|
386
561
|
}
|
|
387
562
|
// ── Built-in: GitEvidenceProvider ──────────────────────────────────────────
|
|
388
|
-
/**
|
|
389
|
-
*
|
|
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
|
+
}
|
|
390
580
|
export class GitEvidenceProvider {
|
|
391
581
|
name = "git";
|
|
582
|
+
kind = "git";
|
|
392
583
|
capture(context) {
|
|
393
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);
|
|
394
594
|
return captureGitFileStateAsync(context?.signal, context?.timeoutMs, workspace).then((state) => {
|
|
395
595
|
if (!state)
|
|
396
596
|
return null;
|
|
@@ -398,7 +598,7 @@ export class GitEvidenceProvider {
|
|
|
398
598
|
...state.tracked,
|
|
399
599
|
...state.staged,
|
|
400
600
|
...state.untracked,
|
|
401
|
-
])].sort();
|
|
601
|
+
])].filter((file) => !isRuntimeState(file)).sort();
|
|
402
602
|
// v3.7.1: paths from git are workspace-relative; the capture runs with
|
|
403
603
|
// the workspace as cwd, so relative stat/read resolve there naturally.
|
|
404
604
|
// An injectable cwd (tests) must resolve explicitly.
|
|
@@ -411,21 +611,25 @@ export class GitEvidenceProvider {
|
|
|
411
611
|
fingerprints[file] = `${stat.mode}:${hash}`;
|
|
412
612
|
}
|
|
413
613
|
catch {
|
|
414
|
-
// Deleted files are evidence too.
|
|
614
|
+
// Deleted files are evidence too. A stable sentinel lets the diff
|
|
415
615
|
// distinguish deleted/restored transitions across a round.
|
|
416
616
|
fingerprints[file] = "missing";
|
|
417
617
|
}
|
|
418
618
|
}
|
|
419
619
|
return {
|
|
420
|
-
|
|
421
|
-
|
|
620
|
+
schemaVersion: 1,
|
|
621
|
+
providerId: "git",
|
|
622
|
+
kind: "git",
|
|
623
|
+
phase: context?.phase ?? "after",
|
|
624
|
+
startedAt,
|
|
625
|
+
finishedAt: Date.now(),
|
|
626
|
+
status: "observed",
|
|
422
627
|
files,
|
|
423
628
|
data: {
|
|
424
|
-
tracked: state.tracked,
|
|
425
|
-
staged: state.staged,
|
|
426
|
-
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)),
|
|
427
632
|
fingerprints,
|
|
428
|
-
// v2.13: HEAD commit hash for backtrack workspace restore
|
|
429
633
|
head: state.head,
|
|
430
634
|
},
|
|
431
635
|
};
|
|
@@ -434,31 +638,47 @@ export class GitEvidenceProvider {
|
|
|
434
638
|
}
|
|
435
639
|
registerEvidenceProvider("git", () => new GitEvidenceProvider());
|
|
436
640
|
// ── Utility ────────────────────────────────────────────────────────────────
|
|
437
|
-
/** Extract merged file list from
|
|
438
|
-
*
|
|
641
|
+
/** Extract merged file list from observations for backward compat with
|
|
642
|
+
* runtimeFilesChanged (string[] | null).
|
|
439
643
|
*
|
|
440
|
-
* Looks for the "git" provider first; falls back to merging all
|
|
441
|
-
*
|
|
442
|
-
export function extractFilesFromSnapshots(
|
|
443
|
-
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)
|
|
444
648
|
return null;
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
return [...gitSnapshot.files].sort();
|
|
449
|
-
// Fallback: merge all providers
|
|
649
|
+
const gitObservation = observations.find((item) => item.providerId === "git");
|
|
650
|
+
if (gitObservation)
|
|
651
|
+
return [...gitObservation.files].sort();
|
|
450
652
|
const allFiles = new Set();
|
|
451
|
-
for (const
|
|
452
|
-
for (const
|
|
453
|
-
allFiles.add(
|
|
653
|
+
for (const item of observations) {
|
|
654
|
+
for (const file of item.files)
|
|
655
|
+
allFiles.add(file);
|
|
454
656
|
}
|
|
455
657
|
return [...allFiles].sort();
|
|
456
658
|
}
|
|
457
|
-
/**
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
|
|
461
|
-
|
|
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;
|
|
462
682
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
463
683
|
return null;
|
|
464
684
|
const fingerprints = {};
|
|
@@ -482,80 +702,4 @@ function diffProviderSnapshot(before, after) {
|
|
|
482
702
|
return beforeFingerprints[file] !== afterFingerprints[file];
|
|
483
703
|
}).sort();
|
|
484
704
|
}
|
|
485
|
-
/** Return provider snapshots narrowed to evidence produced during this round.
|
|
486
|
-
* The full provider payload remains available in data, while files contains
|
|
487
|
-
* only added/removed/content-changed paths. */
|
|
488
|
-
export function diffSnapshotCollections(before, after) {
|
|
489
|
-
return after.map((snapshot) => {
|
|
490
|
-
const baseline = before.find((item) => item.provider === snapshot.provider);
|
|
491
|
-
if (!baseline)
|
|
492
|
-
return snapshot;
|
|
493
|
-
return {
|
|
494
|
-
...snapshot,
|
|
495
|
-
files: diffProviderSnapshot(baseline, snapshot),
|
|
496
|
-
};
|
|
497
|
-
});
|
|
498
|
-
}
|
|
499
|
-
export function diffSnapshots(before, after) {
|
|
500
|
-
return extractFilesFromSnapshots(diffSnapshotCollections(before, after));
|
|
501
|
-
}
|
|
502
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
503
|
-
// v3.3.1: Backtrack auto-restore (engine.backtrack_auto_restore)
|
|
504
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
505
|
-
/** v3.3.1: Implement the v2.13 policy switch `engine.backtrack_auto_restore` —
|
|
506
|
-
* automatically restore the workspace after a backtrack:
|
|
507
|
-
* 1. `git stash push -u` — every uncommitted change (tracked + untracked)
|
|
508
|
-
* is preserved in a stash, never destroyed (an untracked file the agent
|
|
509
|
-
* created in the failed rounds is not silently deleted).
|
|
510
|
-
* 2. `git reset --hard <restoreHead>` — when the failed rounds created
|
|
511
|
-
* commits, discard them back to the clean round's commit (the v2.12
|
|
512
|
-
* restore-point HEAD).
|
|
513
|
-
*
|
|
514
|
-
* Runs only inside the workspace (cwd, shell: false, 30s timeout), and the
|
|
515
|
-
* caller gates it behind the policy flag — DANGEROUS by design, off by
|
|
516
|
-
* default. Failures are reported, never thrown: the verification gate still
|
|
517
|
-
* checks workspace cleanliness afterwards, and the backtrack prompt already
|
|
518
|
-
* instructs manual restore as the fallback. An empty workspace ("No local
|
|
519
|
-
* changes") is not a failure. */
|
|
520
|
-
export async function runBacktrackAutoRestore(gitHead, round,
|
|
521
|
-
/** v3.3.1: workspace root; injectable so tests can run against a temp
|
|
522
|
-
* repo. Defaults to the process cwd, matching the evidence providers. */
|
|
523
|
-
cwd = process.cwd()) {
|
|
524
|
-
const run = (args) => new Promise((resolvePromise) => {
|
|
525
|
-
execFile("git", args, {
|
|
526
|
-
cwd,
|
|
527
|
-
shell: false,
|
|
528
|
-
timeout: 30_000,
|
|
529
|
-
}, (error, stdout, stderr) => {
|
|
530
|
-
if (error) {
|
|
531
|
-
resolvePromise({ ok: false, out: String(stderr || error.message) });
|
|
532
|
-
return;
|
|
533
|
-
}
|
|
534
|
-
resolvePromise({ ok: true, out: String(stdout) });
|
|
535
|
-
});
|
|
536
|
-
});
|
|
537
|
-
const steps = [];
|
|
538
|
-
const stash = await run(["stash", "push", "-u", "-m", `loopforge-backtrack-round-${round}`]);
|
|
539
|
-
const workspaceWasClean = stash.out.includes("No local changes");
|
|
540
|
-
steps.push(`stash: ${workspaceWasClean ? "clean (nothing to stash)" : stash.ok ? "ok" : `failed — ${stash.out.trim()}`}`);
|
|
541
|
-
// L6 (v3.7.x): a failed stash must abort the restore. The contract above
|
|
542
|
-
// says every uncommitted change is preserved in a stash, NEVER destroyed —
|
|
543
|
-
// `reset --hard` after a stash failure would destroy exactly the changes
|
|
544
|
-
// the stash was meant to save (failed-round work AND any unrelated user
|
|
545
|
-
// edits). The verification gate then reports the workspace as unrestored
|
|
546
|
-
// and the backtrack prompt's manual-restore instructions take over.
|
|
547
|
-
if (!workspaceWasClean && !stash.ok) {
|
|
548
|
-
return {
|
|
549
|
-
ok: false,
|
|
550
|
-
detail: steps.join("; ") +
|
|
551
|
-
"; reset --hard SKIPPED — uncommitted changes would be destroyed",
|
|
552
|
-
};
|
|
553
|
-
}
|
|
554
|
-
if (gitHead) {
|
|
555
|
-
const reset = await run(["reset", "--hard", gitHead]);
|
|
556
|
-
steps.push(`reset --hard ${gitHead.slice(0, 12)}: ${reset.ok ? "ok" : `failed — ${reset.out.trim()}`}`);
|
|
557
|
-
return { ok: reset.ok, detail: steps.join("; ") };
|
|
558
|
-
}
|
|
559
|
-
return { ok: workspaceWasClean || stash.ok, detail: steps.join("; ") };
|
|
560
|
-
}
|
|
561
705
|
//# sourceMappingURL=evidence-provider.js.map
|