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
@@ -1,11 +1,13 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { ConstraintSchema, DecisionSchema, TrapSchema, HandoffSchema, PlanItemSchema } from './schema.js';
4
- import { ensureMemoryDir, resolveEntityDir } from './io.js';
4
+ import { ensureMemoryDir, resolveEntityDir, writeFileAtomic } from './io.js';
5
5
  import { mutate } from './mutation-pipeline.js';
6
6
  import { commitMemoryChange } from './memory-git.js';
7
7
  import { appendEvent } from './event-log.js';
8
- import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
8
+ import { appendJournalRecords, resolveJournalMode, resolveCheckpointRead } from './events/journal.js';
9
+ import { materializeStateFromCheckpoint } from './events/checkpoint.js';
10
+ import { loadVersionedJsonFile, serializeVersionedJson, preparePersistedDocument } from './migration.js';
9
11
  import { rebuildProjectMd } from './markdown.js';
10
12
  import { refreshLiveCompanions } from '../commands/export.js';
11
13
  import { logger } from './logger.js';
@@ -102,6 +104,24 @@ export function findLoadValidationWarning(entity, id, cwd) {
102
104
  export function loadState(cwd) {
103
105
  // Load from entity-aligned directories (with legacy fallback)
104
106
  const effectiveCwd = cwd ?? process.cwd();
107
+ // pln#566 Inc0 s2 — checkpointRead fast path. OFF by default (dual/off mode):
108
+ // projection files remain the read substrate. When the capability is enabled
109
+ // (primary soak) AND a verified journal-derived checkpoint exists, serve from
110
+ // checkpoint + sealed tail instead of reading every projection file. ANY
111
+ // failure (no checkpoint, failed verification, replay error) falls through to
112
+ // the projection read below — the checkpoint is never the sole truth.
113
+ if (resolveCheckpointRead(effectiveCwd)) {
114
+ try {
115
+ const fast = materializeStateFromCheckpoint(effectiveCwd);
116
+ // Merge over emptyState so the served State carries the same envelope
117
+ // fields (version/write_version) a projection read produces; the
118
+ // checkpoint only materializes the 5 entity collections. Already sorted
119
+ // by projectLiveToState.
120
+ if (fast)
121
+ return { ...emptyState(), ...fast };
122
+ }
123
+ catch { /* fall through to projection read */ }
124
+ }
105
125
  const state = emptyState();
106
126
  state.active_constraints = loadDirectoryItems(resolveEntityDir('constraints', effectiveCwd, 'read'), ConstraintSchema, 'constraint');
107
127
  state.recent_decisions = loadDirectoryItems(resolveEntityDir('decisions', effectiveCwd, 'read'), DecisionSchema, 'decision');
@@ -116,72 +136,189 @@ export function loadState(cwd) {
116
136
  state.plan_items.sort((a, b) => a.created_at.localeCompare(b.created_at));
117
137
  return state;
118
138
  }
119
- function syncDirectory(dirPath, items, documentType, schema) {
120
- if (!fs.existsSync(dirPath)) {
121
- fs.mkdirSync(dirPath, { recursive: true });
139
+ const persistWriteStats = { written: 0, skippedUnchanged: 0 };
140
+ export function readPersistWriteStats() {
141
+ return { ...persistWriteStats };
142
+ }
143
+ export function resetPersistWriteStats() {
144
+ persistWriteStats.written = 0;
145
+ persistWriteStats.skippedUnchanged = 0;
146
+ }
147
+ /**
148
+ * Order-insensitive canonical form of a JSON document, for the dirty-tracking
149
+ * skip compare. Recursively sorts object keys; returns the raw input on parse
150
+ * failure so corrupt/non-JSON bytes never compare equal to a valid desired doc.
151
+ */
152
+ function canonicalJson(raw) {
153
+ const sortKeys = (value) => {
154
+ if (Array.isArray(value))
155
+ return value.map(sortKeys);
156
+ if (value && typeof value === 'object') {
157
+ return Object.fromEntries(Object.entries(value)
158
+ .sort(([a], [b]) => a.localeCompare(b))
159
+ .map(([k, v]) => [k, sortKeys(v)]));
160
+ }
161
+ return value;
162
+ };
163
+ try {
164
+ return JSON.stringify(sortKeys(JSON.parse(raw)));
165
+ }
166
+ catch {
167
+ return raw; // unparseable → never equals a canonical desired doc
122
168
  }
123
- // Write all current items
169
+ }
170
+ /**
171
+ * Pure planning pass: compute which projection files WOULD change, without
172
+ * writing anything. Same dirty-tracking + canonical-compare + parseable-guard
173
+ * logic as the old syncDirectory; only the IO is deferred to applySyncPlan.
174
+ */
175
+ function planSyncDirectory(dirPath, items, documentType, schema, deleteMissing) {
176
+ const plan = { dirPath, writes: [], deletes: [] };
177
+ // Write only the items whose on-disk bytes would change (dirty-tracking,
178
+ // pln#543 step 3). The comparison uses the writer's own serializer so a
179
+ // "skip" can never diverge from what saveVersionedJsonFile would produce.
180
+ // Safe against trp#126: a missing/byte-different file never matches, so an
181
+ // in-state entity whose projection is absent/corrupt is always rewritten.
124
182
  const currentIds = new Set();
125
183
  for (const item of items) {
126
184
  currentIds.add(item.id);
127
185
  const filepath = path.join(dirPath, `${item.id}.json`);
128
- saveVersionedJsonFile(documentType, filepath, item);
129
- }
130
- // Remove files that are no longer in the state (e.g. if deleted/pruned).
131
- // CRITICAL: we must distinguish "file dropped from state intentionally" from
132
- // "file silently dropped by loadDirectoryItems because its schema.parse threw".
133
- // Deleting the second kind corrupts data (see trap: silent-data-loss via
134
- // load-swallow + write-sync-GC). So before unlinking, we re-validate the file
135
- // against the schema. Parseable + not in state = intentional remove → unlink.
136
- // Unparseable = preserved, operator can inspect/repair.
137
- const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.json'));
186
+ const desired = serializeVersionedJson(documentType, item);
187
+ let existing;
188
+ try {
189
+ existing = fs.readFileSync(filepath, 'utf-8');
190
+ }
191
+ catch {
192
+ existing = undefined; // missing/unreadable write
193
+ }
194
+ // Semantic (canonical, sorted-key) compare, not byte compare: loadState
195
+ // re-parses through zod which can reorder keys; a byte compare would
196
+ // rewrite the whole store every persist. Unparseable bytes never match
197
+ // → rewrite (keeps trp#126 safety).
198
+ if (existing !== undefined && canonicalJson(existing) === canonicalJson(desired)) {
199
+ persistWriteStats.skippedUnchanged += 1;
200
+ continue;
201
+ }
202
+ plan.writes.push({ filepath, desired, item, created: existing === undefined });
203
+ }
204
+ if (!deleteMissing)
205
+ return plan;
206
+ // Plan removals of files no longer in state. CRITICAL: distinguish an
207
+ // intentional drop from a file silently dropped by loadDirectoryItems on a
208
+ // schema.parse throw (deleting the second kind corrupts data — trp#126). Only
209
+ // parseable + not-in-state files are unlinked; unparseable are preserved.
210
+ const files = fs.existsSync(dirPath)
211
+ ? fs.readdirSync(dirPath).filter(f => f.endsWith('.json'))
212
+ : [];
138
213
  for (const file of files) {
139
214
  const id = file.replace('.json', '');
140
215
  if (currentIds.has(id))
141
216
  continue;
142
217
  const filepath = path.join(dirPath, file);
143
- let parseable = false;
144
218
  try {
145
219
  schema.parse(loadVersionedJsonFile(documentType, filepath).document);
146
- parseable = true;
147
220
  }
148
221
  catch {
149
222
  // Already logged by loadDirectoryItems — leave the file in place.
223
+ continue;
150
224
  }
151
- if (parseable) {
152
- fs.unlinkSync(filepath);
153
- }
225
+ plan.deletes.push({ filepath, id });
226
+ }
227
+ return plan;
228
+ }
229
+ /** Apply a planned sync: the actual projection-file writes/unlinks (the IO that
230
+ * must happen AFTER the journal append+fsync). Returns the dirty result. */
231
+ function applySyncPlan(plan) {
232
+ const result = { written: [], deleted: [] };
233
+ // Create the entity dir unconditionally (matches the pre-split syncDirectory,
234
+ // which always ensured the dir even for an empty/unchanged collection).
235
+ if (!fs.existsSync(plan.dirPath)) {
236
+ fs.mkdirSync(plan.dirPath, { recursive: true });
237
+ }
238
+ for (const { filepath, desired, item, created } of plan.writes) {
239
+ writeFileAtomic(filepath, desired);
240
+ persistWriteStats.written += 1;
241
+ result.written.push({ item, created });
154
242
  }
243
+ for (const { filepath, id } of plan.deletes) {
244
+ fs.unlinkSync(filepath);
245
+ result.deleted.push(id);
246
+ }
247
+ return result;
155
248
  }
156
249
  export function saveState(state, cwd) {
157
250
  persistState(state, cwd, { writeProjectMarkdown: false });
158
251
  }
159
252
  function persistStateUnlocked(state, cwd, options = {}) {
160
- writeStateDirectories(state, cwd);
253
+ ensureMemoryDir(cwd);
254
+ const effectiveCwd = cwd ?? process.cwd();
255
+ // pln#566 F1 — JOURNAL BEFORE PROJECTIONS (invariant I2). Persist now runs in
256
+ // three ordered phases so the journal can never lag the projections: a crash
257
+ // mid-persist leaves the journal AHEAD (the only direction lazy-reconcile can
258
+ // recover), never projections ahead (which materialize/verify could not
259
+ // explain). Phase 1 PLAN (pure compute, no IO) → Phase 2 emit+fsync the
260
+ // per-entity post-images to the journal → Phase 3 APPLY projection writes.
261
+ const { plans, legacyDeletes, dirty } = planStateDirectories(state, effectiveCwd, options.deleteMissing ?? false);
262
+ emitPerEntityJournalRecords(dirty, options.eventAction, effectiveCwd);
263
+ faultPoint('after_journal'); // test-only: crash AFTER journal, BEFORE projections
264
+ applyStatePlans(plans, legacyDeletes);
265
+ faultPoint('after_projection'); // test-only: crash AFTER projections written
161
266
  if (options.writeProjectMarkdown ?? true) {
162
- rebuildProjectMd(state, cwd);
267
+ rebuildProjectMd(state, effectiveCwd);
163
268
  }
269
+ // v1 events.jsonl: keep the coarse store event for existing consumers, but
270
+ // suppress its envelope-only journal dual-write — the v2 per-entity emit
271
+ // above is the authoritative §2.8 diff choke point.
164
272
  appendEvent({
165
273
  action: options.eventAction ?? 'update',
166
274
  item_type: 'state',
167
275
  agent: 'system',
168
276
  summary: options.eventSummary,
169
- }, cwd);
170
- commitMemoryChange(options.commitMessage ?? 'state update', cwd);
171
- // Auto-refresh live companion files (Tier B/C agents) after state mutations.
172
- // Non-fatal: failures are logged but don't break the mutation.
277
+ }, effectiveCwd, { journalDualWrite: false });
278
+ // NOTE (pln#558 step 2): the git commit and refreshLiveCompanions used to
279
+ // run here, INSIDE the mutation lock. A single persistState was holding
280
+ // the lock for >5s on Juan's machine (full-store rewrite + git add -A +
281
+ // git commit + live-companion refresh), which serialized every other
282
+ // writer. They are now invoked by persistState / mutateState AFTER the
283
+ // lock releases — see runPostWriteHooks below. The critical section is
284
+ // now writes-only; commit / companion-refresh are best-effort observers.
285
+ }
286
+ function runPostWriteHooks(cwd, commitMessage) {
287
+ // git add + git commit. Safe outside the lock because (a) git itself
288
+ // serializes concurrent index access via .git/index.lock, (b) the
289
+ // implementation already swallows failures (see memory-git.ts), and
290
+ // (c) the commit is an audit trail, not the data itself.
291
+ try {
292
+ commitMemoryChange(commitMessage, cwd);
293
+ }
294
+ catch { /* best-effort */ }
295
+ // Live companion files (Tier B/C agent surfaces). Already best-effort.
173
296
  try {
174
297
  refreshLiveCompanions(cwd);
175
298
  }
176
299
  catch { /* best-effort */ }
177
300
  }
178
- function cleanupLegacyDir(entityName, currentIds, cwd, documentType, schema) {
301
+ /**
302
+ * Test-only crash injection (pln#566 F1). No-op unless BRAINCLAW_FAULT_POINT
303
+ * matches the label — then it throws, simulating a process death at that exact
304
+ * point in the persist pipeline so crash-ordering invariants can be tested
305
+ * deterministically without racing a real SIGKILL.
306
+ */
307
+ function faultPoint(label) {
308
+ if (process.env.BRAINCLAW_FAULT_POINT === label) {
309
+ throw new Error(`fault-injection: crashed at "${label}" (BRAINCLAW_FAULT_POINT)`);
310
+ }
311
+ }
312
+ /**
313
+ * Plan (do not apply) the removal of legacy-dir orphans. Read-only: matches
314
+ * syncDirectory's safety condition (only parseable records absent from state
315
+ * are deletable; unparseable are preserved for inspection/repair). The actual
316
+ * unlink is deferred to applyStatePlans so it lands AFTER the journal append.
317
+ */
318
+ function planCleanupLegacyDir(entityName, currentIds, cwd, documentType, schema) {
319
+ const out = [];
179
320
  const writeDir = resolveEntityDir(entityName, cwd, 'write');
180
321
  const readDir = resolveEntityDir(entityName, cwd, 'read');
181
- // If read resolves to a different (legacy) directory, clean orphans there too.
182
- // Match syncDirectory's safety condition: only delete parseable records that
183
- // are absent from the current state. Schema-invalid legacy files may be drifted
184
- // data that operators still need to inspect or repair.
185
322
  if (readDir !== writeDir && fs.existsSync(readDir)) {
186
323
  const files = fs.readdirSync(readDir).filter(f => f.endsWith('.json'));
187
324
  for (const file of files) {
@@ -189,51 +326,110 @@ function cleanupLegacyDir(entityName, currentIds, cwd, documentType, schema) {
189
326
  if (currentIds.has(id))
190
327
  continue;
191
328
  const filepath = path.join(readDir, file);
192
- let parseable = false;
193
329
  try {
194
330
  schema.parse(loadVersionedJsonFile(documentType, filepath).document);
195
- parseable = true;
196
331
  }
197
332
  catch {
198
333
  logger.warn(`Preserving unparseable legacy ${entityName} file ${file}`);
199
334
  continue;
200
335
  }
201
- if (parseable) {
202
- fs.unlinkSync(filepath);
203
- }
336
+ out.push({ filepath, id }); // O3: surfaced so a delete tombstone is emitted
204
337
  }
205
338
  }
339
+ return out;
206
340
  }
207
- function writeStateDirectories(state, cwd) {
208
- ensureMemoryDir(cwd);
209
- const effectiveCwd = cwd ?? process.cwd();
341
+ /**
342
+ * Phase 1 of persist: compute every projection change WITHOUT writing. The
343
+ * returned `dirty` (post-images + tombstone ids) lets the journal be emitted +
344
+ * fsync'd before applyStatePlans touches any file (pln#566 F1 / I2).
345
+ */
346
+ function planStateDirectories(state, cwd, deleteMissing) {
210
347
  const entities = [
211
- { name: 'constraints', items: state.active_constraints, docType: 'constraint', schema: ConstraintSchema },
212
- { name: 'decisions', items: state.recent_decisions, docType: 'decision', schema: DecisionSchema },
213
- { name: 'traps', items: state.known_traps, docType: 'trap', schema: TrapSchema },
214
- { name: 'handoffs', items: state.open_handoffs, docType: 'handoff', schema: HandoffSchema },
215
- { name: 'plans', items: state.plan_items, docType: 'plan', schema: PlanItemSchema },
348
+ { name: 'constraints', itemType: 'constraint', items: state.active_constraints, docType: 'constraint', schema: ConstraintSchema },
349
+ { name: 'decisions', itemType: 'decision', items: state.recent_decisions, docType: 'decision', schema: DecisionSchema },
350
+ { name: 'traps', itemType: 'trap', items: state.known_traps, docType: 'trap', schema: TrapSchema },
351
+ { name: 'handoffs', itemType: 'handoff', items: state.open_handoffs, docType: 'handoff', schema: HandoffSchema },
352
+ { name: 'plans', itemType: 'plan', items: state.plan_items, docType: 'plan', schema: PlanItemSchema },
216
353
  ];
217
- for (const { name, items, docType, schema } of entities) {
218
- const writeDir = resolveEntityDir(name, effectiveCwd, 'write');
219
- syncDirectory(writeDir, items, docType, schema);
220
- const currentIds = new Set(items.map(item => item.id));
221
- cleanupLegacyDir(name, currentIds, effectiveCwd, docType, schema);
354
+ const plans = [];
355
+ const legacyDeletes = [];
356
+ const dirty = [];
357
+ for (const { name, itemType, items, docType, schema } of entities) {
358
+ const writeDir = resolveEntityDir(name, cwd, 'write');
359
+ const plan = planSyncDirectory(writeDir, items, docType, schema, deleteMissing);
360
+ plans.push(plan);
361
+ const deleted = plan.deletes.map(d => d.id);
362
+ if (deleteMissing) {
363
+ // O3: legacy-dir orphans must also emit a delete tombstone.
364
+ const legacy = planCleanupLegacyDir(name, new Set(items.map(i => i.id)), cwd, docType, schema);
365
+ for (const l of legacy) {
366
+ legacyDeletes.push(l);
367
+ deleted.push(l.id);
368
+ }
369
+ }
370
+ dirty.push({ itemType, written: plan.writes.map(w => ({ item: w.item, created: w.created })), deleted });
371
+ }
372
+ return { plans, legacyDeletes, dirty };
373
+ }
374
+ /** Phase 3 of persist: apply the planned projection writes/unlinks (the IO that
375
+ * must follow the journal append+fsync). */
376
+ function applyStatePlans(plans, legacyDeletes) {
377
+ for (const plan of plans)
378
+ applySyncPlan(plan);
379
+ for (const { filepath } of legacyDeletes) {
380
+ try {
381
+ fs.unlinkSync(filepath);
382
+ }
383
+ catch { /* already gone — idempotent */ }
384
+ }
385
+ }
386
+ /**
387
+ * Emit one journal record per dirty entity with its full post-image
388
+ * (entity-state class, §2.1.1 / §2.8): the persist path is where the store's
389
+ * source-of-truth events are minted, because only it holds the entity docs.
390
+ * No-op when the journal flag is off. Failures are swallowed inside
391
+ * appendJournalRecords (dual mode: v1 projections remain the truth).
392
+ */
393
+ function emitPerEntityJournalRecords(dirty, storeAction, cwd) {
394
+ if (resolveJournalMode(cwd) === 'off')
395
+ return;
396
+ const records = [];
397
+ for (const { itemType, written, deleted } of dirty) {
398
+ for (const { item, created } of written) {
399
+ // Post-image = the prepared document (with schema_version), so the
400
+ // journal record is byte-faithful to the projection: materialize can
401
+ // reconstruct an identical file, and verify compares like-for-like.
402
+ records.push({
403
+ action: storeAction && storeAction !== 'update' ? storeAction : (created ? 'create' : 'update'),
404
+ item_type: itemType,
405
+ item_id: item.id,
406
+ agent: 'system',
407
+ payload: preparePersistedDocument(itemType, item),
408
+ });
409
+ }
410
+ for (const id of deleted) {
411
+ records.push({ action: 'delete', item_type: itemType, item_id: id, agent: 'system' });
412
+ }
222
413
  }
414
+ if (records.length > 0)
415
+ appendJournalRecords(records, cwd);
223
416
  }
224
417
  export function persistState(state, cwd, options = {}) {
225
418
  const effectiveCwd = cwd ?? process.cwd();
226
419
  mutate({ cwd: effectiveCwd }, () => {
227
420
  persistStateUnlocked(state, effectiveCwd, options);
228
421
  });
422
+ runPostWriteHooks(effectiveCwd, options.commitMessage ?? 'state update');
229
423
  }
230
424
  export function mutateState(mutateFn, cwd, options = {}) {
231
425
  const effectiveCwd = cwd ?? process.cwd();
232
- return mutate({ cwd: effectiveCwd }, () => {
426
+ const result = mutate({ cwd: effectiveCwd }, () => {
233
427
  const state = loadState(effectiveCwd);
234
- const result = mutateFn(state);
235
- persistStateUnlocked(state, effectiveCwd, options);
236
- return result;
428
+ const value = mutateFn(state);
429
+ persistStateUnlocked(state, effectiveCwd, { ...options, deleteMissing: true });
430
+ return value;
237
431
  });
432
+ runPostWriteHooks(effectiveCwd, options.commitMessage ?? 'state update');
433
+ return result;
238
434
  }
239
435
  //# sourceMappingURL=state.js.map
@@ -3,7 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { loadActiveProject } from './active-project.js';
5
5
  import { loadConfig } from './config.js';
6
- import { loadCurrentSession } from './identity.js';
6
+ import { loadCurrentSession, loadSessionById } from './identity.js';
7
7
  import { MEMORY_DIR } from './io.js';
8
8
  import { summarizeWorkspaceProjects } from './workspace-projects.js';
9
9
  /**
@@ -98,10 +98,18 @@ export function resolveTargetStore(cwd = process.cwd(), target = 'local', option
98
98
  * 6. Workspace anchor or process.cwd()
99
99
  */
100
100
  export function resolveEffectiveCwd(options = {}) {
101
+ return resolveEffectiveCwdInfo(options).cwd;
102
+ }
103
+ /**
104
+ * Resolve the effective cwd and explain which selector won. Use this for MCP
105
+ * facades that must echo their project scope to avoid silent cross-project reads.
106
+ */
107
+ export function resolveEffectiveCwdInfo(options = {}) {
101
108
  const baseCwd = path.resolve(options.baseCwd ?? process.cwd());
102
109
  // 1. Explicit --cwd flag
103
110
  if (options.explicitCwd) {
104
- return path.resolve(options.explicitCwd);
111
+ const cwd = path.resolve(options.explicitCwd);
112
+ return { cwd, active_source: 'explicit', resolved_project: projectInfo(cwd) };
105
113
  }
106
114
  // 2. BRAINCLAW_CWD env var — set by MCP configs to anchor resolution to the
107
115
  // workspace regardless of the IDE's process.cwd() at launch time. It is a
@@ -118,14 +126,16 @@ export function resolveEffectiveCwd(options = {}) {
118
126
  if (envProject) {
119
127
  const resolved = resolveProjectRef(envProject, anchorCwd, options.storeChainOptions);
120
128
  if (resolved)
121
- return resolved;
129
+ return { cwd: resolved, active_source: 'env_project', resolved_project: projectInfo(resolved) };
122
130
  }
123
131
  // 4. Session-scoped active project (per-agent, no cross-agent interference)
124
- const session = loadCurrentSession(anchorCwd);
132
+ const session = options.sessionId
133
+ ? loadSessionById(options.sessionId, anchorCwd)
134
+ : loadCurrentSession(anchorCwd);
125
135
  if (session?.active_project) {
126
136
  const sp = session.active_project;
127
137
  if (fs.existsSync(path.join(sp.path, MEMORY_DIR, 'config.yaml'))) {
128
- return sp.path;
138
+ return { cwd: sp.path, active_source: 'session', resolved_project: { path: sp.path, name: sp.name } };
129
139
  }
130
140
  }
131
141
  // 5. Global active-project.json from workspace root
@@ -133,11 +143,20 @@ export function resolveEffectiveCwd(options = {}) {
133
143
  if (wsRoot) {
134
144
  const active = loadActiveProject(wsRoot);
135
145
  if (active && fs.existsSync(path.join(active.path, MEMORY_DIR, 'config.yaml'))) {
136
- return active.path;
146
+ return { cwd: active.path, active_source: 'global', resolved_project: { path: active.path, name: active.name } };
137
147
  }
138
148
  }
139
149
  // 6. Default
140
- return anchorCwd;
150
+ return { cwd: anchorCwd, active_source: 'cwd', resolved_project: projectInfo(anchorCwd) };
151
+ }
152
+ function projectInfo(cwd) {
153
+ try {
154
+ const config = loadConfig(cwd);
155
+ return { path: cwd, name: config.project_name };
156
+ }
157
+ catch {
158
+ return { path: cwd };
159
+ }
141
160
  }
142
161
  /**
143
162
  * Find the workspace root (farthest store in the chain, or the one with
@@ -166,12 +185,24 @@ export function resolveProjectRef(ref, cwd = process.cwd(), storeChainOptions) {
166
185
  ?? resolveWorkspaceRoot(cwd, storeChainOptions);
167
186
  if (!wsRoot)
168
187
  return undefined;
169
- // Try as absolute path
188
+ // The trust boundary for raw path refs is the provided cwd. Callers in
189
+ // MCP context set cwd to the workspace root, so child projects resolve
190
+ // naturally. Walking further up (to a user-level store at home) would
191
+ // allow path-injection to sibling or home stores — that is the vulnerability
192
+ // we are closing. Name-based lookup below is unrestricted since it matches
193
+ // by project_name / project_id, not by arbitrary path.
194
+ const trustBoundary = path.resolve(cwd);
195
+ // Try as absolute path — only allowed if within the cwd boundary.
170
196
  if (path.isAbsolute(ref)) {
197
+ if (!isAtOrBelow(ref, trustBoundary))
198
+ return undefined;
171
199
  return fs.existsSync(path.join(ref, MEMORY_DIR, 'config.yaml')) ? ref : undefined;
172
200
  }
173
- // Try as relative path from workspace root
174
- const asPath = path.resolve(wsRoot, ref);
201
+ // Try as relative path resolved from the cwd boundary.
202
+ // Guards against ../ traversal (e.g. "../sibling-project").
203
+ const asPath = path.resolve(trustBoundary, ref);
204
+ if (!isAtOrBelow(asPath, trustBoundary))
205
+ return undefined;
175
206
  if (fs.existsSync(path.join(asPath, MEMORY_DIR, 'config.yaml'))) {
176
207
  return asPath;
177
208
  }
@@ -315,8 +346,10 @@ function findClosestStoreBelow(target, ceiling) {
315
346
  */
316
347
  function isAtOrBelow(dir, ancestor) {
317
348
  const rel = path.relative(ancestor, dir);
318
- // If relative path starts with '..', dir is above ancestor
319
- return !rel.startsWith('..');
349
+ // '..' prefix → dir is above ancestor. An absolute result means a different
350
+ // Windows drive (path.relative returns the absolute `to` path then), which is
351
+ // also outside the boundary — without this check `D:\evil` would pass.
352
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
320
353
  }
321
354
  function resolveAbsoluteTargetPath(cwd, target) {
322
355
  if (path.isAbsolute(target)) {