loopforge 3.9.2 → 4.0.7

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 (285) hide show
  1. package/README.md +236 -112
  2. package/dist/audit.d.ts +73 -15
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +131 -61
  5. package/dist/audit.js.map +1 -1
  6. package/dist/canonical-state.d.ts +152 -40
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +382 -206
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +41 -12
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-governance.d.ts +0 -2
  14. package/dist/cognitive-governance.d.ts.map +1 -1
  15. package/dist/cognitive-governance.js +0 -4
  16. package/dist/cognitive-governance.js.map +1 -1
  17. package/dist/committed-round.d.ts +105 -141
  18. package/dist/committed-round.d.ts.map +1 -1
  19. package/dist/committed-round.js +249 -372
  20. package/dist/committed-round.js.map +1 -1
  21. package/dist/constraint-lifecycle.d.ts +73 -0
  22. package/dist/constraint-lifecycle.d.ts.map +1 -0
  23. package/dist/constraint-lifecycle.js +174 -0
  24. package/dist/constraint-lifecycle.js.map +1 -0
  25. package/dist/context-boundary.d.ts +83 -0
  26. package/dist/context-boundary.d.ts.map +1 -0
  27. package/dist/context-boundary.js +101 -0
  28. package/dist/context-boundary.js.map +1 -0
  29. package/dist/contract-items.d.ts +12 -8
  30. package/dist/contract-items.d.ts.map +1 -1
  31. package/dist/contract-items.js +31 -26
  32. package/dist/contract-items.js.map +1 -1
  33. package/dist/contract-requirement.d.ts +47 -0
  34. package/dist/contract-requirement.d.ts.map +1 -0
  35. package/dist/contract-requirement.js +51 -0
  36. package/dist/contract-requirement.js.map +1 -0
  37. package/dist/criterion-status.d.ts +35 -0
  38. package/dist/criterion-status.d.ts.map +1 -0
  39. package/dist/criterion-status.js +89 -0
  40. package/dist/criterion-status.js.map +1 -0
  41. package/dist/enforcement-gate.d.ts +32 -67
  42. package/dist/enforcement-gate.d.ts.map +1 -1
  43. package/dist/enforcement-gate.js +203 -439
  44. package/dist/enforcement-gate.js.map +1 -1
  45. package/dist/engine.d.ts +9 -103
  46. package/dist/engine.d.ts.map +1 -1
  47. package/dist/engine.js +28 -887
  48. package/dist/engine.js.map +1 -1
  49. package/dist/errors.d.ts +41 -0
  50. package/dist/errors.d.ts.map +1 -0
  51. package/dist/errors.js +69 -0
  52. package/dist/errors.js.map +1 -0
  53. package/dist/evidence-claims.d.ts +33 -31
  54. package/dist/evidence-claims.d.ts.map +1 -1
  55. package/dist/evidence-claims.js +58 -72
  56. package/dist/evidence-claims.js.map +1 -1
  57. package/dist/evidence-posture-view.d.ts +19 -0
  58. package/dist/evidence-posture-view.d.ts.map +1 -0
  59. package/dist/evidence-posture-view.js +116 -0
  60. package/dist/evidence-posture-view.js.map +1 -0
  61. package/dist/evidence-posture.d.ts +73 -0
  62. package/dist/evidence-posture.d.ts.map +1 -0
  63. package/dist/evidence-posture.js +281 -0
  64. package/dist/evidence-posture.js.map +1 -0
  65. package/dist/evidence-provider.d.ts +55 -37
  66. package/dist/evidence-provider.d.ts.map +1 -1
  67. package/dist/evidence-provider.js +182 -332
  68. package/dist/evidence-provider.js.map +1 -1
  69. package/dist/explain.d.ts +167 -27
  70. package/dist/explain.d.ts.map +1 -1
  71. package/dist/explain.js +373 -58
  72. package/dist/explain.js.map +1 -1
  73. package/dist/field-accounting.d.ts.map +1 -1
  74. package/dist/field-accounting.js +3 -22
  75. package/dist/field-accounting.js.map +1 -1
  76. package/dist/generate-schema.js +13 -8
  77. package/dist/generate-schema.js.map +1 -1
  78. package/dist/git-boundary.d.ts +128 -0
  79. package/dist/git-boundary.d.ts.map +1 -0
  80. package/dist/git-boundary.js +625 -0
  81. package/dist/git-boundary.js.map +1 -0
  82. package/dist/git-facts.d.ts +22 -0
  83. package/dist/git-facts.d.ts.map +1 -0
  84. package/dist/git-facts.js +29 -0
  85. package/dist/git-facts.js.map +1 -0
  86. package/dist/git-motion.d.ts +37 -0
  87. package/dist/git-motion.d.ts.map +1 -0
  88. package/dist/git-motion.js +59 -0
  89. package/dist/git-motion.js.map +1 -0
  90. package/dist/history.d.ts +44 -0
  91. package/dist/history.d.ts.map +1 -0
  92. package/dist/history.js +77 -0
  93. package/dist/history.js.map +1 -0
  94. package/dist/index.d.ts +19 -9
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +12 -4
  97. package/dist/index.js.map +1 -1
  98. package/dist/init.d.ts +16 -0
  99. package/dist/init.d.ts.map +1 -1
  100. package/dist/init.js +80 -14
  101. package/dist/init.js.map +1 -1
  102. package/dist/loop-compiler.d.ts +51 -19
  103. package/dist/loop-compiler.d.ts.map +1 -1
  104. package/dist/loop-compiler.js +423 -507
  105. package/dist/loop-compiler.js.map +1 -1
  106. package/dist/loop-store.d.ts +6 -5
  107. package/dist/loop-store.d.ts.map +1 -1
  108. package/dist/loop-store.js +153 -35
  109. package/dist/loop-store.js.map +1 -1
  110. package/dist/mcp/round-lifecycle.d.ts +285 -81
  111. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  112. package/dist/mcp/round-lifecycle.js +1629 -574
  113. package/dist/mcp/round-lifecycle.js.map +1 -1
  114. package/dist/mcp/server.d.ts +5 -0
  115. package/dist/mcp/server.d.ts.map +1 -1
  116. package/dist/mcp/server.js +61 -19
  117. package/dist/mcp/server.js.map +1 -1
  118. package/dist/mcp/session.d.ts +81 -39
  119. package/dist/mcp/session.d.ts.map +1 -1
  120. package/dist/mcp/session.js +963 -271
  121. package/dist/mcp/session.js.map +1 -1
  122. package/dist/mcp/submission-boundary.d.ts +23 -31
  123. package/dist/mcp/submission-boundary.d.ts.map +1 -1
  124. package/dist/mcp/submission-boundary.js +242 -82
  125. package/dist/mcp/submission-boundary.js.map +1 -1
  126. package/dist/mcp/submission-handle.d.ts +66 -0
  127. package/dist/mcp/submission-handle.d.ts.map +1 -0
  128. package/dist/mcp/submission-handle.js +173 -0
  129. package/dist/mcp/submission-handle.js.map +1 -0
  130. package/dist/mcp/tools.d.ts +17 -7
  131. package/dist/mcp/tools.d.ts.map +1 -1
  132. package/dist/mcp/tools.js +763 -244
  133. package/dist/mcp/tools.js.map +1 -1
  134. package/dist/open-question.d.ts +7 -0
  135. package/dist/open-question.d.ts.map +1 -0
  136. package/dist/open-question.js +14 -0
  137. package/dist/open-question.js.map +1 -0
  138. package/dist/policy-metrics.d.ts +31 -21
  139. package/dist/policy-metrics.d.ts.map +1 -1
  140. package/dist/policy-metrics.js +117 -64
  141. package/dist/policy-metrics.js.map +1 -1
  142. package/dist/policy.d.ts +15 -40
  143. package/dist/policy.d.ts.map +1 -1
  144. package/dist/policy.js +13 -13
  145. package/dist/policy.js.map +1 -1
  146. package/dist/progress.d.ts +45 -0
  147. package/dist/progress.d.ts.map +1 -0
  148. package/dist/progress.js +136 -0
  149. package/dist/progress.js.map +1 -0
  150. package/dist/prompt-assembler.d.ts +28 -9
  151. package/dist/prompt-assembler.d.ts.map +1 -1
  152. package/dist/prompt-assembler.js +592 -603
  153. package/dist/prompt-assembler.js.map +1 -1
  154. package/dist/prompt-coverage.d.ts +39 -0
  155. package/dist/prompt-coverage.d.ts.map +1 -0
  156. package/dist/prompt-coverage.js +80 -0
  157. package/dist/prompt-coverage.js.map +1 -0
  158. package/dist/prompt-freshness.d.ts +25 -0
  159. package/dist/prompt-freshness.d.ts.map +1 -0
  160. package/dist/prompt-freshness.js +88 -0
  161. package/dist/prompt-freshness.js.map +1 -0
  162. package/dist/prompt-policy.d.ts +14 -4
  163. package/dist/prompt-policy.d.ts.map +1 -1
  164. package/dist/prompt-policy.js +29 -10
  165. package/dist/prompt-policy.js.map +1 -1
  166. package/dist/protocol.d.ts +875 -419
  167. package/dist/protocol.d.ts.map +1 -1
  168. package/dist/protocol.js +73 -99
  169. package/dist/protocol.js.map +1 -1
  170. package/dist/recovery-guidance.d.ts +13 -0
  171. package/dist/recovery-guidance.d.ts.map +1 -0
  172. package/dist/recovery-guidance.js +15 -0
  173. package/dist/recovery-guidance.js.map +1 -0
  174. package/dist/recovery.d.ts +68 -0
  175. package/dist/recovery.d.ts.map +1 -0
  176. package/dist/recovery.js +234 -0
  177. package/dist/recovery.js.map +1 -0
  178. package/dist/replay.d.ts +42 -8
  179. package/dist/replay.d.ts.map +1 -1
  180. package/dist/replay.js +311 -84
  181. package/dist/replay.js.map +1 -1
  182. package/dist/result-facts.d.ts +45 -0
  183. package/dist/result-facts.d.ts.map +1 -0
  184. package/dist/result-facts.js +86 -0
  185. package/dist/result-facts.js.map +1 -0
  186. package/dist/round-contract.d.ts +38 -8
  187. package/dist/round-contract.d.ts.map +1 -1
  188. package/dist/round-contract.js +71 -31
  189. package/dist/round-contract.js.map +1 -1
  190. package/dist/round-coordinator.d.ts +45 -91
  191. package/dist/round-coordinator.d.ts.map +1 -1
  192. package/dist/round-coordinator.js +254 -271
  193. package/dist/round-coordinator.js.map +1 -1
  194. package/dist/round-driver.d.ts +56 -16
  195. package/dist/round-driver.d.ts.map +1 -1
  196. package/dist/round-driver.js +225 -39
  197. package/dist/round-driver.js.map +1 -1
  198. package/dist/round-facts.d.ts +33 -52
  199. package/dist/round-facts.d.ts.map +1 -1
  200. package/dist/round-facts.js +54 -157
  201. package/dist/round-facts.js.map +1 -1
  202. package/dist/round-guidance.d.ts +56 -0
  203. package/dist/round-guidance.d.ts.map +1 -0
  204. package/dist/round-guidance.js +161 -0
  205. package/dist/round-guidance.js.map +1 -0
  206. package/dist/round-outcome.d.ts +21 -0
  207. package/dist/round-outcome.d.ts.map +1 -0
  208. package/dist/round-outcome.js +31 -0
  209. package/dist/round-outcome.js.map +1 -0
  210. package/dist/round-transaction.d.ts +168 -21
  211. package/dist/round-transaction.d.ts.map +1 -1
  212. package/dist/round-transaction.js +856 -39
  213. package/dist/round-transaction.js.map +1 -1
  214. package/dist/self-eval.d.ts +40 -60
  215. package/dist/self-eval.d.ts.map +1 -1
  216. package/dist/self-eval.js +455 -384
  217. package/dist/self-eval.js.map +1 -1
  218. package/dist/storage.d.ts +16 -0
  219. package/dist/storage.d.ts.map +1 -1
  220. package/dist/storage.js +54 -4
  221. package/dist/storage.js.map +1 -1
  222. package/dist/subgoal-state.d.ts +2 -3
  223. package/dist/subgoal-state.d.ts.map +1 -1
  224. package/dist/subgoal-state.js +2 -3
  225. package/dist/subgoal-state.js.map +1 -1
  226. package/dist/submission-guidance.d.ts +43 -21
  227. package/dist/submission-guidance.d.ts.map +1 -1
  228. package/dist/submission-guidance.js +368 -262
  229. package/dist/submission-guidance.js.map +1 -1
  230. package/dist/submission-receipt.d.ts +16 -8
  231. package/dist/submission-receipt.d.ts.map +1 -1
  232. package/dist/submission-receipt.js +146 -51
  233. package/dist/submission-receipt.js.map +1 -1
  234. package/dist/token-utils.d.ts +47 -7
  235. package/dist/token-utils.d.ts.map +1 -1
  236. package/dist/token-utils.js +66 -15
  237. package/dist/token-utils.js.map +1 -1
  238. package/dist/transaction-identity.d.ts +9 -0
  239. package/dist/transaction-identity.d.ts.map +1 -0
  240. package/dist/transaction-identity.js +69 -0
  241. package/dist/transaction-identity.js.map +1 -0
  242. package/dist/verification-actions.d.ts +13 -0
  243. package/dist/verification-actions.d.ts.map +1 -0
  244. package/dist/verification-actions.js +44 -0
  245. package/dist/verification-actions.js.map +1 -0
  246. package/dist/verification-gate.d.ts +26 -46
  247. package/dist/verification-gate.d.ts.map +1 -1
  248. package/dist/verification-gate.js +218 -427
  249. package/dist/verification-gate.js.map +1 -1
  250. package/dist/version.d.ts +1 -1
  251. package/dist/version.js +1 -1
  252. package/dist/working-memory.d.ts +187 -0
  253. package/dist/working-memory.d.ts.map +1 -0
  254. package/dist/working-memory.js +767 -0
  255. package/dist/working-memory.js.map +1 -0
  256. package/dist/workspace-checkpoint.d.ts +88 -0
  257. package/dist/workspace-checkpoint.d.ts.map +1 -0
  258. package/dist/workspace-checkpoint.js +326 -0
  259. package/dist/workspace-checkpoint.js.map +1 -0
  260. package/dist/workspace-restore.d.ts +68 -0
  261. package/dist/workspace-restore.d.ts.map +1 -0
  262. package/dist/workspace-restore.js +604 -0
  263. package/dist/workspace-restore.js.map +1 -0
  264. package/dist/workspace.d.ts +0 -9
  265. package/dist/workspace.d.ts.map +1 -1
  266. package/dist/workspace.js +0 -17
  267. package/dist/workspace.js.map +1 -1
  268. package/loop_policy.json +5 -19
  269. package/package.json +2 -2
  270. package/skills/forge/SKILL.md +212 -0
  271. package/skills/forge/references/evidence-and-contracts.md +204 -0
  272. package/skills/forge/references/recovery-and-views.md +188 -0
  273. package/dist/cognitive-facts.d.ts +0 -30
  274. package/dist/cognitive-facts.d.ts.map +0 -1
  275. package/dist/cognitive-facts.js +0 -113
  276. package/dist/cognitive-facts.js.map +0 -1
  277. package/dist/loop-extras-parser.d.ts +0 -81
  278. package/dist/loop-extras-parser.d.ts.map +0 -1
  279. package/dist/loop-extras-parser.js +0 -213
  280. package/dist/loop-extras-parser.js.map +0 -1
  281. package/dist/loop-projection.d.ts +0 -6
  282. package/dist/loop-projection.d.ts.map +0 -1
  283. package/dist/loop-projection.js +0 -28
  284. package/dist/loop-projection.js.map +0 -1
  285. package/skills/perception/SKILL.md +0 -370
@@ -10,11 +10,18 @@
10
10
  * execution (v2.0.1), plus explicitly configured shell-free command providers.
11
11
  */
12
12
  import { createHash } from "node:crypto";
13
- import { createReadStream, lstatSync, readFileSync, readlinkSync, realpathSync, statSync, } from "node:fs";
14
- import { spawn, execFile, } from "node:child_process";
13
+ import { readFileSync, realpathSync, statSync, } from "node:fs";
14
+ import { spawn } from "node:child_process";
15
15
  import { isAbsolute, relative, resolve, sep } from "node:path";
16
16
  import { GIT_OBSERVATION_SCHEMA_VERSION } from "./protocol.js";
17
+ import { GIT_WORKSPACE_COVERAGE, gitMotionSignal, observedGitHead, } from "./git-motion.js";
17
18
  import { containInWorkspace } from "./workspace.js";
19
+ import { isRecord } from "./token-utils.js";
20
+ import { captureBoundary, GitCaptureFailure } from "./git-boundary.js";
21
+ // v3.9.8: the Git boundary implementation moved to git-boundary.ts so evidence
22
+ // and workspace checkpoints share ONE scan. These re-exports keep the existing
23
+ // test/library surface stable.
24
+ export { captureGitFileStateAsync } from "./git-boundary.js";
18
25
  import { commandConfigHash, deriveConfiguredCapability, getPolicy, resolveStateDirectory } from "./policy.js";
19
26
  import { logEvent } from "./observability.js";
20
27
  import { policyMetrics } from "./policy-metrics.js";
@@ -118,6 +125,18 @@ export function capabilityWarnings(configured, policy, observed) {
118
125
  "cannot be machine-verified; contract closure will require an explicit " +
119
126
  "blocked outcome.");
120
127
  }
128
+ // v3.9.9: the stall rule selects Git workspace coverage explicitly. Without
129
+ // the Git provider configured there is no workspace evidence basis for a
130
+ // stall verdict — other providers' facts remain visible but cannot affect
131
+ // that rule. This is a fact statement, not a blocker.
132
+ const providerNames = Array.isArray(policy.evidence.providers)
133
+ ? policy.evidence.providers
134
+ : [];
135
+ if (!providerNames.includes("git")) {
136
+ warnings.push("The Git workspace evidence provider is not configured — the stall rule " +
137
+ "has no workspace evidence basis this loop. Progress facts from other " +
138
+ "providers are recorded and displayed but cannot affect stall decisions.");
139
+ }
121
140
  // v3.8.2: a configured command whose harness is named in package.json. The
122
141
  // entrypoint set for `npm test` is ["package.json"], so the test files the
123
142
  // script actually runs are never in it: editing one is a warning, not a
@@ -191,12 +210,18 @@ function unregisteredProvider(name) {
191
210
  }
192
211
  /** v3.8: Build the observation a failed capture resolves to. The status names
193
212
  * the machine fact; `detail` explains it for the audit trail. */
213
+ /** v3.9.9: THE per-kind base observation schema table. Custom observations
214
+ * keep base 1; only their nested progress snapshot moved to inner 2. */
215
+ export function expectedObservationSchemaVersion(kind) {
216
+ return kind === "git" ? GIT_OBSERVATION_SCHEMA_VERSION : 1;
217
+ }
194
218
  function syntheticObservation(providerId, kind, phase, startedAt, status, detail) {
195
219
  return {
196
220
  // v3.9.1: a failed Git capture still carries the current Git observation
197
221
  // schema so the committed snapshot can be parsed; its non-observed status
198
- // is what tells consumers the boundary is unavailable.
199
- schemaVersion: kind === "git" ? GIT_OBSERVATION_SCHEMA_VERSION : 1,
222
+ // is what tells consumers the boundary is unavailable. Every synthetic
223
+ // observation goes through the ONE per-kind table.
224
+ schemaVersion: expectedObservationSchemaVersion(kind),
200
225
  providerId,
201
226
  kind,
202
227
  phase,
@@ -207,6 +232,31 @@ function syntheticObservation(providerId, kind, phase, startedAt, status, detail
207
232
  data: { detail },
208
233
  };
209
234
  }
235
+ export function observationVersionIssue(kind, schemaVersion, data, _status) {
236
+ if (kind !== "git" && kind !== "command" && kind !== "custom") {
237
+ return {
238
+ envelope: "observation",
239
+ schemaVersion: typeof schemaVersion === "number" ? schemaVersion : -1,
240
+ reason: "schema",
241
+ };
242
+ }
243
+ if (!isRecord(data)) {
244
+ return {
245
+ envelope: "observation",
246
+ schemaVersion: typeof schemaVersion === "number" ? schemaVersion : -1,
247
+ reason: "malformed",
248
+ };
249
+ }
250
+ const expected = expectedObservationSchemaVersion(kind);
251
+ if (schemaVersion !== expected) {
252
+ return {
253
+ envelope: "observation",
254
+ schemaVersion: typeof schemaVersion === "number" ? schemaVersion : -1,
255
+ reason: "schema",
256
+ };
257
+ }
258
+ return null;
259
+ }
210
260
  /** Collects observations from all configured providers (always async).
211
261
  *
212
262
  * v3.8: every configured provider produces exactly one observation per
@@ -255,8 +305,18 @@ export class EvidenceCollector {
255
305
  const capture = Promise.resolve(provider.capture({
256
306
  signal: controller.signal,
257
307
  timeoutMs,
258
- loopId: options.loopId,
308
+ ...(options.loopId !== undefined ? { loopId: options.loopId } : {}),
259
309
  phase,
310
+ ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
311
+ ...(options.materializer
312
+ ? { materializer: options.materializer }
313
+ : {}),
314
+ ...(options.boundarySink
315
+ ? { boundarySink: options.boundarySink }
316
+ : {}),
317
+ ...(options.maxPaths !== undefined
318
+ ? { maxPaths: options.maxPaths }
319
+ : {}),
260
320
  }));
261
321
  const observation = timeoutMs > 0
262
322
  ? await Promise.race([
@@ -277,10 +337,12 @@ export class EvidenceCollector {
277
337
  }
278
338
  if (!observation) {
279
339
  policyMetrics.recordEvidence(provider.name, "unavailable", Date.now() - startedAt, options.loopId);
280
- return syntheticObservation(provider.name, provider.kind, phase, startedAt, "unavailable", "provider reported no observation");
340
+ const unavailable = syntheticObservation(provider.name, provider.kind, phase, startedAt, "unavailable", "provider reported no observation");
341
+ return unavailable;
281
342
  }
282
- policyMetrics.recordEvidence(provider.name, observation.status === "timeout" ? "timeout" : "available", Date.now() - startedAt, options.loopId);
283
- return observation;
343
+ const normalized = observation;
344
+ policyMetrics.recordEvidence(provider.name, normalized.status === "timeout" ? "timeout" : "available", Date.now() - startedAt, options.loopId, normalized.status);
345
+ return normalized;
284
346
  }
285
347
  catch (error) {
286
348
  policyMetrics.recordEvidence(provider.name, "failure", Date.now() - startedAt, options.loopId);
@@ -580,7 +642,7 @@ export class CommandEvidenceProvider {
580
642
  observation(phase, status, exitCode, signal, stdout, stderr, truncated, durationMs, entrypointFiles, failureDetail, stdoutSha256, stderrSha256) {
581
643
  const finishedAt = Date.now();
582
644
  return {
583
- schemaVersion: 1,
645
+ schemaVersion: expectedObservationSchemaVersion("command"),
584
646
  providerId: this.name,
585
647
  kind: "command",
586
648
  phase,
@@ -608,180 +670,8 @@ export class CommandEvidenceProvider {
608
670
  };
609
671
  }
610
672
  }
611
- class GitCaptureFailure extends Error {
612
- status;
613
- constructor(status, message) {
614
- super(message);
615
- this.status = status;
616
- this.name = "GitCaptureFailure";
617
- }
618
- }
619
- const GIT_MAX_BUFFER = 64 * 1024 * 1024;
620
- /** Bounded file-hash concurrency. A module constant, not a policy knob. */
621
- const BOUNDARY_HASH_CONCURRENCY = 16;
622
- function splitNul(text) {
623
- return text.split("\0").filter((entry) => entry.length > 0);
624
- }
625
- function uniqueSorted(values) {
626
- return [...new Set(values)].sort();
627
- }
628
- /** Parse `git ls-files --stage -z`. A conflicted path can have several
629
- * stages; stage 0 wins when present, otherwise the first entry is kept. The
630
- * worktree fingerprint is what matters, so this is only used to recognise
631
- * gitlinks and to carry the index object id. */
632
- function parseStageEntries(text) {
633
- const byPath = new Map();
634
- for (const record of splitNul(text)) {
635
- const tab = record.indexOf("\t");
636
- if (tab < 0)
637
- continue;
638
- const [mode, object, stage] = record.slice(0, tab).split(" ");
639
- if (!mode || !object || !stage)
640
- continue;
641
- const path = record.slice(tab + 1);
642
- if (!path)
643
- continue;
644
- const existing = byPath.get(path);
645
- if (!existing || stage === "0") {
646
- byPath.set(path, { entry: { path, mode, object }, stage });
647
- }
648
- }
649
- return [...byPath.values()]
650
- .map((item) => item.entry)
651
- .sort((a, b) => a.path.localeCompare(b.path));
652
- }
653
- /** v3.9.1: capture the complete boundary. All required git commands run in
654
- * one parallel batch; HEAD is best-effort (a repository with no commit is a
655
- * valid boundary with no head). Any required-command failure returns an
656
- * explicit status — never an empty-but-observed boundary. */
657
- export async function captureGitFileStateAsync(signal, timeoutMs,
658
- /** Workspace root; injectable so tests can capture against a temp repo.
659
- * Defaults to the process cwd, matching the evidence providers. */
660
- cwd = process.cwd()) {
661
- const timeout = timeoutMs ?? 15000;
662
- const runGit = (args) => {
663
- return new Promise((resolveGit, reject) => {
664
- let settled = false;
665
- const succeed = (stdout) => {
666
- if (settled)
667
- return;
668
- settled = true;
669
- resolveGit(stdout);
670
- };
671
- const fail = (error) => {
672
- if (settled)
673
- return;
674
- settled = true;
675
- reject(error);
676
- };
677
- // M1: core.quotePath=false — git otherwise octal-escapes every path
678
- // with bytes >= 0x80. With -z, paths are NUL-delimited raw bytes; the
679
- // flag is retained for the HEAD/plain outputs.
680
- const child = execFile("git", ["-c", "core.quotePath=false", ...args], {
681
- cwd,
682
- encoding: "utf-8",
683
- timeout,
684
- signal,
685
- maxBuffer: GIT_MAX_BUFFER,
686
- windowsHide: true,
687
- });
688
- let stdout = "";
689
- let stderr = "";
690
- child.stdout?.on("data", (chunk) => {
691
- stdout += chunk;
692
- });
693
- child.stderr?.on("data", (chunk) => {
694
- stderr += chunk;
695
- });
696
- child.on("close", (code) => {
697
- if (code === 0)
698
- succeed(stdout);
699
- else if (code === null) {
700
- // A killed process can close without an exit code. If the error
701
- // event has not settled the promise yet, the measured cause is the
702
- // deadline.
703
- fail(new GitCaptureFailure("timeout", `git ${args[0]} was killed`));
704
- }
705
- else if (/not a git repository|not a repository/i.test(stderr)) {
706
- fail(new GitCaptureFailure("unavailable", stderr.trim() || "not a git repository"));
707
- }
708
- else {
709
- fail(new GitCaptureFailure("error", `git ${args[0]} exited ${code}${stderr ? `: ${stderr.trim().slice(0, 300)}` : ""}`));
710
- }
711
- });
712
- child.on("error", (error) => {
713
- if (signal?.aborted) {
714
- fail(new GitCaptureFailure("aborted", `git ${args[0]} aborted`));
715
- }
716
- else if (error.code === "ENOENT") {
717
- fail(new GitCaptureFailure("unavailable", "git executable was not found"));
718
- }
719
- else if (error.killed || error.signal) {
720
- fail(new GitCaptureFailure("timeout", `git ${args[0]} timed out after ${timeout}ms`));
721
- }
722
- else if (/not a git repository/i.test(error.message)) {
723
- fail(new GitCaptureFailure("unavailable", error.message));
724
- }
725
- else {
726
- fail(new GitCaptureFailure("error", error.message));
727
- }
728
- });
729
- });
730
- };
731
- try {
732
- // allSettled, not all: on failure the other children must still finish
733
- // before this capture returns, or Windows keeps the workspace directory
734
- // locked (and every caller would leak child-process handles).
735
- const settled = await Promise.allSettled([
736
- runGit(["ls-files", "--stage", "-z"]),
737
- runGit(["ls-files", "--others", "--exclude-standard", "-z"]),
738
- runGit(["diff", "--name-only", "-z"]),
739
- runGit(["diff", "--cached", "--name-only", "-z"]),
740
- ]);
741
- const failures = settled
742
- .filter((item) => item.status === "rejected")
743
- .map((item) => item.reason)
744
- .filter((error) => error instanceof GitCaptureFailure);
745
- if (failures.length > 0) {
746
- const failure = failures.find((item) => item.status === "aborted" || item.status === "timeout") ?? failures[0];
747
- throw failure;
748
- }
749
- const rejected = settled.find((item) => item.status === "rejected");
750
- if (rejected) {
751
- throw new GitCaptureFailure("error", String(rejected.reason));
752
- }
753
- const [stageOut, untrackedOut, unstagedOut, stagedOut] = settled.map((item) => item.value);
754
- let head;
755
- try {
756
- head = (await runGit(["rev-parse", "--verify", "HEAD"])).trim() || undefined;
757
- }
758
- catch (error) {
759
- // A repository with no commit has no HEAD. Abort still propagates.
760
- if (error instanceof GitCaptureFailure && error.status === "aborted")
761
- throw error;
762
- }
763
- const untracked = uniqueSorted(splitNul(untrackedOut));
764
- return {
765
- ok: true,
766
- state: {
767
- tracked: parseStageEntries(stageOut),
768
- untracked,
769
- dirty: uniqueSorted([
770
- ...splitNul(unstagedOut),
771
- ...splitNul(stagedOut),
772
- ...untracked,
773
- ]),
774
- head,
775
- },
776
- };
777
- }
778
- catch (error) {
779
- if (error instanceof GitCaptureFailure) {
780
- return { ok: false, status: error.status, detail: error.message };
781
- }
782
- return { ok: false, status: "error", detail: String(error) };
783
- }
784
- }
673
+ // The Git boundary capture/fingerprint implementation lives in
674
+ // git-boundary.ts (shared with workspace checkpoints).
785
675
  // ── Built-in: GitEvidenceProvider ──────────────────────────────────────────
786
676
  /** The runtime's resolved state directory, or null when it cannot be observed
787
677
  * (configured outside the workspace, or an unresolvable path). */
@@ -800,111 +690,6 @@ function isInsideDirectory(file, workspace, dir) {
800
690
  const prefix = dir.endsWith(sep) ? dir : `${dir}${sep}`;
801
691
  return absolute === dir || absolute.startsWith(prefix);
802
692
  }
803
- function hashText(text) {
804
- return createHash("sha256").update(text).digest("hex");
805
- }
806
- /** Stream a regular file through SHA-256 without loading it into memory. */
807
- function hashFileStream(abs, signal) {
808
- return new Promise((resolveHash, reject) => {
809
- const hash = createHash("sha256");
810
- const stream = createReadStream(abs, { highWaterMark: 64 * 1024 });
811
- let settled = false;
812
- const cleanup = () => {
813
- if (signal)
814
- signal.removeEventListener("abort", onAbort);
815
- };
816
- const finish = (error) => {
817
- if (settled)
818
- return;
819
- settled = true;
820
- cleanup();
821
- if (error)
822
- reject(error);
823
- else
824
- resolveHash(hash.digest("hex"));
825
- };
826
- const onAbort = () => {
827
- stream.destroy();
828
- finish(new GitCaptureFailure("aborted", "boundary hashing aborted"));
829
- };
830
- if (signal?.aborted) {
831
- onAbort();
832
- return;
833
- }
834
- signal?.addEventListener("abort", onAbort, { once: true });
835
- stream.on("data", (chunk) => hash.update(chunk));
836
- stream.on("error", (error) => finish(error));
837
- stream.on("end", () => finish());
838
- });
839
- }
840
- /** Fingerprint one boundary path.
841
- * - symlinks hash their LINK TEXT; the target is never read
842
- * - regular files stream their content
843
- * - gitlinks record the index commit (submodule contents are not covered)
844
- * A per-file read/containment failure is an explicit `unreadable:<mode>`
845
- * sentinel, not an omitted path. */
846
- async function boundaryFingerprint(workspace, path, tracked, signal) {
847
- const abs = resolve(workspace, path);
848
- let stat;
849
- try {
850
- stat = lstatSync(abs);
851
- }
852
- catch (error) {
853
- return error.code === "ENOENT" ? "missing" : "unreadable";
854
- }
855
- const mode = stat.mode;
856
- if (stat.isSymbolicLink()) {
857
- try {
858
- // The parent must be contained; the final link target is NOT followed.
859
- const parent = path.includes("/")
860
- ? path.slice(0, path.lastIndexOf("/")) || "."
861
- : ".";
862
- containInWorkspace(workspace, parent);
863
- return `link:${hashText(readlinkSync(abs))}`;
864
- }
865
- catch {
866
- return `unreadable:${mode}`;
867
- }
868
- }
869
- if (stat.isDirectory()) {
870
- if (tracked?.mode === "160000")
871
- return `gitlink:${tracked.object}`;
872
- return `dir:${mode}`;
873
- }
874
- if (!stat.isFile())
875
- return `other:${mode}`;
876
- try {
877
- containInWorkspace(workspace, path);
878
- const digest = await hashFileStream(abs, signal);
879
- return `${mode}:${digest}`;
880
- }
881
- catch (error) {
882
- if (error instanceof GitCaptureFailure)
883
- throw error;
884
- return `unreadable:${mode}`;
885
- }
886
- }
887
- /** Hash the complete boundary with bounded concurrency. */
888
- async function fingerprintBoundary(workspace, entries, signal) {
889
- const paths = [...entries.keys()].sort();
890
- const fingerprints = {};
891
- let next = 0;
892
- const worker = async () => {
893
- for (;;) {
894
- if (signal?.aborted) {
895
- throw new GitCaptureFailure("aborted", "boundary hashing aborted");
896
- }
897
- const index = next++;
898
- if (index >= paths.length)
899
- return;
900
- const path = paths[index];
901
- fingerprints[path] = await boundaryFingerprint(workspace, path, entries.get(path), signal);
902
- }
903
- };
904
- const workers = Math.min(BOUNDARY_HASH_CONCURRENCY, Math.max(1, paths.length));
905
- await Promise.all(Array.from({ length: workers }, () => worker()));
906
- return fingerprints;
907
- }
908
693
  export class GitEvidenceProvider {
909
694
  name = "git";
910
695
  kind = "git";
@@ -939,56 +724,48 @@ export class GitEvidenceProvider {
939
724
  }, budget)
940
725
  : undefined;
941
726
  try {
942
- const result = await captureGitFileStateAsync(controller.signal, context?.timeoutMs, workspace);
727
+ const result = await captureBoundary({
728
+ cwd: workspace,
729
+ signal: controller.signal,
730
+ ...(context?.timeoutMs !== undefined ? { timeoutMs: context.timeoutMs } : {}),
731
+ exclude: isRuntimeState,
732
+ ...(context?.materializer ? { materializer: context.materializer } : {}),
733
+ ...(context?.maxPaths !== undefined ? { maxPaths: context.maxPaths } : {}),
734
+ });
943
735
  if (timedOut) {
944
736
  return syntheticObservation(this.name, this.kind, phase, startedAt, "timeout", `boundary capture exceeded ${budget}ms`);
945
737
  }
946
738
  if (!result.ok) {
947
739
  return syntheticObservation(this.name, this.kind, phase, startedAt, result.status, result.detail);
948
740
  }
949
- const tracked = result.state.tracked.filter((entry) => !isRuntimeState(entry.path));
950
- const untracked = result.state.untracked.filter((file) => !isRuntimeState(file));
951
- const dirty = result.state.dirty.filter((file) => !isRuntimeState(file));
952
- const boundary = new Map();
953
- for (const entry of tracked)
954
- boundary.set(entry.path, entry);
955
- for (const file of untracked) {
956
- if (!boundary.has(file)) {
957
- boundary.set(file, { path: file, mode: "", object: "" });
958
- }
959
- }
960
- try {
961
- const fingerprints = await fingerprintBoundary(workspace, boundary, controller.signal);
962
- if (timedOut) {
963
- return syntheticObservation(this.name, this.kind, phase, startedAt, "timeout", `boundary capture exceeded ${budget}ms`);
964
- }
965
- return {
966
- schemaVersion: GIT_OBSERVATION_SCHEMA_VERSION,
967
- providerId: "git",
968
- kind: "git",
969
- phase,
970
- startedAt,
971
- finishedAt: Date.now(),
972
- status: "observed",
973
- // `files` is the dirty display list; the complete boundary lives in
974
- // data.fingerprints and the round delta is derived from it.
975
- files: dirty,
976
- data: {
977
- dirty,
978
- fingerprints,
979
- head: result.state.head,
980
- coverage: "tracked_and_non_ignored_untracked",
981
- },
982
- };
983
- }
984
- catch (error) {
985
- const status = timedOut
986
- ? "timeout"
987
- : error instanceof GitCaptureFailure
988
- ? error.status
989
- : "error";
990
- return syntheticObservation(this.name, this.kind, phase, startedAt, status, String(error instanceof Error ? error.message : error));
991
- }
741
+ const boundary = result.boundary;
742
+ context?.boundarySink?.(boundary);
743
+ return {
744
+ schemaVersion: expectedObservationSchemaVersion("git"),
745
+ providerId: "git",
746
+ kind: "git",
747
+ phase,
748
+ startedAt,
749
+ finishedAt: Date.now(),
750
+ status: "observed",
751
+ // `files` is the dirty display list; the complete boundary lives in
752
+ // data.fingerprints and the round delta is derived from it.
753
+ files: [...boundary.dirty],
754
+ data: {
755
+ dirty: [...boundary.dirty],
756
+ fingerprints: boundary.fingerprints,
757
+ ...(boundary.head ? { head: boundary.head } : {}),
758
+ coverage: "tracked_and_non_ignored_untracked",
759
+ },
760
+ };
761
+ }
762
+ catch (error) {
763
+ const status = timedOut
764
+ ? "timeout"
765
+ : error instanceof GitCaptureFailure
766
+ ? error.status
767
+ : "error";
768
+ return syntheticObservation(this.name, this.kind, phase, startedAt, status, String(error instanceof Error ? error.message : error));
992
769
  }
993
770
  finally {
994
771
  if (timer)
@@ -998,6 +775,78 @@ export class GitEvidenceProvider {
998
775
  }
999
776
  }
1000
777
  registerEvidenceProvider("git", () => new GitEvidenceProvider());
778
+ /** The Git fact descriptor is derived from the PERSISTED observation, not from
779
+ * a code constant: the scope is the boundary coverage the observation
780
+ * declared and the version is its schema version, so a historical
781
+ * observation with different evidence semantics naturally stops matching the
782
+ * rule instead of being retroactively rewritten by future code. */
783
+ function gitProgressCoverage(observation) {
784
+ if (observation?.kind === "git") {
785
+ const scope = typeof observation.data.coverage === "string" &&
786
+ observation.data.coverage.length > 0
787
+ ? observation.data.coverage
788
+ : GIT_WORKSPACE_COVERAGE.scope;
789
+ const version = Number.isInteger(observation.schemaVersion) &&
790
+ observation.schemaVersion >= 1
791
+ ? observation.schemaVersion
792
+ : GIT_WORKSPACE_COVERAGE.version;
793
+ return {
794
+ domain: GIT_WORKSPACE_COVERAGE.domain,
795
+ object: GIT_WORKSPACE_COVERAGE.object,
796
+ scope,
797
+ version,
798
+ };
799
+ }
800
+ return { ...GIT_WORKSPACE_COVERAGE };
801
+ }
802
+ function deltaProgressFact(observation, beforeObservation, afterObservation, changedFiles, ambiguous) {
803
+ if (observation.kind === "git") {
804
+ const coverage = gitProgressCoverage(afterObservation ?? beforeObservation);
805
+ const observable = !ambiguous &&
806
+ beforeObservation?.kind === "git" &&
807
+ afterObservation?.kind === "git" &&
808
+ beforeObservation.status === "observed" &&
809
+ afterObservation.status === "observed" &&
810
+ changedFiles !== null;
811
+ if (!observable) {
812
+ return {
813
+ kind: "git",
814
+ coverage,
815
+ state: "unknown",
816
+ complete: false,
817
+ detail: "the Git boundary pair is not comparable",
818
+ };
819
+ }
820
+ const signal = gitMotionSignal({
821
+ changedFiles,
822
+ headBefore: observedGitHead(beforeObservation),
823
+ headAfter: observedGitHead(afterObservation),
824
+ });
825
+ if (signal === null) {
826
+ return {
827
+ kind: "git",
828
+ coverage,
829
+ state: "unknown",
830
+ complete: true,
831
+ detail: "the Git change set is known but HEAD movement is not comparable",
832
+ };
833
+ }
834
+ return {
835
+ kind: "git",
836
+ coverage,
837
+ state: signal ? "changed" : "unchanged",
838
+ complete: true,
839
+ };
840
+ }
841
+ // Custom observations are reports. They carry no progress fact, so they can
842
+ // never influence stall or prompt freshness.
843
+ return null;
844
+ }
845
+ /** Stable display key for a coverage descriptor — derived only, never
846
+ * persisted, never used as an identity substitute for exact comparison. */
847
+ export function coverageKey(coverage) {
848
+ return `${coverage.domain}/${coverage.object}/${coverage.scope}@${coverage.version}`;
849
+ }
1001
850
  const DELTA_KIND_ORDER = {
1002
851
  git: 0,
1003
852
  command: 1,
@@ -1076,6 +925,7 @@ export function deriveObservationDeltas(before, after) {
1076
925
  before: beforeObservation,
1077
926
  after: afterObservation,
1078
927
  changedFiles,
928
+ progress: deltaProgressFact(observation, beforeObservation, afterObservation, changedFiles, ambiguous),
1079
929
  };
1080
930
  });
1081
931
  return deltas.sort((left, right) => DELTA_KIND_ORDER[left.kind] - DELTA_KIND_ORDER[right.kind] ||