loopforge 2.11.0 → 3.0.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 (166) hide show
  1. package/README.md +145 -128
  2. package/dist/canonical-event-writer.d.ts +20 -0
  3. package/dist/canonical-event-writer.d.ts.map +1 -0
  4. package/dist/canonical-event-writer.js +131 -0
  5. package/dist/canonical-event-writer.js.map +1 -0
  6. package/dist/canonical-state.d.ts +20 -16
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +170 -61
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +11 -10
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-governance.d.ts +48 -0
  14. package/dist/cognitive-governance.d.ts.map +1 -0
  15. package/dist/cognitive-governance.js +226 -0
  16. package/dist/cognitive-governance.js.map +1 -0
  17. package/dist/compiler-subgoals.d.ts +7 -0
  18. package/dist/compiler-subgoals.d.ts.map +1 -0
  19. package/dist/compiler-subgoals.js +93 -0
  20. package/dist/compiler-subgoals.js.map +1 -0
  21. package/dist/derive-id.d.ts +12 -0
  22. package/dist/derive-id.d.ts.map +1 -0
  23. package/dist/derive-id.js +31 -0
  24. package/dist/derive-id.js.map +1 -0
  25. package/dist/evidence-provider.d.ts +23 -21
  26. package/dist/evidence-provider.d.ts.map +1 -1
  27. package/dist/evidence-provider.js +34 -74
  28. package/dist/evidence-provider.js.map +1 -1
  29. package/dist/generate-schema.js +90 -2
  30. package/dist/generate-schema.js.map +1 -1
  31. package/dist/index.d.ts +22 -19
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +15 -16
  34. package/dist/index.js.map +1 -1
  35. package/dist/loop-compiler.d.ts +1 -1
  36. package/dist/loop-compiler.d.ts.map +1 -1
  37. package/dist/loop-compiler.js +250 -337
  38. package/dist/loop-compiler.js.map +1 -1
  39. package/dist/loop-extras-parser.d.ts +2 -2
  40. package/dist/loop-extras-parser.d.ts.map +1 -1
  41. package/dist/loop-extras-parser.js +2 -2
  42. package/dist/loop-extras-parser.js.map +1 -1
  43. package/dist/loop-store.d.ts +20 -58
  44. package/dist/loop-store.d.ts.map +1 -1
  45. package/dist/loop-store.js +232 -309
  46. package/dist/loop-store.js.map +1 -1
  47. package/dist/mcp/server.d.ts +3 -3
  48. package/dist/mcp/server.d.ts.map +1 -1
  49. package/dist/mcp/server.js +83 -13
  50. package/dist/mcp/server.js.map +1 -1
  51. package/dist/mcp/session-projection.d.ts +5 -0
  52. package/dist/mcp/session-projection.d.ts.map +1 -0
  53. package/dist/mcp/session-projection.js +18 -0
  54. package/dist/mcp/session-projection.js.map +1 -0
  55. package/dist/mcp/session-queue.d.ts +3 -0
  56. package/dist/mcp/session-queue.d.ts.map +1 -0
  57. package/dist/mcp/session-queue.js +18 -0
  58. package/dist/mcp/session-queue.js.map +1 -0
  59. package/dist/mcp/session-types.d.ts +72 -0
  60. package/dist/mcp/session-types.d.ts.map +1 -0
  61. package/dist/mcp/session-types.js +2 -0
  62. package/dist/mcp/session-types.js.map +1 -0
  63. package/dist/mcp/session.d.ts +49 -93
  64. package/dist/mcp/session.d.ts.map +1 -1
  65. package/dist/mcp/session.js +544 -245
  66. package/dist/mcp/session.js.map +1 -1
  67. package/dist/mcp/tools.d.ts +26 -1
  68. package/dist/mcp/tools.d.ts.map +1 -1
  69. package/dist/mcp/tools.js +399 -274
  70. package/dist/mcp/tools.js.map +1 -1
  71. package/dist/policy-metrics.d.ts +17 -1
  72. package/dist/policy-metrics.d.ts.map +1 -1
  73. package/dist/policy-metrics.js +164 -1
  74. package/dist/policy-metrics.js.map +1 -1
  75. package/dist/policy.d.ts +34 -18
  76. package/dist/policy.d.ts.map +1 -1
  77. package/dist/policy.js +113 -18
  78. package/dist/policy.js.map +1 -1
  79. package/dist/prompt-assembler.d.ts +5 -3
  80. package/dist/prompt-assembler.d.ts.map +1 -1
  81. package/dist/prompt-assembler.js +126 -77
  82. package/dist/prompt-assembler.js.map +1 -1
  83. package/dist/prompt-policy.d.ts +8 -1
  84. package/dist/prompt-policy.d.ts.map +1 -1
  85. package/dist/prompt-policy.js +26 -8
  86. package/dist/prompt-policy.js.map +1 -1
  87. package/dist/protocol.d.ts +232 -192
  88. package/dist/protocol.d.ts.map +1 -1
  89. package/dist/protocol.js +15 -100
  90. package/dist/protocol.js.map +1 -1
  91. package/dist/replay.d.ts +11 -3
  92. package/dist/replay.d.ts.map +1 -1
  93. package/dist/replay.js +51 -14
  94. package/dist/replay.js.map +1 -1
  95. package/dist/round-commit-store.d.ts +12 -0
  96. package/dist/round-commit-store.d.ts.map +1 -0
  97. package/dist/round-commit-store.js +13 -0
  98. package/dist/round-commit-store.js.map +1 -0
  99. package/dist/round-coordinator.d.ts +25 -18
  100. package/dist/round-coordinator.d.ts.map +1 -1
  101. package/dist/round-coordinator.js +83 -71
  102. package/dist/round-coordinator.js.map +1 -1
  103. package/dist/round-driver.d.ts +14 -10
  104. package/dist/round-driver.d.ts.map +1 -1
  105. package/dist/round-driver.js +9 -13
  106. package/dist/round-driver.js.map +1 -1
  107. package/dist/round-gate.d.ts +31 -0
  108. package/dist/round-gate.d.ts.map +1 -0
  109. package/dist/round-gate.js +259 -0
  110. package/dist/round-gate.js.map +1 -0
  111. package/dist/round-recovery.d.ts +17 -0
  112. package/dist/round-recovery.d.ts.map +1 -0
  113. package/dist/round-recovery.js +117 -0
  114. package/dist/round-recovery.js.map +1 -0
  115. package/dist/round-report.d.ts +16 -0
  116. package/dist/round-report.d.ts.map +1 -0
  117. package/dist/round-report.js +401 -0
  118. package/dist/round-report.js.map +1 -0
  119. package/dist/round-transaction.d.ts +19 -12
  120. package/dist/round-transaction.d.ts.map +1 -1
  121. package/dist/round-transaction.js +102 -88
  122. package/dist/round-transaction.js.map +1 -1
  123. package/dist/runtime-services.d.ts +59 -0
  124. package/dist/runtime-services.d.ts.map +1 -0
  125. package/dist/runtime-services.js +460 -0
  126. package/dist/runtime-services.js.map +1 -0
  127. package/dist/storage.d.ts +3 -9
  128. package/dist/storage.d.ts.map +1 -1
  129. package/dist/storage.js +6 -17
  130. package/dist/storage.js.map +1 -1
  131. package/dist/token-utils.d.ts +9 -8
  132. package/dist/token-utils.d.ts.map +1 -1
  133. package/dist/token-utils.js +50 -10
  134. package/dist/token-utils.js.map +1 -1
  135. package/dist/vault-format.d.ts +4 -0
  136. package/dist/vault-format.d.ts.map +1 -0
  137. package/dist/vault-format.js +4 -0
  138. package/dist/vault-format.js.map +1 -0
  139. package/dist/vault-types.d.ts +24 -0
  140. package/dist/vault-types.d.ts.map +1 -0
  141. package/dist/vault-types.js +2 -0
  142. package/dist/vault-types.js.map +1 -0
  143. package/loop_policy.json +10 -12
  144. package/loopforge-policy.json +340 -0
  145. package/package.json +12 -6
  146. package/skills/perception/SKILL.md +199 -164
  147. package/dist/backends/interface.d.ts +0 -39
  148. package/dist/backends/interface.d.ts.map +0 -1
  149. package/dist/backends/interface.js +0 -6
  150. package/dist/backends/interface.js.map +0 -1
  151. package/dist/enforcement-gate.d.ts +0 -71
  152. package/dist/enforcement-gate.d.ts.map +0 -1
  153. package/dist/enforcement-gate.js +0 -744
  154. package/dist/enforcement-gate.js.map +0 -1
  155. package/dist/engine.d.ts +0 -59
  156. package/dist/engine.d.ts.map +0 -1
  157. package/dist/engine.js +0 -596
  158. package/dist/engine.js.map +0 -1
  159. package/dist/self-eval.d.ts +0 -23
  160. package/dist/self-eval.d.ts.map +0 -1
  161. package/dist/self-eval.js +0 -181
  162. package/dist/self-eval.js.map +0 -1
  163. package/dist/verification-gate.d.ts +0 -56
  164. package/dist/verification-gate.d.ts.map +0 -1
  165. package/dist/verification-gate.js +0 -563
  166. package/dist/verification-gate.js.map +0 -1
@@ -1,25 +1,39 @@
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
- */
1
+ /** Typed per-loop persistence. Markdown and prompts are derived views. */
10
2
  import { createHash, randomUUID } from "node:crypto";
11
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
12
4
  import { dirname, join, resolve } from "node:path";
13
5
  import { parseRoundTransactionSnapshot } from "./round-transaction.js";
14
6
  import { validateLoopId } from "./policy.js";
15
7
  import { isRecord } from "./token-utils.js";
16
- export const LOOP_STORE_SCHEMA_VERSION = 1;
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
+ }
17
+ export class StorageCorruptionError extends Error {
18
+ kind;
19
+ path;
20
+ loopId;
21
+ sequence;
22
+ 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
+ this.name = "StorageCorruptionError";
31
+ }
32
+ }
17
33
  function loopIdFromEntry(entry) {
18
34
  if (typeof entry.loop_id === "string" && entry.loop_id)
19
35
  return entry.loop_id;
20
36
  const taskId = String(entry.task_id ?? "");
21
- if (!taskId.startsWith("loop:"))
22
- return null;
23
37
  const session = taskId.match(/^loop:(.+):session$/);
24
38
  if (session)
25
39
  return session[1];
@@ -27,22 +41,19 @@ function loopIdFromEntry(entry) {
27
41
  return round?.[1] ?? null;
28
42
  }
29
43
  function roundFromEntry(entry) {
30
- const data = entry.loop_lineage;
31
- if (data && typeof data.round === "number" && Number.isInteger(data.round)) {
32
- return data.round;
33
- }
44
+ const round = entry.loop_lineage?.round;
45
+ if (typeof round === "number" && Number.isInteger(round))
46
+ return round;
34
47
  const match = String(entry.task_id ?? "").match(/:r(\d+)(?::|$)/);
35
48
  return match ? Number(match[1]) : null;
36
49
  }
37
- function promptFromSnapshot(snapshot) {
38
- return snapshot?.promptArtifact;
50
+ function isEvent(value) {
51
+ return isRecord(value) && value.format === LOOP_EVENT_FORMAT;
39
52
  }
40
53
  export class FileLoopStore {
41
54
  root;
42
55
  lockDepth = 0;
43
- constructor(root = ".loopforge") {
44
- this.root = resolve(root);
45
- }
56
+ constructor(root = ".loopforge") { this.root = resolve(root); }
46
57
  withLock(fn) {
47
58
  if (this.lockDepth > 0) {
48
59
  this.lockDepth++;
@@ -54,46 +65,64 @@ export class FileLoopStore {
54
65
  }
55
66
  }
56
67
  mkdirSync(this.root, { recursive: true });
57
- const lockPath = join(this.root, ".store.lock");
58
- const ownerPath = join(lockPath, "owner.json");
68
+ const lock = join(this.root, ".store.lock");
69
+ const owner = join(lock, "owner.json");
59
70
  const token = randomUUID();
60
71
  const deadline = Date.now() + 1000;
72
+ let spinCount = 0;
61
73
  for (;;) {
62
74
  try {
63
- mkdirSync(lockPath);
64
- writeFileSync(ownerPath, JSON.stringify({ token, pid: process.pid, createdAt: Date.now() }));
75
+ mkdirSync(lock);
76
+ writeFileSync(owner, JSON.stringify({ token, pid: process.pid }));
65
77
  break;
66
78
  }
67
79
  catch {
68
80
  let stale = false;
69
81
  try {
70
- const owner = JSON.parse(readFileSync(ownerPath, "utf8"));
71
- const age = Date.now() - statSync(lockPath).mtimeMs;
72
- if (age > 5000 && typeof owner.pid === "number") {
73
- try {
74
- process.kill(owner.pid, 0);
75
- }
76
- catch (error) {
77
- // On Windows, EPERM may be returned for dead cross-user
78
- // processes. Treat as stale when the lock is old regardless.
79
- const code = error.code;
80
- stale = code === "ESRCH"
81
- || (code === "EPERM" && age > 10_000); // Windows safety: EPERM + old lock → stale
82
- }
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
+ }
83
99
  }
84
100
  }
85
101
  catch {
86
- stale = false;
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;
106
+ try {
107
+ age = Date.now() - statSync(lock).mtimeMs;
108
+ }
109
+ catch { /* lock vanished; next spin retries */ }
110
+ if (age > 5000)
111
+ stale = true;
87
112
  }
88
113
  if (stale) {
89
114
  try {
90
- rmSync(lockPath, { recursive: true });
115
+ rmSync(lock, { recursive: true });
91
116
  }
92
117
  catch { /* race */ }
93
118
  continue;
94
119
  }
95
120
  if (Date.now() >= deadline)
96
121
  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++;
97
126
  }
98
127
  }
99
128
  this.lockDepth = 1;
@@ -103,338 +132,232 @@ export class FileLoopStore {
103
132
  finally {
104
133
  this.lockDepth = 0;
105
134
  try {
106
- const owner = JSON.parse(readFileSync(ownerPath, "utf8"));
107
- if (owner.token === token)
108
- rmSync(lockPath, { recursive: true });
135
+ const current = JSON.parse(readFileSync(owner, "utf8"));
136
+ if (current.token === token)
137
+ rmSync(lock, { recursive: true });
109
138
  }
110
- catch { /* never delete an unowned lock */ }
139
+ catch { /* never remove another owner's lock */ }
111
140
  }
112
141
  }
113
142
  listLoopIds() {
114
- const loops = join(this.root, "loops");
115
- if (!existsSync(loops))
143
+ const root = join(this.root, "loops");
144
+ if (!existsSync(root))
116
145
  return [];
117
- const result = [];
118
- for (const name of readdirSync(loops)) {
119
- const metadata = this.readJson(join(loops, name, "metadata.json"));
120
- if (isRecord(metadata) && typeof metadata.loopId === "string") {
121
- result.push(metadata.loopId);
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)
151
+ continue;
152
+ if (!isRecord(metadata) || metadata.format !== LOOP_STORE_FORMAT ||
153
+ typeof metadata.loopId !== "string" || !metadata.loopId) {
154
+ throw new StorageCorruptionError("invalid_format", metadataPath);
122
155
  }
156
+ ids.push(metadata.loopId);
123
157
  }
124
- return result.sort();
158
+ return ids.sort();
125
159
  }
126
160
  readSession(loopId) {
127
161
  validateLoopId(loopId);
128
- const value = this.readJson(join(this.loopDir(loopId), "session.json"));
129
- if (!isRecord(value) || value.schemaVersion !== LOOP_STORE_SCHEMA_VERSION ||
130
- value.loopId !== loopId || !isRecord(value.entry))
162
+ const path = join(this.loopDir(loopId), "session.json");
163
+ const value = this.readJson(path);
164
+ if (value === null)
131
165
  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);
171
+ }
132
172
  return value;
133
173
  }
134
174
  writeSession(loopId, document) {
135
175
  validateLoopId(loopId);
136
- this.atomicWrite(join(this.loopDir(loopId), "session.json"), document);
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 });
137
178
  }
138
179
  readRound(loopId, round) {
139
180
  validateLoopId(loopId);
140
181
  if (!Number.isInteger(round) || round < 1)
141
182
  return null;
142
- const value = this.readJson(join(this.loopDir(loopId), "rounds", `${round}.json`));
143
- if (!isRecord(value) || value.schemaVersion !== LOOP_STORE_SCHEMA_VERSION ||
144
- value.loopId !== loopId || value.round !== round)
183
+ const path = join(this.loopDir(loopId), "rounds", `${round}.json`);
184
+ const value = this.readJson(path);
185
+ if (value === null)
145
186
  return null;
146
- const events = Array.isArray(value.events)
147
- ? value.events.filter(isRecord)
148
- : [];
149
- const transaction = parseRoundTransactionSnapshot(value.transaction);
150
- return {
151
- schemaVersion: LOOP_STORE_SCHEMA_VERSION,
152
- loopId,
153
- round,
154
- updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : "",
155
- lineage: isRecord(value.lineage) ? value.lineage : undefined,
156
- feedback: isRecord(value.feedback) ? value.feedback : undefined,
157
- transaction: transaction ?? undefined,
158
- promptArtifact: transaction?.promptArtifact,
159
- events,
160
- };
187
+ if (!isRecord(value) || value.format !== LOOP_STORE_FORMAT || value.loopId !== loopId || value.round !== round) {
188
+ throw new StorageCorruptionError("invalid_format", path, loopId);
189
+ }
190
+ const tx = parseRoundTransactionSnapshot(value.transaction);
191
+ if (value.transaction !== undefined && !tx) {
192
+ throw new StorageCorruptionError("invalid_format", path, loopId);
193
+ }
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
+ }
204
+ }
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 : [] };
161
206
  }
162
207
  listEntries(loopId) {
163
- const ids = loopId ? [loopId] : this.listLoopIds();
164
208
  const result = [];
165
- for (const id of ids) {
209
+ for (const id of loopId ? [loopId] : this.listLoopIds()) {
166
210
  const session = this.readSession(id);
167
211
  if (session)
168
212
  result.push(session.entry);
169
- const roundsDir = join(this.loopDir(id), "rounds");
170
- if (!existsSync(roundsDir))
213
+ const dir = join(this.loopDir(id), "rounds");
214
+ if (!existsSync(dir))
171
215
  continue;
172
- for (const file of readdirSync(roundsDir).filter((name) => /^\d+\.json$/.test(name))) {
216
+ for (const file of readdirSync(dir).filter((name) => /^\d+\.json$/.test(name))) {
173
217
  const round = this.readRound(id, Number(file.slice(0, -5)));
174
218
  if (!round)
175
219
  continue;
176
- if (round.lineage) {
177
- result.push({
178
- ...round.lineage,
179
- full_prompt: round.promptArtifact?.renderedPrompt ?? round.lineage.full_prompt,
180
- });
181
- }
182
- if (round.feedback)
183
- result.push(round.feedback);
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);
184
224
  result.push(...round.events);
185
225
  }
186
226
  }
187
227
  return result;
188
228
  }
189
- appendEntry(entry) {
190
- this.withLock(() => this.writeEntry(entry));
191
- }
192
- appendEntries(entries) {
193
- return this.withLock(() => {
194
- for (const entry of entries)
195
- this.writeEntry(entry);
196
- return entries.length;
197
- });
198
- }
199
- replaceEntries(entries) {
200
- this.withLock(() => {
201
- for (const entry of entries)
202
- this.writeEntry(entry);
203
- });
204
- }
205
- migrateLegacyVault(path = ".promptcraft/prompt_vault.json") {
206
- const source = resolve(path);
207
- const marker = join(this.root, "migrations", "promptcraft-v1.json");
208
- if (existsSync(marker)) {
209
- return { source, imported: 0, skipped: 0, alreadyMigrated: true };
210
- }
211
- let imported = 0;
212
- let skipped = 0;
213
- const raw = this.readJson(source);
214
- const entries = isRecord(raw) && Array.isArray(raw.entries)
215
- ? raw.entries.filter(isRecord)
216
- : [];
217
- this.withLock(() => {
218
- const existing = new Set(this.listEntries().map((entry) => String(entry.task_id ?? "")));
219
- for (const entry of entries) {
220
- if (!loopIdFromEntry(entry) || existing.has(String(entry.task_id ?? ""))) {
221
- skipped++;
222
- continue;
223
- }
224
- this.writeEntry(entry);
225
- imported++;
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);
226
238
  }
227
- this.atomicWrite(marker, {
228
- schemaVersion: LOOP_STORE_SCHEMA_VERSION,
229
- source,
230
- imported,
231
- skipped,
232
- migratedAt: new Date().toISOString(),
233
- });
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);
252
+ }
253
+ return sa - sb;
234
254
  });
235
- return { source, imported, skipped, alreadyMigrated: false };
255
+ this.assertSequence(entries, loopId);
256
+ return entries;
236
257
  }
237
- writeEntry(entry) {
238
- const loopId = loopIdFromEntry(entry);
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);
239
265
  if (!loopId)
240
266
  throw new Error("LoopStore only accepts loop-scoped entries");
241
267
  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 });
242
274
  const now = new Date().toISOString();
243
275
  const dir = this.loopDir(loopId);
244
- this.atomicWrite(join(dir, "metadata.json"), {
245
- schemaVersion: LOOP_STORE_SCHEMA_VERSION,
246
- loopId,
247
- });
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 });
248
282
  if (entry.task_type === "session_state" || entry.task_id === `loop:${loopId}:session`) {
249
- this.atomicWrite(join(dir, "session.json"), {
250
- schemaVersion: LOOP_STORE_SCHEMA_VERSION,
251
- loopId,
252
- updatedAt: now,
253
- entry,
254
- });
283
+ this.atomicWrite(join(dir, "session.json"), { format: LOOP_STORE_FORMAT, loopId, updatedAt: now, entry });
255
284
  return;
256
285
  }
257
286
  const round = roundFromEntry(entry);
258
287
  if (!round)
259
288
  throw new Error(`Loop entry has no round: ${entry.task_id ?? "unknown"}`);
260
- const current = this.readRound(loopId, round) ?? {
261
- schemaVersion: LOOP_STORE_SCHEMA_VERSION,
262
- loopId,
263
- round,
264
- updatedAt: now,
265
- events: [],
266
- };
289
+ const current = this.readRound(loopId, round) ?? { format: LOOP_STORE_FORMAT, loopId, round, updatedAt: now, events: [] };
267
290
  const taskId = String(entry.task_id ?? "");
268
- if (taskId.endsWith(":feedback"))
269
- current.feedback = entry;
270
- else if (entry.task_type === "loop_lineage" || taskId === `loop:${loopId}:r${round}`) {
291
+ if (taskId.endsWith(":event"))
292
+ current.roundEvent = entry;
293
+ else if (entry.task_type === "loop_lineage" || taskId === `loop:${loopId}:r${round}`)
271
294
  current.lineage = entry;
272
- }
273
- else {
274
- current.events = [
275
- ...current.events.filter((event) => event.task_id !== entry.task_id),
276
- entry,
277
- ];
278
- }
279
- const transactionRaw = current.feedback?.loop_lineage?.round_transaction ??
280
- entry.loop_lineage?.round_transaction;
281
- const transaction = parseRoundTransactionSnapshot(isRecord(transactionRaw) ? transactionRaw.snapshot : undefined);
282
- if (transaction) {
283
- current.transaction = transaction;
284
- current.promptArtifact = promptFromSnapshot(transaction);
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;
285
302
  }
286
303
  current.updatedAt = now;
287
304
  this.atomicWrite(join(dir, "rounds", `${round}.json`), current);
288
305
  }
289
- loopDir(loopId) {
290
- const hash = createHash("sha256").update(loopId).digest("hex");
291
- return join(this.root, "loops", hash);
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 };
292
332
  }
333
+ loopDir(loopId) { return join(this.root, "loops", createHash("sha256").update(loopId).digest("hex")); }
293
334
  readJson(path) {
294
335
  try {
295
336
  return JSON.parse(readFileSync(path, "utf8"));
296
337
  }
297
- catch {
298
- return null;
299
- }
300
- }
301
- atomicWrite(path, value) {
302
- mkdirSync(dirname(path), { recursive: true });
303
- const temporary = `${path}.tmp.${randomUUID().slice(0, 8)}`;
304
- writeFileSync(temporary, JSON.stringify(value, null, 2), "utf8");
305
- renameSync(temporary, path);
306
- }
307
- }
308
- /** @deprecated Use LoopStore directly.
309
- *
310
- * Compatibility adapter so modules that still accept VaultBackend can
311
- * operate on a LoopStore. Persistent truth is the typed per-loop
312
- * documents; no Markdown lineage is written. */
313
- export class LoopStoreBackend {
314
- store;
315
- constructor(store = new FileLoopStore()) {
316
- this.store = store;
317
- }
318
- withLock(fn) { return this.store.withLock(fn); }
319
- queryEntries(opts) {
320
- return this.store.listEntries().filter((entry) => {
321
- const taskId = String(entry.task_id ?? "");
322
- if (opts?.feedbackOnly && !taskId.endsWith(":feedback"))
323
- return false;
324
- if (!opts?.feedbackOnly && taskId.endsWith(":feedback"))
325
- return false;
326
- if (opts?.prefix && !taskId.startsWith(opts.prefix))
327
- return false;
328
- if (opts?.taskIdPattern && !taskId.includes(opts.taskIdPattern))
329
- return false;
330
- return true;
331
- });
332
- }
333
- appendEntry(entry) { this.store.appendEntry(entry); }
334
- appendEntries(entries) { return this.store.appendEntries(entries); }
335
- }
336
- /** Adapter that wraps a VaultBackend as a LoopStore for backward compat.
337
- *
338
- * SessionManager uses this when a VaultBackend is provided directly so
339
- * VaultSessionStateStore can operate on typed session documents while
340
- * the underlying storage remains VaultBackend entries. */
341
- export class VaultBackendLoopStore {
342
- backend;
343
- constructor(backend) {
344
- this.backend = backend;
345
- }
346
- withLock(fn) {
347
- return typeof this.backend.withLock === "function"
348
- ? this.backend.withLock(fn)
349
- : fn();
350
- }
351
- listLoopIds() {
352
- return [...new Set(this.backend.queryEntries()
353
- .filter((e) => typeof e.loop_id === "string")
354
- .map((e) => e.loop_id))].sort();
355
- }
356
- listEntries(loopId) {
357
- if (loopId)
358
- return this.backend.queryEntries({ prefix: `loop:${loopId}` });
359
- return this.backend.queryEntries();
360
- }
361
- appendEntry(entry) { this.backend.appendEntry(entry); }
362
- appendEntries(entries) { return this.backend.appendEntries(entries); }
363
- replaceEntries(_entries) {
364
- throw new Error("VaultBackendLoopStore.replaceEntries is not supported. " +
365
- "Use LoopStore (FileLoopStore) directly for bulk replace operations.");
366
- }
367
- readSession(loopId) {
368
- const entry = this.backend.queryEntries({ prefix: `loop:${loopId}:session` })
369
- .find((e) => e.task_type === "session_state" && e.loop_id === loopId);
370
- if (!entry)
371
- return null;
372
- return {
373
- schemaVersion: LOOP_STORE_SCHEMA_VERSION,
374
- loopId,
375
- updatedAt: typeof entry.timestamp === "string"
376
- ? entry.timestamp
377
- : new Date().toISOString(),
378
- entry,
379
- };
380
- }
381
- writeSession(loopId, document) {
382
- const entry = document.entry;
383
- // Ensure the entry carries identifying fields expected by queryEntries.
384
- entry.task_type = "session_state";
385
- entry.loop_id = loopId;
386
- entry.task_id = `loop:${loopId}:session`;
387
- // Session state lives in the vault entries list. The backend may
388
- // provide readVault/writeVault as optional compat methods; if not,
389
- // we fall back to appendEntry (which may create duplicates, but
390
- // VaultSessionStateStore.load returns the first match).
391
- const be = this.backend;
392
- if (typeof be.readVault === "function" &&
393
- typeof be.writeVault === "function") {
394
- // Call via .call(be) so methods that reference `this` (e.g.
395
- // MemoryBackend.readVault) keep their receiver.
396
- const data = be.readVault.call(be);
397
- const entries = Array.isArray(data.entries)
398
- ? data.entries
399
- : [];
400
- const filtered = entries.filter((item) => !(item.task_type === "session_state" && item.loop_id === loopId));
401
- be.writeVault.call(be, { entries: [...filtered, entry] });
402
- return;
338
+ catch (error) {
339
+ if (error.code === "ENOENT")
340
+ return null;
341
+ throw new StorageCorruptionError("invalid_json", path, undefined, undefined, `Invalid JSON in ${path}`);
403
342
  }
404
- this.backend.appendEntry(entry);
405
343
  }
406
- readRound(loopId, round) {
407
- const prefix = `loop:${loopId}:r${round}:`;
408
- const entries = this.backend.queryEntries({ prefix });
409
- if (entries.length === 0)
410
- return null;
411
- // Separate lineage, feedback, and raw events — same structure as
412
- // FileLoopStore.readRound so callers get consistent round documents
413
- // regardless of backend.
414
- const lineage = entries.find((e) => e.task_type === "loop_lineage" || e.task_id === `loop:${loopId}:r${round}`);
415
- const feedback = entries.find((e) => String(e.task_id ?? "").endsWith(":feedback"));
416
- const events = entries.filter((e) => e !== lineage && e !== feedback);
417
- let transaction;
418
- const transactionRaw = feedback?.loop_lineage?.round_transaction ??
419
- lineage?.loop_lineage?.round_transaction;
420
- const transactionParsed = parseRoundTransactionSnapshot(isRecord(transactionRaw) ? transactionRaw.snapshot : undefined);
421
- if (transactionParsed) {
422
- transaction = transactionParsed;
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);
354
+ }
355
+ seen.add(sequence);
356
+ if (sequence !== index + 1) {
357
+ throw new StorageCorruptionError("sequence_gap", this.loopDir(loopId), loopId, sequence);
358
+ }
423
359
  }
424
- return {
425
- schemaVersion: LOOP_STORE_SCHEMA_VERSION,
426
- loopId,
427
- round,
428
- updatedAt: entries[0].timestamp ?? new Date().toISOString(),
429
- lineage: lineage,
430
- feedback: feedback,
431
- transaction,
432
- promptArtifact: transaction?.promptArtifact,
433
- events,
434
- };
435
- }
436
- migrateLegacyVault(_path) {
437
- return { source: _path ?? "vault", imported: 0, skipped: 0, alreadyMigrated: true };
438
360
  }
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); }
439
362
  }
440
363
  //# sourceMappingURL=loop-store.js.map