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.
Files changed (144) hide show
  1. package/README.md +84 -130
  2. package/dist/audit.d.ts +53 -1
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +106 -22
  5. package/dist/audit.js.map +1 -1
  6. package/dist/canonical-state.d.ts +52 -67
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +134 -147
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +103 -4
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-facts.d.ts +13 -0
  14. package/dist/cognitive-facts.d.ts.map +1 -1
  15. package/dist/cognitive-facts.js +28 -13
  16. package/dist/cognitive-facts.js.map +1 -1
  17. package/dist/committed-round.d.ts +103 -21
  18. package/dist/committed-round.d.ts.map +1 -1
  19. package/dist/committed-round.js +188 -45
  20. package/dist/committed-round.js.map +1 -1
  21. package/dist/contract-items.d.ts +55 -0
  22. package/dist/contract-items.d.ts.map +1 -0
  23. package/dist/contract-items.js +180 -0
  24. package/dist/contract-items.js.map +1 -0
  25. package/dist/enforcement-gate.d.ts +20 -2
  26. package/dist/enforcement-gate.d.ts.map +1 -1
  27. package/dist/enforcement-gate.js +197 -386
  28. package/dist/enforcement-gate.js.map +1 -1
  29. package/dist/engine.d.ts +1 -1
  30. package/dist/engine.d.ts.map +1 -1
  31. package/dist/engine.js +53 -38
  32. package/dist/engine.js.map +1 -1
  33. package/dist/evidence-claims.d.ts +4 -4
  34. package/dist/evidence-claims.d.ts.map +1 -1
  35. package/dist/evidence-claims.js +16 -14
  36. package/dist/evidence-claims.js.map +1 -1
  37. package/dist/evidence-provider.d.ts +85 -89
  38. package/dist/evidence-provider.d.ts.map +1 -1
  39. package/dist/evidence-provider.js +328 -184
  40. package/dist/evidence-provider.js.map +1 -1
  41. package/dist/explain.d.ts +76 -0
  42. package/dist/explain.d.ts.map +1 -0
  43. package/dist/explain.js +127 -0
  44. package/dist/explain.js.map +1 -0
  45. package/dist/generate-schema.js +152 -16
  46. package/dist/generate-schema.js.map +1 -1
  47. package/dist/index.d.ts +6 -6
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +5 -5
  50. package/dist/index.js.map +1 -1
  51. package/dist/loop-compiler.d.ts +40 -53
  52. package/dist/loop-compiler.d.ts.map +1 -1
  53. package/dist/loop-compiler.js +229 -553
  54. package/dist/loop-compiler.js.map +1 -1
  55. package/dist/loop-extras-parser.d.ts +0 -1
  56. package/dist/loop-extras-parser.d.ts.map +1 -1
  57. package/dist/loop-extras-parser.js +0 -4
  58. package/dist/loop-extras-parser.js.map +1 -1
  59. package/dist/loop-projection.d.ts.map +1 -1
  60. package/dist/loop-projection.js +3 -1
  61. package/dist/loop-projection.js.map +1 -1
  62. package/dist/mcp/round-lifecycle.d.ts +30 -21
  63. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  64. package/dist/mcp/round-lifecycle.js +47 -103
  65. package/dist/mcp/round-lifecycle.js.map +1 -1
  66. package/dist/mcp/server.d.ts.map +1 -1
  67. package/dist/mcp/server.js +51 -7
  68. package/dist/mcp/server.js.map +1 -1
  69. package/dist/mcp/session.d.ts +39 -23
  70. package/dist/mcp/session.d.ts.map +1 -1
  71. package/dist/mcp/session.js +148 -163
  72. package/dist/mcp/session.js.map +1 -1
  73. package/dist/mcp/submission-boundary.d.ts +89 -0
  74. package/dist/mcp/submission-boundary.d.ts.map +1 -0
  75. package/dist/mcp/submission-boundary.js +203 -0
  76. package/dist/mcp/submission-boundary.js.map +1 -0
  77. package/dist/mcp/tools.d.ts.map +1 -1
  78. package/dist/mcp/tools.js +249 -128
  79. package/dist/mcp/tools.js.map +1 -1
  80. package/dist/policy-metrics.js +2 -2
  81. package/dist/policy-metrics.js.map +1 -1
  82. package/dist/policy.d.ts +55 -93
  83. package/dist/policy.d.ts.map +1 -1
  84. package/dist/policy.js +149 -38
  85. package/dist/policy.js.map +1 -1
  86. package/dist/prompt-assembler.d.ts +1 -32
  87. package/dist/prompt-assembler.d.ts.map +1 -1
  88. package/dist/prompt-assembler.js +282 -435
  89. package/dist/prompt-assembler.js.map +1 -1
  90. package/dist/prompt-policy.d.ts +1 -4
  91. package/dist/prompt-policy.d.ts.map +1 -1
  92. package/dist/prompt-policy.js +6 -7
  93. package/dist/prompt-policy.js.map +1 -1
  94. package/dist/protocol.d.ts +365 -142
  95. package/dist/protocol.d.ts.map +1 -1
  96. package/dist/protocol.js +21 -66
  97. package/dist/protocol.js.map +1 -1
  98. package/dist/replay.js +2 -2
  99. package/dist/replay.js.map +1 -1
  100. package/dist/round-contract.d.ts +69 -57
  101. package/dist/round-contract.d.ts.map +1 -1
  102. package/dist/round-contract.js +104 -64
  103. package/dist/round-contract.js.map +1 -1
  104. package/dist/round-coordinator.d.ts +36 -16
  105. package/dist/round-coordinator.d.ts.map +1 -1
  106. package/dist/round-coordinator.js +166 -102
  107. package/dist/round-coordinator.js.map +1 -1
  108. package/dist/round-driver.d.ts +3 -5
  109. package/dist/round-driver.d.ts.map +1 -1
  110. package/dist/round-driver.js +0 -1
  111. package/dist/round-driver.js.map +1 -1
  112. package/dist/round-facts.d.ts +96 -0
  113. package/dist/round-facts.d.ts.map +1 -0
  114. package/dist/round-facts.js +216 -0
  115. package/dist/round-facts.js.map +1 -0
  116. package/dist/round-transaction.d.ts +19 -9
  117. package/dist/round-transaction.d.ts.map +1 -1
  118. package/dist/round-transaction.js +90 -22
  119. package/dist/round-transaction.js.map +1 -1
  120. package/dist/self-eval.d.ts +87 -9
  121. package/dist/self-eval.d.ts.map +1 -1
  122. package/dist/self-eval.js +396 -27
  123. package/dist/self-eval.js.map +1 -1
  124. package/dist/subgoal-state.d.ts +77 -0
  125. package/dist/subgoal-state.d.ts.map +1 -0
  126. package/dist/subgoal-state.js +198 -0
  127. package/dist/subgoal-state.js.map +1 -0
  128. package/dist/token-utils.d.ts +52 -19
  129. package/dist/token-utils.d.ts.map +1 -1
  130. package/dist/token-utils.js +83 -72
  131. package/dist/token-utils.js.map +1 -1
  132. package/dist/verification-gate.d.ts +14 -31
  133. package/dist/verification-gate.d.ts.map +1 -1
  134. package/dist/verification-gate.js +178 -496
  135. package/dist/verification-gate.js.map +1 -1
  136. package/dist/version.d.ts +1 -1
  137. package/dist/version.js +1 -1
  138. package/dist/workspace.d.ts +9 -0
  139. package/dist/workspace.d.ts.map +1 -1
  140. package/dist/workspace.js +17 -0
  141. package/dist/workspace.js.map +1 -1
  142. package/loop_policy.json +59 -83
  143. package/package.json +67 -67
  144. package/skills/perception/SKILL.md +218 -204
@@ -1,19 +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
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
- // ── EvidenceCollector ──────────────────────────────────────────────────────
32
- /** L8: true when a command snapshot reports that the command hit its own
33
- * deadline (the CommandEvidenceProvider kills the child and records
34
- * status "timeout") rather than completing. */
35
- function isTimedOutSnapshot(snapshot) {
36
- if (!isRecord(snapshot.data))
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
- return snapshot.data.kind === "command" && snapshot.data.status === "timeout";
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
- /** Collects evidence from all configured providers (always async — the
41
- * synchronous collect() was removed in v3.7).
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
- * Usage:
44
- * const collector = new EvidenceCollector([new GitEvidenceProvider()]);
45
- * const snapshots = await collector.collectAsync({ phase: "before" });
46
- * // snapshots = [{ provider: "git", files: [...], data: {...} }]
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. Unknown provider
54
- * 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. */
55
195
  static fromProviderNames(providerNames) {
56
- const providers = [];
57
- for (const name of providerNames) {
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: options.phase ?? "after",
228
+ phase,
96
229
  }));
97
- const snapshot = timeoutMs > 0
230
+ const observation = timeoutMs > 0
98
231
  ? await Promise.race([
99
232
  capture,
100
- new Promise((resolve) => {
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
- resolve(null);
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
- return timedOut ? null : snapshot;
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 null;
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 (await Promise.all(captures)).filter((snapshot) => snapshot !== null);
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
- 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) {
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
- 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");
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.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"));
227
382
  }
228
383
  try {
229
384
  cwd = commandCwd(this.config.cwd);
230
385
  }
231
386
  catch (error) {
232
- 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)));
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
- if (text.length > 0)
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.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")));
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) => { stdout = append(stdout, chunk); });
277
- 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
+ });
278
443
  child.once("error", (error) => {
444
+ stderrHash.update(error.message);
279
445
  stderr = append(stderr, error.message);
280
- finish(error.code === "ENOENT" ? "missing" : "failed", null, null);
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
- 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();
298
465
  return {
299
- provider: this.name,
300
- timestamp: Date.now(),
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
- kind: "command",
304
- 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,
305
479
  required: this.config.required,
306
- phase,
307
- status,
308
480
  exitCode,
309
481
  signal,
310
482
  durationMs,
311
- stdout,
312
- 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,
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((resolve, reject) => {
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
- ? resolve(stdout)
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
- /** v3.7: async capture only — the synchronous captureGitFileState() fallback
389
- * 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
+ }
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. A stable sentinel lets the diff
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
- provider: "git",
421
- 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",
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 evidence snapshots for backward compat
438
- * with runtimeFilesChanged (string[] | null).
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
- * providers' files arrays (deduplicated). */
442
- export function extractFilesFromSnapshots(snapshots) {
443
- 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)
444
648
  return null;
445
- // Prefer the git provider for backward compat
446
- const gitSnapshot = snapshots.find((s) => s.provider === "git");
447
- if (gitSnapshot)
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 s of snapshots) {
452
- for (const f of s.files)
453
- allFiles.add(f);
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
- /** Compute a diff between two evidence collections (before → after).
458
- * Returns files that appeared in the after-snapshot but not the before.
459
- * Used by runtime.ts to compute runtimeFilesChanged. */
460
- function snapshotFingerprints(snapshot) {
461
- 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;
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