brainclaw 1.8.0 → 1.9.1

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 (178) hide show
  1. package/README.md +592 -505
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +138 -13
  4. package/dist/commands/add-step.js +1 -1
  5. package/dist/commands/bootstrap.js +2 -26
  6. package/dist/commands/check-security-mcp.js +50 -33
  7. package/dist/commands/check-security.js +86 -43
  8. package/dist/commands/claim.js +22 -21
  9. package/dist/commands/confirm.js +26 -0
  10. package/dist/commands/context-diff.js +1 -1
  11. package/dist/commands/dispatch-watch.js +142 -0
  12. package/dist/commands/doctor.js +113 -2
  13. package/dist/commands/estimation-report.js +115 -16
  14. package/dist/commands/harvest.js +286 -23
  15. package/dist/commands/hooks.js +73 -73
  16. package/dist/commands/init.js +124 -22
  17. package/dist/commands/install-hooks.js +78 -78
  18. package/dist/commands/loops-handlers.js +4 -0
  19. package/dist/commands/mcp-read-handlers.js +253 -41
  20. package/dist/commands/mcp.js +664 -102
  21. package/dist/commands/memory.js +21 -17
  22. package/dist/commands/migrate.js +81 -17
  23. package/dist/commands/prune.js +78 -4
  24. package/dist/commands/reflect.js +26 -20
  25. package/dist/commands/register-agent.js +57 -1
  26. package/dist/commands/repair.js +20 -0
  27. package/dist/commands/session-end.js +15 -6
  28. package/dist/commands/session-start.js +18 -1
  29. package/dist/commands/setup-security.js +39 -18
  30. package/dist/commands/setup.js +26 -27
  31. package/dist/commands/stale.js +16 -2
  32. package/dist/commands/switch.js +26 -5
  33. package/dist/commands/uninstall.js +126 -34
  34. package/dist/commands/update-step.js +6 -0
  35. package/dist/commands/version.js +1 -1
  36. package/dist/commands/worktree.js +60 -0
  37. package/dist/core/actions.js +12 -3
  38. package/dist/core/agent-capability.js +30 -17
  39. package/dist/core/agent-files.js +963 -666
  40. package/dist/core/agent-integrations.js +0 -3
  41. package/dist/core/agent-inventory.js +67 -0
  42. package/dist/core/agent-registry.js +163 -29
  43. package/dist/core/agentrun-reconciler.js +33 -2
  44. package/dist/core/agentruns.js +7 -1
  45. package/dist/core/ai-agent-detection.js +31 -44
  46. package/dist/core/archival.js +15 -9
  47. package/dist/core/assignment-reconciler.js +56 -0
  48. package/dist/core/assignment-sweeper.js +127 -4
  49. package/dist/core/assignments.js +69 -11
  50. package/dist/core/bootstrap.js +233 -67
  51. package/dist/core/brainclaw-version.js +22 -0
  52. package/dist/core/candidates.js +21 -1
  53. package/dist/core/claims.js +313 -150
  54. package/dist/core/codev-prompts.js +38 -38
  55. package/dist/core/config.js +6 -1
  56. package/dist/core/context-diff.js +148 -20
  57. package/dist/core/context.js +129 -8
  58. package/dist/core/coordination.js +22 -3
  59. package/dist/core/default-profiles/doctor.yaml +11 -11
  60. package/dist/core/default-profiles/janitor.yaml +11 -11
  61. package/dist/core/default-profiles/onboarder.yaml +11 -11
  62. package/dist/core/default-profiles/reviewer.yaml +13 -13
  63. package/dist/core/dispatch-status.js +79 -5
  64. package/dist/core/dispatcher.js +65 -12
  65. package/dist/core/entity-operations.js +74 -27
  66. package/dist/core/entity-registry.js +31 -5
  67. package/dist/core/event-log.js +138 -21
  68. package/dist/core/events/checkpoint.js +258 -0
  69. package/dist/core/events/genesis.js +220 -0
  70. package/dist/core/events/journal.js +507 -0
  71. package/dist/core/events/materialize.js +126 -0
  72. package/dist/core/events/registry-post-image.js +110 -0
  73. package/dist/core/events/verify.js +109 -0
  74. package/dist/core/execution-adapters.js +23 -0
  75. package/dist/core/execution.js +1 -1
  76. package/dist/core/facade-schema.js +38 -0
  77. package/dist/core/gc-semantic.js +130 -5
  78. package/dist/core/handoff-snapshot.js +68 -0
  79. package/dist/core/ids.js +19 -8
  80. package/dist/core/instruction-templates.js +34 -115
  81. package/dist/core/io.js +39 -3
  82. package/dist/core/json-store.js +10 -1
  83. package/dist/core/lock.js +153 -28
  84. package/dist/core/loops/bootstrap-acquire.js +25 -1
  85. package/dist/core/loops/facade-schema.js +2 -0
  86. package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
  87. package/dist/core/loops/index.js +1 -0
  88. package/dist/core/loops/presets/bootstrap.js +7 -0
  89. package/dist/core/loops/store.js +17 -0
  90. package/dist/core/loops/verbs.js +24 -2
  91. package/dist/core/markdown.js +8 -76
  92. package/dist/core/mcp-command-resolution.js +245 -0
  93. package/dist/core/memory-compactor.js +5 -3
  94. package/dist/core/memory-lifecycle.js +282 -0
  95. package/dist/core/merge-risk.js +150 -0
  96. package/dist/core/messaging.js +10 -3
  97. package/dist/core/migration.js +11 -1
  98. package/dist/core/observer-mode.js +26 -0
  99. package/dist/core/operations/memory-mutation.js +90 -65
  100. package/dist/core/operations/plan.js +27 -1
  101. package/dist/core/protocol-skills.js +210 -0
  102. package/dist/core/reflection-safety.js +6 -7
  103. package/dist/core/reputation.js +84 -2
  104. package/dist/core/runtime-signals.js +72 -10
  105. package/dist/core/runtime.js +84 -1
  106. package/dist/core/schema.js +114 -0
  107. package/dist/core/search.js +19 -2
  108. package/dist/core/security-detectors.js +125 -0
  109. package/dist/core/security-extract.js +189 -0
  110. package/dist/core/security-guard.js +217 -139
  111. package/dist/core/security-packages.js +121 -0
  112. package/dist/core/security-scoring.js +76 -9
  113. package/dist/core/security.js +34 -2
  114. package/dist/core/sequence.js +11 -2
  115. package/dist/core/setup-flow.js +141 -13
  116. package/dist/core/spawn-check.js +16 -2
  117. package/dist/core/staleness.js +73 -2
  118. package/dist/core/state.js +250 -54
  119. package/dist/core/store-resolution.js +45 -12
  120. package/dist/core/worktree.js +90 -26
  121. package/dist/facts.js +8 -8
  122. package/dist/facts.json +7 -7
  123. package/docs/PROTOCOL.md +223 -0
  124. package/docs/adapters/openclaw.md +43 -43
  125. package/docs/architecture/project-refs.md +328 -328
  126. package/docs/cli.md +2097 -2096
  127. package/docs/concepts/coordination.md +52 -52
  128. package/docs/concepts/coordinator-runbook.md +129 -0
  129. package/docs/concepts/dispatch-lifecycle.md +245 -245
  130. package/docs/concepts/event-log-store.md +928 -0
  131. package/docs/concepts/ideation-loop.md +317 -317
  132. package/docs/concepts/loop-engine.md +520 -511
  133. package/docs/concepts/mcp-governance.md +268 -268
  134. package/docs/concepts/memory.md +89 -88
  135. package/docs/concepts/multi-agent-workflows.md +167 -167
  136. package/docs/concepts/observer-protocol.md +361 -0
  137. package/docs/concepts/parallel-merge-protocol.md +71 -0
  138. package/docs/concepts/plans-and-claims.md +217 -174
  139. package/docs/concepts/project-md-convention.md +35 -35
  140. package/docs/concepts/runtime-notes.md +38 -38
  141. package/docs/concepts/skills.md +78 -0
  142. package/docs/concepts/troubleshooting.md +254 -254
  143. package/docs/concepts/workspace-bootstrapping.md +142 -81
  144. package/docs/context-format-changelog.md +35 -35
  145. package/docs/context-format.md +48 -48
  146. package/docs/index.md +65 -65
  147. package/docs/integrations/agents.md +162 -162
  148. package/docs/integrations/claude-code.md +23 -23
  149. package/docs/integrations/cline.md +87 -88
  150. package/docs/integrations/codex.md +2 -2
  151. package/docs/integrations/continue.md +60 -60
  152. package/docs/integrations/copilot.md +82 -80
  153. package/docs/integrations/cursor.md +23 -23
  154. package/docs/integrations/kilocode.md +72 -72
  155. package/docs/integrations/mcp.md +377 -377
  156. package/docs/integrations/mistral-vibe.md +122 -122
  157. package/docs/integrations/openclaw.md +99 -98
  158. package/docs/integrations/opencode.md +84 -84
  159. package/docs/integrations/overview.md +122 -122
  160. package/docs/integrations/roo.md +74 -74
  161. package/docs/integrations/windsurf.md +83 -83
  162. package/docs/mcp-schema-changelog.md +360 -329
  163. package/docs/playbooks/integration/index.md +121 -121
  164. package/docs/playbooks/orchestration.md +37 -0
  165. package/docs/playbooks/productivity/index.md +99 -99
  166. package/docs/playbooks/team/index.md +117 -117
  167. package/docs/product/agent-first-model.md +184 -184
  168. package/docs/product/entity-model-audit.md +462 -462
  169. package/docs/product/positioning.md +86 -86
  170. package/docs/quickstart-existing-project.md +107 -107
  171. package/docs/quickstart.md +148 -147
  172. package/docs/release-maintenance.md +79 -79
  173. package/docs/reputation.md +52 -52
  174. package/docs/review.md +45 -45
  175. package/docs/security.md +212 -53
  176. package/docs/server-operations.md +118 -118
  177. package/docs/storage.md +110 -108
  178. package/package.json +86 -69
@@ -3,15 +3,18 @@ import path from 'node:path';
3
3
  import { memoryDir } from './io.js';
4
4
  import { nowISO } from './ids.js';
5
5
  import { logger } from './logger.js';
6
+ import { isObserverMode } from './observer-mode.js';
7
+ import { appendJournalRecords, resolveJournalMode } from './events/journal.js';
8
+ import { REGISTRY_POST_IMAGE_ITEM_TYPES } from './events/registry-post-image.js';
6
9
  const EVENT_LOG_FILE = 'events.jsonl';
7
10
  const CURSORS_DIR = '.cursors';
8
- // --- Writer ---
9
- export function appendEvent(event, cwd) {
11
+ export function appendEvent(event, cwd, options = {}) {
10
12
  try {
11
13
  const full = {
12
14
  ts: event.ts ?? nowISO(),
13
15
  agent: event.agent ?? 'unknown',
14
16
  agent_id: event.agent_id,
17
+ session_id: event.session_id ?? (process.env.BRAINCLAW_SESSION_ID?.trim() || undefined),
15
18
  user: event.user ?? process.env.USER ?? process.env.USERNAME,
16
19
  action: event.action,
17
20
  item_type: event.item_type,
@@ -25,6 +28,56 @@ export function appendEvent(event, cwd) {
25
28
  catch (err) {
26
29
  logger.debug('Failed to write event log entry:', err);
27
30
  }
31
+ if (options.journalDualWrite !== false) {
32
+ dualWriteToJournal(event, cwd);
33
+ }
34
+ }
35
+ /**
36
+ * v2 journal dual-write (pln#543 step 2). Mirrors every v1 emission into
37
+ * the segmented journal when BRAINCLAW_JOURNAL_MODE=dual; a no-op when off.
38
+ * Mapping per spec §2.1.1: the coarse `update/upgrade/rollback : state`
39
+ * store event becomes a `journal_note` kind `store_marker` (the per-entity
40
+ * events it stands for arrive with step 3 dirty-tracking).
41
+ */
42
+ function dualWriteToJournal(event, cwd) {
43
+ try {
44
+ if (resolveJournalMode(cwd) === 'off')
45
+ return;
46
+ const base = {
47
+ agent: event.agent,
48
+ agent_id: event.agent_id,
49
+ session_id: event.session_id,
50
+ user: event.user,
51
+ summary: event.summary,
52
+ ts: event.ts,
53
+ };
54
+ if (event.item_type === 'state') {
55
+ appendJournalRecords([{
56
+ ...base,
57
+ action: 'journal_note',
58
+ item_type: 'journal',
59
+ payload: { kind: 'store_marker', op: event.action, detail: event.summary },
60
+ }], cwd);
61
+ return;
62
+ }
63
+ // pln#568 — registry/coordination families now journal full entity-state
64
+ // post-images on their persist chokepoint (emitRegistryPostImage). Their
65
+ // legacy envelope-only dual-write here would be redundant noise (a
66
+ // registry-lifecycle record materialize ignores), so suppress it: in the v2
67
+ // journal these families appear ONLY as post-images. events.jsonl (above)
68
+ // still records the v1 lifecycle event for existing consumers.
69
+ if (REGISTRY_POST_IMAGE_ITEM_TYPES.has(event.item_type))
70
+ return;
71
+ appendJournalRecords([{
72
+ ...base,
73
+ action: event.action,
74
+ item_type: event.item_type,
75
+ item_id: event.item_id,
76
+ }], cwd);
77
+ }
78
+ catch (err) {
79
+ logger.debug('journal dual-write skipped:', err);
80
+ }
28
81
  }
29
82
  // --- Reader ---
30
83
  export function readAllEvents(cwd) {
@@ -43,39 +96,96 @@ export function readAllEvents(cwd) {
43
96
  }
44
97
  return events;
45
98
  }
99
+ function normalizeReader(reader) {
100
+ return typeof reader === 'string' ? { agent: reader } : reader;
101
+ }
46
102
  function cursorsDir(cwd) {
47
103
  return path.join(memoryDir(cwd), CURSORS_DIR);
48
104
  }
49
- function cursorPath(agent, cwd) {
50
- return path.join(cursorsDir(cwd), `${agent}.json`);
105
+ /** Cursor files are keyed by session_id when present, else by agent name. */
106
+ function cursorKey(reader) {
107
+ return reader.session_id?.trim() || reader.agent;
51
108
  }
52
- function loadCursor(agent, cwd) {
53
- const fp = cursorPath(agent, cwd);
54
- if (!fs.existsSync(fp))
55
- return { offset: 0, last_read: '' };
56
- try {
57
- return JSON.parse(fs.readFileSync(fp, 'utf-8'));
109
+ function cursorPath(key, cwd) {
110
+ return path.join(cursorsDir(cwd), `${key}.json`);
111
+ }
112
+ function loadCursor(reader, cwd) {
113
+ const fp = cursorPath(cursorKey(reader), cwd);
114
+ if (fs.existsSync(fp)) {
115
+ try {
116
+ return JSON.parse(fs.readFileSync(fp, 'utf-8'));
117
+ }
118
+ catch {
119
+ return { offset: 0, last_read: '' };
120
+ }
58
121
  }
59
- catch {
60
- return { offset: 0, last_read: '' };
122
+ // name→instance migration: a session-keyed cursor that does not exist yet
123
+ // seeds from the legacy name-keyed cursor, so an upgraded instance does not
124
+ // replay the whole log. Cursors are caches — worst case is a re-read.
125
+ if (reader.session_id?.trim()) {
126
+ const legacy = cursorPath(reader.agent, cwd);
127
+ if (fs.existsSync(legacy)) {
128
+ try {
129
+ return JSON.parse(fs.readFileSync(legacy, 'utf-8'));
130
+ }
131
+ catch { /* fall through to fresh cursor */ }
132
+ }
61
133
  }
134
+ return { offset: 0, last_read: '' };
62
135
  }
63
- function saveCursor(agent, cursor, cwd) {
136
+ function saveCursor(reader, cursor, cwd) {
64
137
  const dir = cursorsDir(cwd);
65
138
  if (!fs.existsSync(dir)) {
66
139
  fs.mkdirSync(dir, { recursive: true });
67
140
  }
68
- fs.writeFileSync(cursorPath(agent, cwd), JSON.stringify(cursor), 'utf-8');
141
+ fs.writeFileSync(cursorPath(cursorKey(reader), cwd), JSON.stringify(cursor), 'utf-8');
142
+ }
143
+ /**
144
+ * True when this reader already has a cursor — session-keyed, or the legacy
145
+ * name-keyed cursor a session reader would migrate from. Absence means first
146
+ * contact: the reader has never consumed this store's event log.
147
+ */
148
+ export function hasEventCursor(reader, cwd) {
149
+ const effectiveReader = normalizeReader(reader);
150
+ if (fs.existsSync(cursorPath(cursorKey(effectiveReader), cwd)))
151
+ return true;
152
+ if (effectiveReader.session_id?.trim()) {
153
+ return fs.existsSync(cursorPath(effectiveReader.agent, cwd));
154
+ }
155
+ return false;
156
+ }
157
+ /**
158
+ * Seed the reader's cursor at the current end of the event log WITHOUT
159
+ * reading it. First-contact path: a fresh agent on a mature store must not
160
+ * replay the whole history (its one chance to triage would be consumed by
161
+ * noise) — the arrival digest informs instead, and the diff becomes
162
+ * incremental from here. Returns the byte offset that was sealed.
163
+ */
164
+ export function seedCursorToEnd(reader, cwd) {
165
+ const effectiveReader = normalizeReader(reader);
166
+ const logPath = eventLogPath(cwd);
167
+ let size = 0;
168
+ try {
169
+ size = fs.statSync(logPath).size;
170
+ }
171
+ catch { /* missing log → offset 0 */ }
172
+ saveCursor(effectiveReader, { offset: size, last_read: nowISO() }, cwd);
173
+ return size;
69
174
  }
70
175
  /**
71
- * Read events unseen by this agent since their last read.
176
+ * Read events unseen by this reader since their last read.
72
177
  * Updates the cursor after reading.
178
+ *
179
+ * Self-exclusion is by SESSION when both sides carry one (pln#562 step 4):
180
+ * an instance skips only its own events, not those of a same-named sibling.
181
+ * Events or readers without session info fall back to name exclusion.
73
182
  */
74
- export function readUnseenEvents(agent, cwd) {
183
+ export function readUnseenEvents(reader, cwd) {
184
+ const effectiveReader = normalizeReader(reader);
75
185
  const logPath = eventLogPath(cwd);
76
186
  if (!fs.existsSync(logPath))
77
187
  return [];
78
- const cursor = loadCursor(agent, cwd);
188
+ const cursor = loadCursor(effectiveReader, cwd);
79
189
  const stat = fs.statSync(logPath);
80
190
  if (stat.size <= cursor.offset)
81
191
  return [];
@@ -90,8 +200,10 @@ export function readUnseenEvents(agent, cwd) {
90
200
  for (const line of lines) {
91
201
  try {
92
202
  const evt = JSON.parse(line);
93
- // Exclude events from self
94
- if (evt.agent !== agent) {
203
+ const isSelf = effectiveReader.session_id && evt.session_id
204
+ ? evt.session_id === effectiveReader.session_id
205
+ : evt.agent === effectiveReader.agent;
206
+ if (!isSelf) {
95
207
  events.push(evt);
96
208
  }
97
209
  }
@@ -99,8 +211,13 @@ export function readUnseenEvents(agent, cwd) {
99
211
  // skip
100
212
  }
101
213
  }
102
- // Update cursor
103
- saveCursor(agent, { offset: stat.size, last_read: nowISO() }, cwd);
214
+ // Update cursor. Observer mode (BRAINCLAW_OBSERVER=1) skips this — the
215
+ // dashboard impersonating an agent must not consume that agent's cursor
216
+ // (the 2026-06-10 leak where the extension drained Juan's claude-code
217
+ // unseen-events queue on every poll).
218
+ if (!isObserverMode()) {
219
+ saveCursor(effectiveReader, { offset: stat.size, last_read: nowISO() }, cwd);
220
+ }
104
221
  return events;
105
222
  }
106
223
  /**
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Journal-derived checkpoints (pln#543 Phase 3 / pln#566 Inc0, slice 1).
3
+ *
4
+ * A checkpoint is a snapshot of the materialized live-entity set at a covered
5
+ * `head_seq`, plus a manifest that BINDS it to the journal lineage. Per the
6
+ * round-3 review (NF1): checkpoints are derived from the JOURNAL ONLY — never
7
+ * from projection files, never consulting `projection_watermark` — so the
8
+ * journal is the single root of trust and there is no checkpoint↔watermark
9
+ * cycle. The cold-read fast path (a later slice) loads a checkpoint + replays
10
+ * only the sealed tail (head_seq+1..tail); on ANY trust-chain failure it falls
11
+ * back to projection files.
12
+ *
13
+ * This slice is strictly ADDITIVE: it builds/verifies/replays checkpoints but
14
+ * is not yet wired into the read path, so it carries zero read-path risk.
15
+ *
16
+ * @module
17
+ */
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import crypto from 'node:crypto';
21
+ import { journalDir, readJournalRecords, journalHeadSeq } from './journal.js';
22
+ import { applyRecordsToLive, projectLiveToState } from './materialize.js';
23
+ import { loadConfig } from '../config.js';
24
+ import { writeFileAtomic } from '../io.js';
25
+ import { nowISO } from '../ids.js';
26
+ const CHECKPOINT_SCHEMA_VERSION = 1;
27
+ export const BASELINE_CAPABILITIES = {
28
+ checkpointRead: false,
29
+ readReconcile: false,
30
+ tombstoneDelete: false,
31
+ perEntityPatch: false,
32
+ };
33
+ function checkpointsDir(cwd) {
34
+ return path.join(journalDir(cwd), 'checkpoints');
35
+ }
36
+ function sha256(s) {
37
+ return crypto.createHash('sha256').update(s).digest('hex');
38
+ }
39
+ /**
40
+ * pln#566 F4 guard: materialize (the reducer behind both checkpoint build and
41
+ * checkpoint+tail replay) only consumes inline `rec.payload`; it does NOT yet
42
+ * dereference `payload_ref`. So if ANY journal record externalized its payload,
43
+ * a checkpoint built/served from the journal would silently DROP that entity.
44
+ * Until payload_ref dereference lands, refuse to build or serve a checkpoint
45
+ * when externalized payloads exist — the read path falls back to projection
46
+ * files, which always carry the full content. Conservative (whole-journal, not
47
+ * just memory-tier) and cheap.
48
+ */
49
+ function journalHasExternalizedPayload(records) {
50
+ return records.some(r => r.payload_ref != null);
51
+ }
52
+ /** Identity of a record for journal-lineage binding: stable across reads, changes if the head record changes. */
53
+ function recordIdentity(rec) {
54
+ return sha256(`${rec.seq}|${rec.writer}|${rec.ts}|${rec.action}|${rec.item_type}|${rec.item_id ?? ''}`);
55
+ }
56
+ /** The record carrying the maximum seq (the covered head). Undefined for an empty journal. */
57
+ function headRecord(records) {
58
+ let head;
59
+ for (const rec of records) {
60
+ if (!head || rec.seq > head.seq)
61
+ head = rec;
62
+ }
63
+ return head;
64
+ }
65
+ function manifestPath(cwd, headSeq) {
66
+ return path.join(checkpointsDir(cwd), `${String(headSeq).padStart(12, '0')}.manifest.json`);
67
+ }
68
+ function snapshotPath(cwd, headSeq) {
69
+ return path.join(checkpointsDir(cwd), `${String(headSeq).padStart(12, '0')}.snapshot.json`);
70
+ }
71
+ /**
72
+ * Build a journal-derived checkpoint at the current journal head. Materializes
73
+ * the live entity set from the journal (NOT from projections), writes the
74
+ * snapshot blob, then publishes the manifest (the manifest is the commit
75
+ * point — an orphan snapshot without a manifest is ignored).
76
+ *
77
+ * NOTE (F6): this slice materializes from the full journal on demand. The
78
+ * incremental "latest verified checkpoint + sealed tail, built outside the hot
79
+ * lock" optimization is a later slice; on-demand build here is not on the hot
80
+ * write path.
81
+ */
82
+ export function createCheckpoint(cwd, capabilities = BASELINE_CAPABILITIES) {
83
+ // Cap to the COMMITTED head (meta.next_seq-1, published after fsync). The
84
+ // raw segment read is lock-free, so a concurrent append may have written —
85
+ // but not yet fsync'd/published — records beyond the committed head; excluding
86
+ // them keeps the manifest bound to durable journal state (codex review MED).
87
+ const committedHead = journalHeadSeq(cwd);
88
+ const records = readJournalRecords(cwd).filter(r => r.seq <= committedHead);
89
+ const head = headRecord(records);
90
+ if (!head)
91
+ return { created: false, reason: 'empty journal — nothing to checkpoint' };
92
+ if (journalHasExternalizedPayload(records)) {
93
+ return { created: false, reason: 'journal has externalized payload_ref records; materialize cannot dereference them yet (pln#566 F4)' };
94
+ }
95
+ const live = applyRecordsToLive(records, new Map());
96
+ const entities = [...live.values()];
97
+ const snapshot = JSON.stringify(entities);
98
+ const manifest = {
99
+ schema_version: CHECKPOINT_SCHEMA_VERSION,
100
+ store_id: loadConfig(cwd).project_id ?? 'unknown',
101
+ head_seq: head.seq,
102
+ head_identity: recordIdentity(head),
103
+ snapshot_sha256: sha256(snapshot),
104
+ entity_count: entities.length,
105
+ capability_vector: capabilities,
106
+ created_at: nowISO(),
107
+ };
108
+ const dir = checkpointsDir(cwd);
109
+ fs.mkdirSync(dir, { recursive: true });
110
+ // Snapshot first, manifest last (commit point).
111
+ writeFileAtomic(snapshotPath(cwd, head.seq), snapshot);
112
+ writeFileAtomic(manifestPath(cwd, head.seq), JSON.stringify(manifest));
113
+ return { created: true, manifest };
114
+ }
115
+ /** Default: create a fresh checkpoint once the journal has grown this many
116
+ * records past the last checkpoint head. Bounds the sealed tail so
117
+ * checkpointRead's gap-replay stays cheap, without checkpointing every persist. */
118
+ export const DEFAULT_CHECKPOINT_INTERVAL = 500;
119
+ /**
120
+ * Create a checkpoint ONLY if the journal has grown >= interval records past
121
+ * the last checkpoint head (cheap head-seq check; no full scan unless building).
122
+ * Intended for off-hot-path callers (session-start maintenance). Journal-derived
123
+ * (F6). No-op when the journal is off/empty or hasn't grown enough.
124
+ */
125
+ export function maybeCreateCheckpoint(cwd, interval = DEFAULT_CHECKPOINT_INTERVAL) {
126
+ const head = journalHeadSeq(cwd);
127
+ if (head === 0)
128
+ return { created: false, gap: 0, reason: 'journal empty/off' };
129
+ const last = loadLatestCheckpointManifest(cwd)?.head_seq ?? 0;
130
+ const gap = head - last;
131
+ if (gap < interval)
132
+ return { created: false, gap, reason: `gap ${gap} < interval ${interval}` };
133
+ const res = createCheckpoint(cwd);
134
+ return { created: res.created, gap, reason: res.reason };
135
+ }
136
+ /**
137
+ * Highest-head_seq manifest on disk, or undefined if none. Selects by NUMERIC
138
+ * head_seq parsed from the filename — lexicographic order on the 12-zero-pad
139
+ * breaks once seq exceeds 12 digits (codex review LOW).
140
+ */
141
+ export function loadLatestCheckpointManifest(cwd) {
142
+ const dir = checkpointsDir(cwd);
143
+ if (!fs.existsSync(dir))
144
+ return undefined;
145
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.manifest.json'))
146
+ .sort((a, b) => (parseInt(b, 10) || 0) - (parseInt(a, 10) || 0)); // numeric desc on the seq prefix
147
+ for (const f of files) {
148
+ try {
149
+ return JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8'));
150
+ }
151
+ catch { /* skip corrupt manifest, try the next */ }
152
+ }
153
+ return undefined;
154
+ }
155
+ function isMaterializedEntityArray(v) {
156
+ return Array.isArray(v) && v.every(e => !!e && typeof e === 'object'
157
+ && typeof e.item_type === 'string'
158
+ && typeof e.item_id === 'string'
159
+ && !!e.payload && typeof e.payload === 'object');
160
+ }
161
+ /**
162
+ * Verify a checkpoint against an ALREADY-READ record set — never throws,
163
+ * validates snapshot SHAPE (not just sha256), and binds to the SAME journal
164
+ * view the caller will serve from (no verify/serve TOCTOU). WITHOUT reading
165
+ * projection files (F3). Returns the parsed entities when valid.
166
+ */
167
+ function verifyCheckpointAgainstRecords(manifest, snapshotRaw, records, cwd) {
168
+ try {
169
+ if (manifest.schema_version !== CHECKPOINT_SCHEMA_VERSION) {
170
+ return { valid: false, reason: `unsupported checkpoint schema_version ${manifest.schema_version}` };
171
+ }
172
+ if (sha256(snapshotRaw) !== manifest.snapshot_sha256) {
173
+ return { valid: false, reason: 'snapshot sha256 mismatch (corrupt/tampered blob)' };
174
+ }
175
+ let parsed;
176
+ try {
177
+ parsed = JSON.parse(snapshotRaw);
178
+ }
179
+ catch {
180
+ return { valid: false, reason: 'snapshot is not valid JSON' };
181
+ }
182
+ if (!isMaterializedEntityArray(parsed))
183
+ return { valid: false, reason: 'snapshot is not a MaterializedEntity[]' };
184
+ const expectedStore = loadConfig(cwd).project_id ?? 'unknown';
185
+ if (manifest.store_id !== expectedStore) {
186
+ return { valid: false, reason: `store_id mismatch (manifest ${manifest.store_id} vs ${expectedStore}) — copied/wrong-branch checkpoint` };
187
+ }
188
+ const head = records.find(r => r.seq === manifest.head_seq);
189
+ if (!head)
190
+ return { valid: false, reason: `head_seq ${manifest.head_seq} not found in journal` };
191
+ if (recordIdentity(head) !== manifest.head_identity) {
192
+ return { valid: false, reason: 'head record identity mismatch — journal lineage diverged from checkpoint' };
193
+ }
194
+ return { valid: true, entities: parsed };
195
+ }
196
+ catch (err) {
197
+ return { valid: false, reason: `verification error: ${err instanceof Error ? err.message : String(err)}` };
198
+ }
199
+ }
200
+ /**
201
+ * Public no-throw verification: snapshot integrity + shape, store binding, and
202
+ * journal-lineage binding. Reads the journal once. Returns valid:false on any
203
+ * failure (never throws) so callers fall back.
204
+ */
205
+ export function verifyCheckpoint(manifest, snapshotRaw, cwd) {
206
+ let records;
207
+ try {
208
+ records = readJournalRecords(cwd);
209
+ }
210
+ catch (err) {
211
+ return { valid: false, reason: `journal read failed: ${err instanceof Error ? err.message : String(err)}` };
212
+ }
213
+ const r = verifyCheckpointAgainstRecords(manifest, snapshotRaw, records, cwd);
214
+ return { valid: r.valid, reason: r.reason };
215
+ }
216
+ /**
217
+ * Materialize state from the latest VERIFIED checkpoint + the sealed tail
218
+ * (records with seq > head_seq), using the same reducer + projector as
219
+ * full-journal materialization. Reads the journal exactly ONCE and uses that
220
+ * single view for verification, the F4 payload_ref guard, and the tail replay
221
+ * (no TOCTOU). Returns null on any failure (caller falls back to projections).
222
+ */
223
+ export function materializeStateFromCheckpoint(cwd) {
224
+ const manifest = loadLatestCheckpointManifest(cwd);
225
+ if (!manifest)
226
+ return null;
227
+ let snapshotRaw;
228
+ try {
229
+ snapshotRaw = fs.readFileSync(snapshotPath(cwd, manifest.head_seq), 'utf-8');
230
+ }
231
+ catch {
232
+ return null; // orphan manifest without a readable snapshot
233
+ }
234
+ let records;
235
+ // Same committed-head cap as the build path: only durable records (seq <=
236
+ // meta.next_seq-1) drive verification, the F4 guard, and the tail replay.
237
+ try {
238
+ const committedHead = journalHeadSeq(cwd);
239
+ records = readJournalRecords(cwd).filter(r => r.seq <= committedHead);
240
+ }
241
+ catch {
242
+ return null;
243
+ }
244
+ const verdict = verifyCheckpointAgainstRecords(manifest, snapshotRaw, records, cwd);
245
+ if (!verdict.valid || !verdict.entities)
246
+ return null;
247
+ // F4 guard: an externalized payload (which materialize can't deref) means the
248
+ // checkpoint/tail would drop entities — refuse to serve, fall back.
249
+ if (journalHasExternalizedPayload(records))
250
+ return null;
251
+ const live = new Map();
252
+ for (const e of verdict.entities)
253
+ live.set(`${e.item_type}:${e.item_id}`, e);
254
+ // Replay only the sealed tail (head_seq+1..end) from the SAME record view.
255
+ applyRecordsToLive(records.filter(r => r.seq > manifest.head_seq), live);
256
+ return projectLiveToState(live);
257
+ }
258
+ //# sourceMappingURL=checkpoint.js.map