loopforge 3.0.0 → 3.6.0

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 (194) hide show
  1. package/README.md +126 -143
  2. package/dist/audit.d.ts +50 -0
  3. package/dist/audit.d.ts.map +1 -0
  4. package/dist/audit.js +131 -0
  5. package/dist/audit.js.map +1 -0
  6. package/dist/canonical-state.d.ts +98 -23
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +257 -181
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +40 -33
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-facts.d.ts +17 -0
  14. package/dist/cognitive-facts.d.ts.map +1 -0
  15. package/dist/cognitive-facts.js +99 -0
  16. package/dist/cognitive-facts.js.map +1 -0
  17. package/dist/cognitive-governance.d.ts +25 -33
  18. package/dist/cognitive-governance.d.ts.map +1 -1
  19. package/dist/cognitive-governance.js +38 -138
  20. package/dist/cognitive-governance.js.map +1 -1
  21. package/dist/committed-round.d.ts +59 -0
  22. package/dist/committed-round.d.ts.map +1 -0
  23. package/dist/committed-round.js +222 -0
  24. package/dist/committed-round.js.map +1 -0
  25. package/dist/enforcement-gate.d.ts +78 -0
  26. package/dist/enforcement-gate.d.ts.map +1 -0
  27. package/dist/enforcement-gate.js +1109 -0
  28. package/dist/enforcement-gate.js.map +1 -0
  29. package/dist/engine.d.ts +111 -0
  30. package/dist/engine.d.ts.map +1 -0
  31. package/dist/engine.js +887 -0
  32. package/dist/engine.js.map +1 -0
  33. package/dist/evidence-claims.d.ts +50 -0
  34. package/dist/evidence-claims.d.ts.map +1 -0
  35. package/dist/evidence-claims.js +116 -0
  36. package/dist/evidence-claims.js.map +1 -0
  37. package/dist/evidence-provider.d.ts +50 -23
  38. package/dist/evidence-provider.d.ts.map +1 -1
  39. package/dist/evidence-provider.js +167 -50
  40. package/dist/evidence-provider.js.map +1 -1
  41. package/dist/generate-schema.js +1 -84
  42. package/dist/generate-schema.js.map +1 -1
  43. package/dist/index.d.ts +19 -22
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +16 -15
  46. package/dist/index.js.map +1 -1
  47. package/dist/loop-compiler.d.ts +58 -3
  48. package/dist/loop-compiler.d.ts.map +1 -1
  49. package/dist/loop-compiler.js +841 -304
  50. package/dist/loop-compiler.js.map +1 -1
  51. package/dist/loop-extras-parser.d.ts +2 -2
  52. package/dist/loop-extras-parser.d.ts.map +1 -1
  53. package/dist/loop-extras-parser.js +2 -2
  54. package/dist/loop-extras-parser.js.map +1 -1
  55. package/dist/loop-projection.d.ts +6 -0
  56. package/dist/loop-projection.d.ts.map +1 -0
  57. package/dist/loop-projection.js +26 -0
  58. package/dist/loop-projection.js.map +1 -0
  59. package/dist/loop-store.d.ts +115 -24
  60. package/dist/loop-store.d.ts.map +1 -1
  61. package/dist/loop-store.js +365 -218
  62. package/dist/loop-store.js.map +1 -1
  63. package/dist/mcp/round-lifecycle.d.ts +214 -0
  64. package/dist/mcp/round-lifecycle.d.ts.map +1 -0
  65. package/dist/mcp/round-lifecycle.js +935 -0
  66. package/dist/mcp/round-lifecycle.js.map +1 -0
  67. package/dist/mcp/server.d.ts +2 -1
  68. package/dist/mcp/server.d.ts.map +1 -1
  69. package/dist/mcp/server.js +29 -43
  70. package/dist/mcp/server.js.map +1 -1
  71. package/dist/mcp/session.d.ts +64 -101
  72. package/dist/mcp/session.d.ts.map +1 -1
  73. package/dist/mcp/session.js +433 -1110
  74. package/dist/mcp/session.js.map +1 -1
  75. package/dist/mcp/tools.d.ts +20 -11
  76. package/dist/mcp/tools.d.ts.map +1 -1
  77. package/dist/mcp/tools.js +587 -413
  78. package/dist/mcp/tools.js.map +1 -1
  79. package/dist/policy-metrics.d.ts +12 -24
  80. package/dist/policy-metrics.d.ts.map +1 -1
  81. package/dist/policy-metrics.js +78 -184
  82. package/dist/policy-metrics.js.map +1 -1
  83. package/dist/policy.d.ts +61 -50
  84. package/dist/policy.d.ts.map +1 -1
  85. package/dist/policy.js +48 -146
  86. package/dist/policy.js.map +1 -1
  87. package/dist/prompt-assembler.d.ts +37 -6
  88. package/dist/prompt-assembler.d.ts.map +1 -1
  89. package/dist/prompt-assembler.js +487 -278
  90. package/dist/prompt-assembler.js.map +1 -1
  91. package/dist/prompt-policy.d.ts +1 -8
  92. package/dist/prompt-policy.d.ts.map +1 -1
  93. package/dist/prompt-policy.js +8 -26
  94. package/dist/prompt-policy.js.map +1 -1
  95. package/dist/protocol.d.ts +398 -236
  96. package/dist/protocol.d.ts.map +1 -1
  97. package/dist/protocol.js +147 -17
  98. package/dist/protocol.js.map +1 -1
  99. package/dist/replay.d.ts +5 -13
  100. package/dist/replay.d.ts.map +1 -1
  101. package/dist/replay.js +32 -69
  102. package/dist/replay.js.map +1 -1
  103. package/dist/round-contract.d.ts +64 -0
  104. package/dist/round-contract.d.ts.map +1 -0
  105. package/dist/round-contract.js +80 -0
  106. package/dist/round-contract.js.map +1 -0
  107. package/dist/round-coordinator.d.ts +23 -25
  108. package/dist/round-coordinator.d.ts.map +1 -1
  109. package/dist/round-coordinator.js +103 -92
  110. package/dist/round-coordinator.js.map +1 -1
  111. package/dist/round-driver.d.ts +13 -13
  112. package/dist/round-driver.d.ts.map +1 -1
  113. package/dist/round-driver.js +25 -14
  114. package/dist/round-driver.js.map +1 -1
  115. package/dist/round-transaction.d.ts +14 -18
  116. package/dist/round-transaction.d.ts.map +1 -1
  117. package/dist/round-transaction.js +37 -47
  118. package/dist/round-transaction.js.map +1 -1
  119. package/dist/self-eval.d.ts +48 -0
  120. package/dist/self-eval.d.ts.map +1 -0
  121. package/dist/self-eval.js +248 -0
  122. package/dist/self-eval.js.map +1 -0
  123. package/dist/storage.d.ts +9 -4
  124. package/dist/storage.d.ts.map +1 -1
  125. package/dist/storage.js +18 -6
  126. package/dist/storage.js.map +1 -1
  127. package/dist/token-utils.d.ts +15 -3
  128. package/dist/token-utils.d.ts.map +1 -1
  129. package/dist/token-utils.js +24 -5
  130. package/dist/token-utils.js.map +1 -1
  131. package/dist/verification-gate.d.ts +149 -0
  132. package/dist/verification-gate.d.ts.map +1 -0
  133. package/dist/verification-gate.js +1343 -0
  134. package/dist/verification-gate.js.map +1 -0
  135. package/dist/workspace.d.ts +29 -0
  136. package/dist/workspace.d.ts.map +1 -0
  137. package/dist/workspace.js +61 -0
  138. package/dist/workspace.js.map +1 -0
  139. package/loop_policy.json +37 -16
  140. package/package.json +5 -10
  141. package/skills/perception/SKILL.md +184 -202
  142. package/dist/canonical-event-writer.d.ts +0 -20
  143. package/dist/canonical-event-writer.d.ts.map +0 -1
  144. package/dist/canonical-event-writer.js +0 -131
  145. package/dist/canonical-event-writer.js.map +0 -1
  146. package/dist/compiler-subgoals.d.ts +0 -7
  147. package/dist/compiler-subgoals.d.ts.map +0 -1
  148. package/dist/compiler-subgoals.js +0 -93
  149. package/dist/compiler-subgoals.js.map +0 -1
  150. package/dist/derive-id.d.ts +0 -12
  151. package/dist/derive-id.d.ts.map +0 -1
  152. package/dist/derive-id.js +0 -31
  153. package/dist/derive-id.js.map +0 -1
  154. package/dist/mcp/session-projection.d.ts +0 -5
  155. package/dist/mcp/session-projection.d.ts.map +0 -1
  156. package/dist/mcp/session-projection.js +0 -18
  157. package/dist/mcp/session-projection.js.map +0 -1
  158. package/dist/mcp/session-queue.d.ts +0 -3
  159. package/dist/mcp/session-queue.d.ts.map +0 -1
  160. package/dist/mcp/session-queue.js +0 -18
  161. package/dist/mcp/session-queue.js.map +0 -1
  162. package/dist/mcp/session-types.d.ts +0 -72
  163. package/dist/mcp/session-types.d.ts.map +0 -1
  164. package/dist/mcp/session-types.js +0 -2
  165. package/dist/mcp/session-types.js.map +0 -1
  166. package/dist/round-commit-store.d.ts +0 -12
  167. package/dist/round-commit-store.d.ts.map +0 -1
  168. package/dist/round-commit-store.js +0 -13
  169. package/dist/round-commit-store.js.map +0 -1
  170. package/dist/round-gate.d.ts +0 -31
  171. package/dist/round-gate.d.ts.map +0 -1
  172. package/dist/round-gate.js +0 -259
  173. package/dist/round-gate.js.map +0 -1
  174. package/dist/round-recovery.d.ts +0 -17
  175. package/dist/round-recovery.d.ts.map +0 -1
  176. package/dist/round-recovery.js +0 -117
  177. package/dist/round-recovery.js.map +0 -1
  178. package/dist/round-report.d.ts +0 -16
  179. package/dist/round-report.d.ts.map +0 -1
  180. package/dist/round-report.js +0 -401
  181. package/dist/round-report.js.map +0 -1
  182. package/dist/runtime-services.d.ts +0 -59
  183. package/dist/runtime-services.d.ts.map +0 -1
  184. package/dist/runtime-services.js +0 -460
  185. package/dist/runtime-services.js.map +0 -1
  186. package/dist/vault-format.d.ts +0 -4
  187. package/dist/vault-format.d.ts.map +0 -1
  188. package/dist/vault-format.js +0 -4
  189. package/dist/vault-format.js.map +0 -1
  190. package/dist/vault-types.d.ts +0 -24
  191. package/dist/vault-types.d.ts.map +0 -1
  192. package/dist/vault-types.js +0 -2
  193. package/dist/vault-types.js.map +0 -1
  194. package/loopforge-policy.json +0 -340
@@ -1,39 +1,128 @@
1
- /** Typed per-loop persistence. Markdown and prompts are derived views. */
1
+ /** Typed per-loop persistence.
2
+ *
3
+ * Layout:
4
+ * .loopforge/loops/<sha256(loopId)>/session.json
5
+ * .loopforge/loops/<sha256(loopId)>/rounds/<round>.json
6
+ *
7
+ * Markdown state files are derived views. These JSON documents are the only
8
+ * durable transaction truth.
9
+ */
2
10
  import { createHash, randomUUID } from "node:crypto";
3
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
4
12
  import { dirname, join, resolve } from "node:path";
5
13
  import { parseRoundTransactionSnapshot } from "./round-transaction.js";
6
14
  import { validateLoopId } from "./policy.js";
7
15
  import { isRecord } from "./token-utils.js";
8
- import { LOOP_EVENT_FORMAT, LOOP_STORE_FORMAT } from "./vault-format.js";
9
- export { LOOP_STORE_FORMAT } from "./vault-format.js";
10
- /** Synchronous sleep for lock backoff. Atomics.wait on a SharedArrayBuffer
11
- * is a zero-dependency sleep that works on all supported platforms
12
- * (including Windows); an ordinary ArrayBuffer would throw a TypeError. */
13
- const BACKOFF_SLOT = new Int32Array(new SharedArrayBuffer(4));
14
- function sleepSync(ms) {
15
- Atomics.wait(BACKOFF_SLOT, 0, 0, ms);
16
- }
16
+ export const LOOP_STORE_SCHEMA_VERSION = 1;
17
+ const SEVERITY_MAP = {
18
+ sequence_gap: "recoverable",
19
+ sequence_duplicate: "recoverable",
20
+ invalid_json: "corrupted",
21
+ invalid_format: "corrupted",
22
+ sequence_cross_loop: "corrupted",
23
+ sequence_invalid: "corrupted",
24
+ };
25
+ /** v2.12: Typed corruption error carrying a severity class. Callers may
26
+ * recover from `recoverable` errors (record and continue) but must surface
27
+ * `corrupted` errors — the runtime never silently repairs storage. */
17
28
  export class StorageCorruptionError extends Error {
18
- kind;
19
- path;
20
- loopId;
21
- sequence;
22
29
  code = "storage_corruption";
23
- recoverable = false;
24
- constructor(kind, path, loopId, sequence, message) {
25
- super(message ?? `Corrupt LoopForge storage (${kind}) at ${path}`);
26
- this.kind = kind;
27
- this.path = path;
28
- this.loopId = loopId;
29
- this.sequence = sequence;
30
+ kind;
31
+ severity;
32
+ constructor(kind, message) {
33
+ super(message);
30
34
  this.name = "StorageCorruptionError";
35
+ this.kind = kind;
36
+ this.severity = SEVERITY_MAP[kind];
37
+ }
38
+ get recoverable() {
39
+ return this.severity === "recoverable";
40
+ }
41
+ }
42
+ /** Zero-dependency synchronous sleep (Atomics.wait on a shared slot). */
43
+ function sleepSync(ms) {
44
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
45
+ }
46
+ /** Filter a loop's flat entry view with the legacy VaultBackend query
47
+ * options. Derived read-only view over the single durable truth (typed
48
+ * session/round documents) — never a separate write path. Feedback
49
+ * entries are excluded by default, mirroring the historical
50
+ * queryEntries({ feedbackOnly }) semantics. */
51
+ export function queryLoopEntries(store, loopId, opts) {
52
+ return store.listEntries(loopId, { sinceRound: opts?.sinceRound }).filter((entry) => {
53
+ const taskId = String(entry.task_id ?? "");
54
+ if (opts?.feedbackOnly && !taskId.endsWith(":feedback"))
55
+ return false;
56
+ if (!opts?.feedbackOnly && taskId.endsWith(":feedback"))
57
+ return false;
58
+ if (opts?.prefix) {
59
+ if (!taskId.startsWith(opts.prefix))
60
+ return false;
61
+ // Guard against ambiguous prefix matches: "loop:x:r1" must not
62
+ // match "loop:x:r10" or "loop:x:r11". Only check when the prefix
63
+ // itself ends with a digit (indicating a specific round number).
64
+ // Prefixes ending in non-digits (e.g. "loop:x:r") match any round
65
+ // and should NOT be filtered.
66
+ const lastChar = opts.prefix[opts.prefix.length - 1];
67
+ if (lastChar !== undefined && /^\d$/.test(lastChar)) {
68
+ const after = taskId[opts.prefix.length];
69
+ if (after !== undefined && /^\d$/.test(after))
70
+ return false;
71
+ }
72
+ }
73
+ return true;
74
+ });
75
+ }
76
+ /** v2.12: Ordered round sequence for a loop. Throws StorageCorruptionError
77
+ * on gaps (recoverable) or mixed-format corruption; returns [] for loops
78
+ * with no rounds. Consumed by audit (sequenceComplete) and resume. */
79
+ export function eventSequence(store, loopId) {
80
+ checkRoundSequence(store, loopId);
81
+ return store.listRoundSequences(loopId).map((doc) => doc.round);
82
+ }
83
+ /** v2.12: Validate that a loop's round documents form a contiguous sequence
84
+ * from 1 to max. Legacy loops (no sequence stamps at all) are exempt.
85
+ * Mixed stamping is allowed only monotonically: rounds below the first
86
+ * stamped round are treated as legacy; once stamping begins it must not
87
+ * stop. Throws StorageCorruptionError on violation. */
88
+ export function checkRoundSequence(store, loopId) {
89
+ const docs = store.listRoundSequences(loopId);
90
+ const stamped = docs.filter((doc) => doc.sequence !== undefined);
91
+ if (stamped.length === 0)
92
+ return { complete: true, legacy: true };
93
+ const firstStamped = Math.min(...stamped.map((doc) => doc.round));
94
+ // Any round at or after the first stamped round must itself be stamped.
95
+ for (const doc of docs) {
96
+ if (doc.round >= firstStamped && doc.sequence === undefined) {
97
+ throw new StorageCorruptionError("sequence_invalid", `Loop ${loopId}: round ${doc.round} is missing its sequence stamp ` +
98
+ `while round ${firstStamped} is stamped (mixed format)`);
99
+ }
31
100
  }
101
+ const stampedRounds = stamped.map((doc) => doc.round);
102
+ const max = Math.max(...stampedRounds);
103
+ const present = new Set(docs.filter((doc) => doc.round >= firstStamped).map((doc) => doc.round));
104
+ for (let round = firstStamped; round <= max; round++) {
105
+ if (!present.has(round)) {
106
+ throw new StorageCorruptionError("sequence_gap", `Loop ${loopId}: round sequence gap at ${round} (max ${max})`);
107
+ }
108
+ }
109
+ // v2.14: rounds below the first stamped round are exempt ONLY for loops
110
+ // without a runtime session document — migration imports may legitimately
111
+ // start at any round, but migration never writes session documents.
112
+ // A stamped loop WITH a session always began at round 1 (write-time
113
+ // continuity), so missing rounds 1..N-1 mean deletion, not import.
114
+ if (firstStamped > 1 && store.readSession(loopId)) {
115
+ throw new StorageCorruptionError("sequence_gap", `Loop ${loopId}: rounds 1..${firstStamped - 1} are missing ` +
116
+ `(first stamped round is ${firstStamped})`);
117
+ }
118
+ return { complete: true, legacy: false };
32
119
  }
33
120
  function loopIdFromEntry(entry) {
34
121
  if (typeof entry.loop_id === "string" && entry.loop_id)
35
122
  return entry.loop_id;
36
123
  const taskId = String(entry.task_id ?? "");
124
+ if (!taskId.startsWith("loop:"))
125
+ return null;
37
126
  const session = taskId.match(/^loop:(.+):session$/);
38
127
  if (session)
39
128
  return session[1];
@@ -41,19 +130,22 @@ function loopIdFromEntry(entry) {
41
130
  return round?.[1] ?? null;
42
131
  }
43
132
  function roundFromEntry(entry) {
44
- const round = entry.loop_lineage?.round;
45
- if (typeof round === "number" && Number.isInteger(round))
46
- return round;
133
+ const data = entry.loop_lineage;
134
+ if (data && typeof data.round === "number" && Number.isInteger(data.round)) {
135
+ return data.round;
136
+ }
47
137
  const match = String(entry.task_id ?? "").match(/:r(\d+)(?::|$)/);
48
138
  return match ? Number(match[1]) : null;
49
139
  }
50
- function isEvent(value) {
51
- return isRecord(value) && value.format === LOOP_EVENT_FORMAT;
140
+ function promptFromSnapshot(snapshot) {
141
+ return snapshot?.promptArtifact;
52
142
  }
53
143
  export class FileLoopStore {
54
144
  root;
55
145
  lockDepth = 0;
56
- constructor(root = ".loopforge") { this.root = resolve(root); }
146
+ constructor(root = ".loopforge") {
147
+ this.root = resolve(root);
148
+ }
57
149
  withLock(fn) {
58
150
  if (this.lockDepth > 0) {
59
151
  this.lockDepth++;
@@ -65,64 +157,57 @@ export class FileLoopStore {
65
157
  }
66
158
  }
67
159
  mkdirSync(this.root, { recursive: true });
68
- const lock = join(this.root, ".store.lock");
69
- const owner = join(lock, "owner.json");
160
+ const lockPath = join(this.root, ".store.lock");
161
+ const ownerPath = join(lockPath, "owner.json");
70
162
  const token = randomUUID();
71
163
  const deadline = Date.now() + 1000;
72
- let spinCount = 0;
73
164
  for (;;) {
74
165
  try {
75
- mkdirSync(lock);
76
- writeFileSync(owner, JSON.stringify({ token, pid: process.pid }));
166
+ mkdirSync(lockPath);
167
+ writeFileSync(ownerPath, JSON.stringify({ token, pid: process.pid, createdAt: Date.now() }));
77
168
  break;
78
169
  }
79
170
  catch {
80
171
  let stale = false;
172
+ let age = 0;
81
173
  try {
82
- const info = JSON.parse(readFileSync(owner, "utf8"));
83
- const age = Date.now() - statSync(lock).mtimeMs;
84
- if (age > 5000) {
85
- // Unreadable owner (orphaned lock) or a self-owned lock (crashed
86
- // during release) is reclaimable; otherwise probe the pid. The
87
- // 5s age gate keeps the mkdir→owner.json race window out of the
88
- // reclaim path.
89
- if (typeof info.pid !== "number" || info.pid === process.pid)
90
- stale = true;
91
- else
92
- try {
93
- process.kill(info.pid, 0);
94
- }
95
- catch (error) {
96
- const code = error.code;
97
- stale = code === "ESRCH" || (code === "EPERM" && age > 10000);
98
- }
99
- }
174
+ age = Date.now() - statSync(lockPath).mtimeMs;
100
175
  }
101
176
  catch {
102
- // owner.json unreadable: an orphan left by a crash between mkdir
103
- // and owner.json. The age gate keeps the mkdir→owner.json race
104
- // window out of the reclaim path (that write completes in ms).
105
- let age = -1;
177
+ age = 0;
178
+ }
179
+ let owner = null;
180
+ try {
181
+ owner = JSON.parse(readFileSync(ownerPath, "utf8"));
182
+ }
183
+ catch {
184
+ // v2.14: a crash between mkdir(lock) and write(owner.json) leaves
185
+ // a lock directory without an owner. That window is microseconds,
186
+ // so after a short grace the lock is provably stale — previously
187
+ // this state caused a permanent lock until manual deletion.
188
+ stale = age > 500;
189
+ }
190
+ if (!stale && owner && age > 5000 && typeof owner.pid === "number") {
106
191
  try {
107
- age = Date.now() - statSync(lock).mtimeMs;
192
+ process.kill(owner.pid, 0);
193
+ }
194
+ catch (error) {
195
+ // On Windows, EPERM may be returned for dead cross-user
196
+ // processes. Treat as stale when the lock is old regardless.
197
+ const code = error.code;
198
+ stale = code === "ESRCH"
199
+ || (code === "EPERM" && age > 10_000); // Windows safety: EPERM + old lock → stale
108
200
  }
109
- catch { /* lock vanished; next spin retries */ }
110
- if (age > 5000)
111
- stale = true;
112
201
  }
113
202
  if (stale) {
114
203
  try {
115
- rmSync(lock, { recursive: true });
204
+ rmSync(lockPath, { recursive: true });
116
205
  }
117
206
  catch { /* race */ }
118
207
  continue;
119
208
  }
120
209
  if (Date.now() >= deadline)
121
210
  throw new Error("LoopStore lock timeout (1000ms)");
122
- // Exponential backoff (2→4→8→16→32ms) instead of a hot spin; the
123
- // deadline above still bounds the total wait.
124
- sleepSync(Math.min(2 << Math.min(spinCount, 4), 32));
125
- spinCount++;
126
211
  }
127
212
  }
128
213
  this.lockDepth = 1;
@@ -132,205 +217,263 @@ export class FileLoopStore {
132
217
  finally {
133
218
  this.lockDepth = 0;
134
219
  try {
135
- const current = JSON.parse(readFileSync(owner, "utf8"));
136
- if (current.token === token)
137
- rmSync(lock, { recursive: true });
220
+ const owner = JSON.parse(readFileSync(ownerPath, "utf8"));
221
+ if (owner.token === token)
222
+ rmSync(lockPath, { recursive: true });
138
223
  }
139
- catch { /* never remove another owner's lock */ }
224
+ catch { /* never delete an unowned lock */ }
140
225
  }
141
226
  }
142
227
  listLoopIds() {
143
- const root = join(this.root, "loops");
144
- if (!existsSync(root))
228
+ const loops = join(this.root, "loops");
229
+ if (!existsSync(loops))
145
230
  return [];
146
- const ids = [];
147
- for (const name of readdirSync(root)) {
148
- const metadataPath = join(root, name, "metadata.json");
149
- const metadata = this.readJson(metadataPath);
150
- if (metadata === null)
231
+ const result = [];
232
+ for (const name of readdirSync(loops)) {
233
+ const dir = join(loops, name);
234
+ const metadata = this.readJson(join(dir, "metadata.json"));
235
+ if (isRecord(metadata) && typeof metadata.loopId === "string") {
236
+ result.push(metadata.loopId);
151
237
  continue;
152
- if (!isRecord(metadata) || metadata.format !== LOOP_STORE_FORMAT ||
153
- typeof metadata.loopId !== "string" || !metadata.loopId) {
154
- throw new StorageCorruptionError("invalid_format", metadataPath);
155
238
  }
156
- ids.push(metadata.loopId);
239
+ // v2.14: orphaned loop dirs (sessions written before metadata
240
+ // stamping) recover their loopId from the session document itself.
241
+ const session = this.readJson(join(dir, "session.json"));
242
+ if (isRecord(session) && typeof session.loopId === "string") {
243
+ result.push(session.loopId);
244
+ }
157
245
  }
158
- return ids.sort();
246
+ return result.sort();
159
247
  }
160
248
  readSession(loopId) {
161
249
  validateLoopId(loopId);
162
- const path = join(this.loopDir(loopId), "session.json");
163
- const value = this.readJson(path);
250
+ const value = this.readJson(join(this.loopDir(loopId), "session.json"));
251
+ // readJson returns null only for a missing file; an existing document
252
+ // with an unexpected shape is structural corruption, never silently
253
+ // treated as "missing" (which would let a later write overwrite it).
164
254
  if (value === null)
165
255
  return null;
166
- if (!isRecord(value) || value.format !== LOOP_STORE_FORMAT || value.loopId !== loopId || !isEvent(value.entry)) {
167
- throw new StorageCorruptionError("invalid_format", path, loopId);
168
- }
169
- if (!Number.isInteger(value.entry.event_sequence) || value.entry.event_sequence < 1) {
170
- throw new StorageCorruptionError("sequence_invalid", path, loopId, value.entry.event_sequence);
256
+ if (!isRecord(value) || value.schemaVersion !== LOOP_STORE_SCHEMA_VERSION ||
257
+ value.loopId !== loopId || !isRecord(value.entry)) {
258
+ throw new StorageCorruptionError("invalid_format", `Loop ${loopId}: session.json has an unexpected document shape`);
171
259
  }
172
260
  return value;
173
261
  }
174
262
  writeSession(loopId, document) {
175
263
  validateLoopId(loopId);
176
- const entry = this.withEventSequence(loopId, { format: LOOP_EVENT_FORMAT, ...document.entry });
177
- this.atomicWrite(join(this.loopDir(loopId), "session.json"), { ...document, format: LOOP_STORE_FORMAT, entry });
264
+ const dir = this.loopDir(loopId);
265
+ // v2.14: stamp metadata alongside the session document — a session with
266
+ // no committed rounds yet (created, then crashed before round 1) must
267
+ // still be discoverable via listLoopIds / auto-resume.
268
+ this.atomicWrite(join(dir, "metadata.json"), {
269
+ schemaVersion: LOOP_STORE_SCHEMA_VERSION,
270
+ loopId,
271
+ });
272
+ this.atomicWrite(join(dir, "session.json"), document);
178
273
  }
179
274
  readRound(loopId, round) {
180
275
  validateLoopId(loopId);
181
276
  if (!Number.isInteger(round) || round < 1)
182
277
  return null;
183
- const path = join(this.loopDir(loopId), "rounds", `${round}.json`);
184
- const value = this.readJson(path);
278
+ const value = this.readJson(join(this.loopDir(loopId), "rounds", `${round}.json`));
279
+ // readJson returns null only for a missing file; an existing document
280
+ // with an unexpected shape is structural corruption — surfacing it
281
+ // (rather than returning null) prevents silent overwrite by writeEntry.
185
282
  if (value === null)
186
283
  return null;
187
- if (!isRecord(value) || value.format !== LOOP_STORE_FORMAT || value.loopId !== loopId || value.round !== round) {
188
- throw new StorageCorruptionError("invalid_format", path, loopId);
284
+ if (!isRecord(value) || value.schemaVersion !== LOOP_STORE_SCHEMA_VERSION ||
285
+ value.loopId !== loopId || value.round !== round) {
286
+ throw new StorageCorruptionError("invalid_format", `Loop ${loopId}: round ${round} document has an unexpected shape`);
189
287
  }
190
- const tx = parseRoundTransactionSnapshot(value.transaction);
191
- if (value.transaction !== undefined && !tx) {
192
- throw new StorageCorruptionError("invalid_format", path, loopId);
288
+ // v2.12: a stamped document whose stamp disagrees with its filename is
289
+ // structural corruption, never silently repaired.
290
+ if (typeof value.sequence === "number" && value.sequence !== round) {
291
+ throw new StorageCorruptionError("sequence_invalid", `Loop ${loopId}: round ${round} document carries sequence ${value.sequence}`);
193
292
  }
194
- const invalidEvent = [value.lineage, value.roundEvent]
195
- .filter((entry) => entry !== undefined && !isEvent(entry)).length > 0;
196
- if (invalidEvent || (value.events !== undefined &&
197
- (!Array.isArray(value.events) || value.events.some((entry) => !isEvent(entry))))) {
198
- throw new StorageCorruptionError("invalid_format", path, loopId);
199
- }
200
- for (const event of [value.lineage, value.roundEvent, ...(Array.isArray(value.events) ? value.events : [])]) {
201
- if (event !== undefined && (!Number.isInteger(event.event_sequence) || event.event_sequence < 1)) {
202
- throw new StorageCorruptionError("sequence_invalid", path, loopId, event.event_sequence);
203
- }
293
+ const events = Array.isArray(value.events)
294
+ ? value.events.filter(isRecord)
295
+ : [];
296
+ const transaction = parseRoundTransactionSnapshot(value.transaction);
297
+ return {
298
+ schemaVersion: LOOP_STORE_SCHEMA_VERSION,
299
+ loopId,
300
+ round,
301
+ sequence: typeof value.sequence === "number" ? value.sequence : undefined,
302
+ updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : "",
303
+ lineage: isRecord(value.lineage) ? value.lineage : undefined,
304
+ feedback: isRecord(value.feedback) ? value.feedback : undefined,
305
+ transaction: transaction ?? undefined,
306
+ promptArtifact: transaction?.promptArtifact,
307
+ events,
308
+ };
309
+ }
310
+ listRoundSequences(loopId) {
311
+ validateLoopId(loopId);
312
+ const roundsDir = join(this.loopDir(loopId), "rounds");
313
+ if (!existsSync(roundsDir))
314
+ return [];
315
+ const result = [];
316
+ for (const file of readdirSync(roundsDir).filter((name) => /^\d+\.json$/.test(name))) {
317
+ const round = Number(file.slice(0, -5));
318
+ const value = this.readJson(join(roundsDir, file));
319
+ if (!isRecord(value) || value.loopId !== loopId || value.round !== round)
320
+ continue;
321
+ result.push({
322
+ round,
323
+ sequence: typeof value.sequence === "number" ? value.sequence : undefined,
324
+ });
204
325
  }
205
- return { format: LOOP_STORE_FORMAT, loopId, round, updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : "", lineage: isEvent(value.lineage) ? value.lineage : undefined, roundEvent: isEvent(value.roundEvent) ? value.roundEvent : undefined, transaction: tx ?? undefined, promptArtifact: tx?.promptArtifact, events: Array.isArray(value.events) ? value.events : [] };
326
+ return result.sort((a, b) => a.round - b.round);
206
327
  }
207
- listEntries(loopId) {
328
+ listEntries(loopId, opts) {
329
+ const ids = loopId ? [loopId] : this.listLoopIds();
208
330
  const result = [];
209
- for (const id of loopId ? [loopId] : this.listLoopIds()) {
331
+ for (const id of ids) {
210
332
  const session = this.readSession(id);
211
333
  if (session)
212
334
  result.push(session.entry);
213
- const dir = join(this.loopDir(id), "rounds");
214
- if (!existsSync(dir))
335
+ const roundsDir = join(this.loopDir(id), "rounds");
336
+ if (!existsSync(roundsDir))
215
337
  continue;
216
- for (const file of readdirSync(dir).filter((name) => /^\d+\.json$/.test(name))) {
217
- const round = this.readRound(id, Number(file.slice(0, -5)));
218
- if (!round)
338
+ // Numeric sort — readdirSync order is filesystem-dependent and must
339
+ // not leak into the flat entry view (rounds 1, 10, 2 … would be).
340
+ for (const file of readdirSync(roundsDir)
341
+ .filter((name) => /^\d+\.json$/.test(name))
342
+ .sort((a, b) => Number(a.slice(0, -5)) - Number(b.slice(0, -5)))) {
343
+ const round = Number(file.slice(0, -5));
344
+ // v3.0.1: incremental reads skip older round documents entirely —
345
+ // this is what bounds per-compile I/O in long loops.
346
+ if (opts?.sinceRound !== undefined && round < opts.sinceRound)
219
347
  continue;
220
- if (round.lineage)
221
- result.push({ ...round.lineage, full_prompt: round.promptArtifact?.renderedPrompt ?? round.lineage.full_prompt });
222
- if (round.roundEvent)
223
- result.push(round.roundEvent);
224
- result.push(...round.events);
348
+ const doc = this.readRound(id, round);
349
+ if (!doc)
350
+ continue;
351
+ if (doc.lineage) {
352
+ result.push({
353
+ ...doc.lineage,
354
+ full_prompt: doc.promptArtifact?.renderedPrompt ?? doc.lineage.full_prompt,
355
+ });
356
+ }
357
+ if (doc.feedback)
358
+ result.push(doc.feedback);
359
+ result.push(...doc.events);
225
360
  }
226
361
  }
227
362
  return result;
228
363
  }
229
- queryLoopEvents(loopId) {
230
- const seen = new Set();
231
- const entries = this.listEntries(loopId).filter((entry) => {
232
- const derived = loopIdFromEntry(entry);
233
- if (entry.loop_id !== undefined && entry.loop_id !== loopId) {
234
- throw new StorageCorruptionError("sequence_cross_loop", this.loopDir(loopId), loopId);
235
- }
236
- if (derived !== loopId) {
237
- throw new StorageCorruptionError("sequence_cross_loop", this.loopDir(loopId), loopId);
238
- }
239
- return true;
240
- }).filter((entry) => {
241
- const key = `${entry.task_id ?? entry.id ?? ""}:${entry.event_sequence ?? ""}`;
242
- if (seen.has(key)) {
243
- throw new StorageCorruptionError("sequence_duplicate", this.loopDir(loopId), loopId, entry.event_sequence);
244
- }
245
- seen.add(key);
246
- return true;
247
- }).sort((a, b) => {
248
- const sa = a.event_sequence;
249
- const sb = b.event_sequence;
250
- if (!Number.isInteger(sa) || !Number.isInteger(sb) || sa < 1 || sb < 1) {
251
- throw new StorageCorruptionError("sequence_invalid", this.loopDir(loopId), loopId);
364
+ appendEntry(entry) {
365
+ this.withLock(() => this.writeEntry(entry));
366
+ }
367
+ appendEntries(entries) {
368
+ return this.withLock(() => {
369
+ for (const entry of entries)
370
+ this.writeEntry(entry);
371
+ return entries.length;
372
+ });
373
+ }
374
+ migrateLegacyVault(path = ".promptcraft/prompt_vault.json") {
375
+ const source = resolve(path);
376
+ const marker = join(this.root, "migrations", "promptcraft-v1.json");
377
+ if (existsSync(marker)) {
378
+ return { source, imported: 0, skipped: 0, alreadyMigrated: true };
379
+ }
380
+ let imported = 0;
381
+ let skipped = 0;
382
+ const raw = this.readJson(source);
383
+ const entries = isRecord(raw) && Array.isArray(raw.entries)
384
+ ? raw.entries.filter(isRecord)
385
+ : [];
386
+ this.withLock(() => {
387
+ const existing = new Set(this.listEntries().map((entry) => String(entry.task_id ?? "")));
388
+ for (const entry of entries) {
389
+ if (!loopIdFromEntry(entry) || existing.has(String(entry.task_id ?? ""))) {
390
+ skipped++;
391
+ continue;
392
+ }
393
+ this.writeEntry(entry, { allowGap: true });
394
+ imported++;
252
395
  }
253
- return sa - sb;
396
+ this.atomicWrite(marker, {
397
+ schemaVersion: LOOP_STORE_SCHEMA_VERSION,
398
+ source,
399
+ imported,
400
+ skipped,
401
+ migratedAt: new Date().toISOString(),
402
+ });
254
403
  });
255
- this.assertSequence(entries, loopId);
256
- return entries;
404
+ return { source, imported, skipped, alreadyMigrated: false };
257
405
  }
258
- appendEntry(entry) { this.withLock(() => this.writeEntry(entry)); }
259
- appendEntries(entries) { return this.withLock(() => { for (const entry of entries)
260
- this.writeEntry(entry); return entries.length; }); }
261
- replaceEntries(entries) { this.withLock(() => { for (const entry of entries)
262
- this.writeEntry(entry); }); }
263
- writeEntry(input) {
264
- const loopId = loopIdFromEntry(input);
406
+ writeEntry(entry, opts) {
407
+ const loopId = loopIdFromEntry(entry);
265
408
  if (!loopId)
266
409
  throw new Error("LoopStore only accepts loop-scoped entries");
267
410
  validateLoopId(loopId);
268
- if (input.loop_id !== undefined && input.loop_id !== loopId) {
269
- throw new StorageCorruptionError("sequence_cross_loop", this.loopDir(loopId), loopId);
270
- }
271
- if (input.format !== undefined && input.format !== LOOP_EVENT_FORMAT)
272
- throw new Error("LoopStore rejected an event with an unknown format");
273
- const entry = this.withEventSequence(loopId, { format: LOOP_EVENT_FORMAT, ...input });
274
411
  const now = new Date().toISOString();
275
412
  const dir = this.loopDir(loopId);
276
- // metadata.json holds only {format, loopId} and changes never: rewrite it
277
- // only when missing (crash-consistent self-healing — a deleted metadata
278
- // file is rebuilt on the next write, and listLoopIds recovers in turn).
279
- const metadataPath = join(dir, "metadata.json");
280
- if (!existsSync(metadataPath))
281
- this.atomicWrite(metadataPath, { format: LOOP_STORE_FORMAT, loopId });
413
+ this.atomicWrite(join(dir, "metadata.json"), {
414
+ schemaVersion: LOOP_STORE_SCHEMA_VERSION,
415
+ loopId,
416
+ });
282
417
  if (entry.task_type === "session_state" || entry.task_id === `loop:${loopId}:session`) {
283
- this.atomicWrite(join(dir, "session.json"), { format: LOOP_STORE_FORMAT, loopId, updatedAt: now, entry });
418
+ this.atomicWrite(join(dir, "session.json"), {
419
+ schemaVersion: LOOP_STORE_SCHEMA_VERSION,
420
+ loopId,
421
+ updatedAt: now,
422
+ entry,
423
+ });
284
424
  return;
285
425
  }
286
426
  const round = roundFromEntry(entry);
287
427
  if (!round)
288
428
  throw new Error(`Loop entry has no round: ${entry.task_id ?? "unknown"}`);
289
- const current = this.readRound(loopId, round) ?? { format: LOOP_STORE_FORMAT, loopId, round, updatedAt: now, events: [] };
429
+ // v2.12: monotonic write-time check — a stamped loop may not skip its
430
+ // predecessor. Legacy imports (allowGap) bypass this; the load-time
431
+ // scan in checkRoundSequence remains the backstop.
432
+ if (!opts?.allowGap && round > 1) {
433
+ const previous = this.readRound(loopId, round - 1);
434
+ if (!previous && this.listRoundSequences(loopId).some((doc) => doc.sequence !== undefined)) {
435
+ throw new StorageCorruptionError("sequence_gap", `Loop ${loopId}: cannot write round ${round}; round ${round - 1} is missing`);
436
+ }
437
+ }
438
+ const current = this.readRound(loopId, round) ?? {
439
+ schemaVersion: LOOP_STORE_SCHEMA_VERSION,
440
+ loopId,
441
+ round,
442
+ sequence: round,
443
+ updatedAt: now,
444
+ events: [],
445
+ };
446
+ if (current.sequence === undefined)
447
+ current.sequence = round;
290
448
  const taskId = String(entry.task_id ?? "");
291
- if (taskId.endsWith(":event"))
292
- current.roundEvent = entry;
293
- else if (entry.task_type === "loop_lineage" || taskId === `loop:${loopId}:r${round}`)
449
+ if (taskId.endsWith(":feedback"))
450
+ current.feedback = entry;
451
+ else if (entry.task_type === "loop_lineage" || taskId === `loop:${loopId}:r${round}`) {
294
452
  current.lineage = entry;
295
- else
296
- current.events = [...current.events.filter((event) => event.task_id !== entry.task_id), entry];
297
- const raw = current.roundEvent?.loop_lineage?.round_transaction ?? entry.loop_lineage?.round_transaction;
298
- const tx = parseRoundTransactionSnapshot(isRecord(raw) ? raw.snapshot : undefined);
299
- if (tx) {
300
- current.transaction = tx;
301
- current.promptArtifact = tx.promptArtifact;
453
+ }
454
+ else {
455
+ current.events = [
456
+ ...current.events.filter((event) => event.task_id !== entry.task_id),
457
+ entry,
458
+ ];
459
+ }
460
+ const transactionRaw = current.feedback?.loop_lineage?.round_transaction ??
461
+ entry.loop_lineage?.round_transaction;
462
+ const transaction = parseRoundTransactionSnapshot(isRecord(transactionRaw) ? transactionRaw.snapshot : undefined);
463
+ if (transaction) {
464
+ current.transaction = transaction;
465
+ current.promptArtifact = promptFromSnapshot(transaction);
302
466
  }
303
467
  current.updatedAt = now;
304
468
  this.atomicWrite(join(dir, "rounds", `${round}.json`), current);
305
469
  }
306
- withEventSequence(loopId, entry) {
307
- const existing = this.listEntries(loopId);
308
- this.assertSequence(existing, loopId);
309
- const prior = existing.find((candidate) => candidate.task_id === entry.task_id);
310
- if (prior && Number.isInteger(prior.event_sequence)) {
311
- if (entry.event_sequence === undefined)
312
- return { ...entry, event_sequence: prior.event_sequence };
313
- if (entry.event_sequence === prior.event_sequence)
314
- return entry;
315
- throw new StorageCorruptionError("sequence_invalid", this.loopDir(loopId), loopId, entry.event_sequence, "An existing event cannot change its event_sequence");
316
- }
317
- const used = new Set(existing.map((candidate) => candidate.event_sequence).filter((value) => typeof value === "number" && Number.isInteger(value) && value > 0));
318
- const next = used.size ? Math.max(...used) + 1 : 1;
319
- if (entry.event_sequence !== undefined) {
320
- if (!Number.isInteger(entry.event_sequence) || entry.event_sequence < 1) {
321
- throw new StorageCorruptionError("sequence_invalid", this.loopDir(loopId), loopId, entry.event_sequence);
322
- }
323
- if (used.has(entry.event_sequence)) {
324
- throw new StorageCorruptionError("sequence_duplicate", this.loopDir(loopId), loopId, entry.event_sequence);
325
- }
326
- if (entry.event_sequence !== next) {
327
- throw new StorageCorruptionError("sequence_gap", this.loopDir(loopId), loopId, entry.event_sequence);
328
- }
329
- return entry;
330
- }
331
- return { ...entry, event_sequence: next };
470
+ loopDir(loopId) {
471
+ const hash = createHash("sha256").update(loopId).digest("hex");
472
+ return join(this.root, "loops", hash);
332
473
  }
333
- loopDir(loopId) { return join(this.root, "loops", createHash("sha256").update(loopId).digest("hex")); }
474
+ /** Read a JSON document, distinguishing missing from corrupt.
475
+ * ENOENT → null (missing); parse failure → StorageCorruptionError
476
+ * (corrupted) — the runtime never silently repairs storage. */
334
477
  readJson(path) {
335
478
  try {
336
479
  return JSON.parse(readFileSync(path, "utf8"));
@@ -338,26 +481,30 @@ export class FileLoopStore {
338
481
  catch (error) {
339
482
  if (error.code === "ENOENT")
340
483
  return null;
341
- throw new StorageCorruptionError("invalid_json", path, undefined, undefined, `Invalid JSON in ${path}`);
484
+ throw new StorageCorruptionError("invalid_json", `Corrupt JSON at ${path}: ${error.message}`);
342
485
  }
343
486
  }
344
- assertSequence(entries, loopId) {
345
- const seen = new Set();
346
- const sorted = [...entries].sort((a, b) => (a.event_sequence ?? 0) - (b.event_sequence ?? 0));
347
- for (let index = 0; index < sorted.length; index++) {
348
- const sequence = sorted[index].event_sequence;
349
- if (!Number.isInteger(sequence) || sequence < 1) {
350
- throw new StorageCorruptionError("sequence_invalid", this.loopDir(loopId), loopId, sequence);
351
- }
352
- if (seen.has(sequence)) {
353
- throw new StorageCorruptionError("sequence_duplicate", this.loopDir(loopId), loopId, sequence);
487
+ atomicWrite(path, value) {
488
+ mkdirSync(dirname(path), { recursive: true });
489
+ const writeOnce = () => {
490
+ const temporary = `${path}.tmp.${randomUUID().slice(0, 8)}`;
491
+ writeFileSync(temporary, JSON.stringify(value, null, 2), "utf8");
492
+ renameSync(temporary, path);
493
+ };
494
+ // v2.12: transient I/O failures (Windows rename EPERM/EACCES) are
495
+ // retried once after a short backoff; a second failure bubbles.
496
+ try {
497
+ writeOnce();
498
+ }
499
+ catch (first) {
500
+ sleepSync(500);
501
+ try {
502
+ writeOnce();
354
503
  }
355
- seen.add(sequence);
356
- if (sequence !== index + 1) {
357
- throw new StorageCorruptionError("sequence_gap", this.loopDir(loopId), loopId, sequence);
504
+ catch (second) {
505
+ throw second;
358
506
  }
359
507
  }
360
508
  }
361
- atomicWrite(path, value) { mkdirSync(dirname(path), { recursive: true }); const temporary = `${path}.tmp.${randomUUID().slice(0, 8)}`; writeFileSync(temporary, JSON.stringify(value, null, 2), "utf8"); renameSync(temporary, path); }
362
509
  }
363
510
  //# sourceMappingURL=loop-store.js.map