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