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.
Files changed (151) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +171 -157
  3. package/dist/audit.d.ts +53 -1
  4. package/dist/audit.d.ts.map +1 -1
  5. package/dist/audit.js +106 -22
  6. package/dist/audit.js.map +1 -1
  7. package/dist/canonical-state.d.ts +75 -69
  8. package/dist/canonical-state.d.ts.map +1 -1
  9. package/dist/canonical-state.js +276 -168
  10. package/dist/canonical-state.js.map +1 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +111 -12
  13. package/dist/cli.js.map +1 -1
  14. package/dist/cognitive-facts.d.ts +13 -0
  15. package/dist/cognitive-facts.d.ts.map +1 -1
  16. package/dist/cognitive-facts.js +30 -16
  17. package/dist/cognitive-facts.js.map +1 -1
  18. package/dist/cognitive-governance.d.ts +30 -6
  19. package/dist/cognitive-governance.d.ts.map +1 -1
  20. package/dist/cognitive-governance.js +74 -18
  21. package/dist/cognitive-governance.js.map +1 -1
  22. package/dist/committed-round.d.ts +109 -24
  23. package/dist/committed-round.d.ts.map +1 -1
  24. package/dist/committed-round.js +235 -52
  25. package/dist/committed-round.js.map +1 -1
  26. package/dist/contract-items.d.ts +55 -0
  27. package/dist/contract-items.d.ts.map +1 -0
  28. package/dist/contract-items.js +180 -0
  29. package/dist/contract-items.js.map +1 -0
  30. package/dist/enforcement-gate.d.ts +43 -20
  31. package/dist/enforcement-gate.d.ts.map +1 -1
  32. package/dist/enforcement-gate.js +269 -378
  33. package/dist/enforcement-gate.js.map +1 -1
  34. package/dist/engine.d.ts +11 -6
  35. package/dist/engine.d.ts.map +1 -1
  36. package/dist/engine.js +102 -67
  37. package/dist/engine.js.map +1 -1
  38. package/dist/evidence-claims.d.ts +4 -4
  39. package/dist/evidence-claims.d.ts.map +1 -1
  40. package/dist/evidence-claims.js +16 -14
  41. package/dist/evidence-claims.js.map +1 -1
  42. package/dist/evidence-provider.d.ts +91 -90
  43. package/dist/evidence-provider.d.ts.map +1 -1
  44. package/dist/evidence-provider.js +349 -160
  45. package/dist/evidence-provider.js.map +1 -1
  46. package/dist/explain.d.ts +76 -0
  47. package/dist/explain.d.ts.map +1 -0
  48. package/dist/explain.js +127 -0
  49. package/dist/explain.js.map +1 -0
  50. package/dist/generate-schema.js +250 -53
  51. package/dist/generate-schema.js.map +1 -1
  52. package/dist/index.d.ts +8 -7
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +7 -6
  55. package/dist/index.js.map +1 -1
  56. package/dist/loop-compiler.d.ts +41 -40
  57. package/dist/loop-compiler.d.ts.map +1 -1
  58. package/dist/loop-compiler.js +275 -515
  59. package/dist/loop-compiler.js.map +1 -1
  60. package/dist/loop-extras-parser.d.ts +0 -1
  61. package/dist/loop-extras-parser.d.ts.map +1 -1
  62. package/dist/loop-extras-parser.js +0 -4
  63. package/dist/loop-extras-parser.js.map +1 -1
  64. package/dist/loop-projection.d.ts.map +1 -1
  65. package/dist/loop-projection.js +3 -1
  66. package/dist/loop-projection.js.map +1 -1
  67. package/dist/mcp/round-lifecycle.d.ts +40 -21
  68. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  69. package/dist/mcp/round-lifecycle.js +173 -120
  70. package/dist/mcp/round-lifecycle.js.map +1 -1
  71. package/dist/mcp/server.d.ts.map +1 -1
  72. package/dist/mcp/server.js +63 -10
  73. package/dist/mcp/server.js.map +1 -1
  74. package/dist/mcp/session.d.ts +53 -7
  75. package/dist/mcp/session.d.ts.map +1 -1
  76. package/dist/mcp/session.js +240 -139
  77. package/dist/mcp/session.js.map +1 -1
  78. package/dist/mcp/submission-boundary.d.ts +89 -0
  79. package/dist/mcp/submission-boundary.d.ts.map +1 -0
  80. package/dist/mcp/submission-boundary.js +203 -0
  81. package/dist/mcp/submission-boundary.js.map +1 -0
  82. package/dist/mcp/tools.d.ts.map +1 -1
  83. package/dist/mcp/tools.js +377 -125
  84. package/dist/mcp/tools.js.map +1 -1
  85. package/dist/policy-metrics.js +2 -2
  86. package/dist/policy-metrics.js.map +1 -1
  87. package/dist/policy.d.ts +65 -95
  88. package/dist/policy.d.ts.map +1 -1
  89. package/dist/policy.js +151 -39
  90. package/dist/policy.js.map +1 -1
  91. package/dist/prompt-assembler.d.ts +7 -33
  92. package/dist/prompt-assembler.d.ts.map +1 -1
  93. package/dist/prompt-assembler.js +313 -473
  94. package/dist/prompt-assembler.js.map +1 -1
  95. package/dist/prompt-policy.d.ts +1 -4
  96. package/dist/prompt-policy.d.ts.map +1 -1
  97. package/dist/prompt-policy.js +6 -7
  98. package/dist/prompt-policy.js.map +1 -1
  99. package/dist/protocol.d.ts +411 -168
  100. package/dist/protocol.d.ts.map +1 -1
  101. package/dist/protocol.js +23 -69
  102. package/dist/protocol.js.map +1 -1
  103. package/dist/replay.js +2 -2
  104. package/dist/replay.js.map +1 -1
  105. package/dist/round-contract.d.ts +69 -57
  106. package/dist/round-contract.d.ts.map +1 -1
  107. package/dist/round-contract.js +104 -64
  108. package/dist/round-contract.js.map +1 -1
  109. package/dist/round-coordinator.d.ts +55 -16
  110. package/dist/round-coordinator.d.ts.map +1 -1
  111. package/dist/round-coordinator.js +179 -57
  112. package/dist/round-coordinator.js.map +1 -1
  113. package/dist/round-driver.d.ts +7 -5
  114. package/dist/round-driver.d.ts.map +1 -1
  115. package/dist/round-driver.js +2 -1
  116. package/dist/round-driver.js.map +1 -1
  117. package/dist/round-facts.d.ts +96 -0
  118. package/dist/round-facts.d.ts.map +1 -0
  119. package/dist/round-facts.js +216 -0
  120. package/dist/round-facts.js.map +1 -0
  121. package/dist/round-transaction.d.ts +23 -9
  122. package/dist/round-transaction.d.ts.map +1 -1
  123. package/dist/round-transaction.js +99 -22
  124. package/dist/round-transaction.js.map +1 -1
  125. package/dist/self-eval.d.ts +127 -11
  126. package/dist/self-eval.d.ts.map +1 -1
  127. package/dist/self-eval.js +517 -36
  128. package/dist/self-eval.js.map +1 -1
  129. package/dist/subgoal-state.d.ts +77 -0
  130. package/dist/subgoal-state.d.ts.map +1 -0
  131. package/dist/subgoal-state.js +198 -0
  132. package/dist/subgoal-state.js.map +1 -0
  133. package/dist/token-utils.d.ts +52 -19
  134. package/dist/token-utils.d.ts.map +1 -1
  135. package/dist/token-utils.js +83 -72
  136. package/dist/token-utils.js.map +1 -1
  137. package/dist/verification-gate.d.ts +27 -46
  138. package/dist/verification-gate.d.ts.map +1 -1
  139. package/dist/verification-gate.js +308 -557
  140. package/dist/verification-gate.js.map +1 -1
  141. package/dist/version.d.ts +5 -0
  142. package/dist/version.d.ts.map +1 -0
  143. package/dist/version.js +5 -0
  144. package/dist/version.js.map +1 -0
  145. package/dist/workspace.d.ts +9 -0
  146. package/dist/workspace.d.ts.map +1 -1
  147. package/dist/workspace.js +17 -0
  148. package/dist/workspace.js.map +1 -1
  149. package/loop_policy.json +17 -25
  150. package/package.json +7 -7
  151. package/skills/perception/SKILL.md +266 -64
@@ -1,18 +1,20 @@
1
- /** EvidenceProvider — Pluggable evidence capture interface (v1.18).
1
+ /** EvidenceProvider — Pluggable machine-observation capture (v1.18 / v3.8).
2
2
  *
3
- * This module defines an abstract EvidenceProvider interface so
4
- * additional evidence sources (test runners, linters, bundle analysis)
5
- * can be added without touching the verification pipeline.
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 provider: GitEvidenceProvider — git file state capture with
8
- * parallel async execution (v2.0.1) and a synchronous fallback.
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
- /** Collects evidence from all configured providers (always async — the
32
- * synchronous collect() was removed in v3.7).
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
- * Usage:
35
- * const collector = new EvidenceCollector([new GitEvidenceProvider()]);
36
- * const snapshots = await collector.collectAsync({ phase: "before" });
37
- * // snapshots = [{ provider: "git", files: [...], data: {...} }]
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. Unknown provider
45
- * names are ignored so newer configs remain backward compatible. */
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
- for (const name of providerNames) {
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: options.phase ?? "after",
228
+ phase,
87
229
  }));
88
- const snapshot = timeoutMs > 0
230
+ const observation = timeoutMs > 0
89
231
  ? await Promise.race([
90
232
  capture,
91
- new Promise((resolve) => {
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
- resolve(null);
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
- return timedOut ? null : snapshot;
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 null;
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 (await Promise.all(captures)).filter((snapshot) => snapshot !== null);
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
- function resolveEntrypointFiles(executable, args, cwd) {
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
- candidates.push("package.json");
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, Math.min(20_000, this.config.max_output_chars));
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.snapshot(phase, "missing", null, null, "", "Command executable is empty", false, 0, []));
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.snapshot(phase, "invalid_cwd", null, null, "", String(error), false, Date.now() - startedAt, []));
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
- if (text.length > 0)
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.snapshot(phase, status, exitCode, signal, stdout, stderr, truncated, Date.now() - startedAt, entrypointFiles));
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) => { stdout = append(stdout, chunk); });
258
- child.stderr.on("data", (chunk) => { stderr = append(stderr, chunk); });
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" ? "missing" : "failed", null, null);
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
- snapshot(phase, status, exitCode, signal, stdout, stderr, truncated, durationMs, entrypointFiles) {
463
+ observation(phase, status, exitCode, signal, stdout, stderr, truncated, durationMs, entrypointFiles, failureDetail, stdoutSha256, stderrSha256) {
464
+ const finishedAt = Date.now();
279
465
  return {
280
- provider: this.name,
281
- timestamp: Date.now(),
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
- kind: "command",
285
- commandName: this.config.name,
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
- stdout,
293
- stderr,
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((resolve, reject) => {
316
- const child = execFile("git", [...args], {
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
- ? resolve(stdout)
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
- /** v3.7: async capture only — the synchronous captureGitFileState() fallback
362
- * was removed together with the sync lifecycle (prepareSync). */
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
- return captureGitFileStateAsync(context?.signal, context?.timeoutMs).then((state) => {
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 stat = statSync(file);
378
- const hash = createHash("sha256").update(readFileSync(file)).digest("hex");
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. A stable sentinel lets the diff
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
- provider: "git",
389
- timestamp: Date.now(),
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 evidence snapshots for backward compat
406
- * with runtimeFilesChanged (string[] | null).
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
- * providers' files arrays (deduplicated). */
410
- export function extractFilesFromSnapshots(snapshots) {
411
- if (snapshots.length === 0)
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
- // Prefer the git provider for backward compat
414
- const gitSnapshot = snapshots.find((s) => s.provider === "git");
415
- if (gitSnapshot)
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 s of snapshots) {
420
- for (const f of s.files)
421
- allFiles.add(f);
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
- /** Compute a diff between two evidence collections (before → after).
426
- * Returns files that appeared in the after-snapshot but not the before.
427
- * Used by runtime.ts to compute runtimeFilesChanged. */
428
- function snapshotFingerprints(snapshot) {
429
- const value = snapshot.data.fingerprints;
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