brainclaw 0.23.0 → 0.24.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.
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import path from 'node:path';
2
3
  import { Command } from 'commander';
3
4
  import { runInit } from './commands/init.js';
4
5
  import { runSetup } from './commands/setup.js';
@@ -94,15 +95,26 @@ program
94
95
  .hook('preAction', (_thisCommand, actionCommand) => {
95
96
  const root = actionCommand.optsWithGlobals();
96
97
  initLogLevel({ verbose: root.verbose, debug: root.debug });
97
- // Resolve effective cwd (--cwd > BRAINCLAW_PROJECT > active-project > process.cwd)
98
- const effectiveCwd = resolveEffectiveCwd({ explicitCwd: root.cwd });
99
- if (effectiveCwd !== process.cwd()) {
100
- // Store resolved cwd so commands can read it via optsWithGlobals().cwd
101
- actionCommand.setOptionValue('cwd', effectiveCwd);
98
+ // Skip effective cwd resolution for commands that create the store
99
+ const cmdName = actionCommand.name();
100
+ const skipResolution = cmdName === 'init' || cmdName === 'setup';
101
+ if (!skipResolution) {
102
+ // Resolve effective cwd (--cwd > BRAINCLAW_PROJECT > active-project > process.cwd)
103
+ const effectiveCwd = resolveEffectiveCwd({ explicitCwd: root.cwd });
104
+ if (effectiveCwd !== process.cwd()) {
105
+ // Change process.cwd() so all commands resolve the correct store
106
+ // without needing individual --cwd plumbing
107
+ process.chdir(effectiveCwd);
108
+ logger.info(`Resolved effective cwd: ${effectiveCwd}`);
109
+ }
110
+ const removed = cleanOrphanFiles(memoryDir());
111
+ if (removed > 0) {
112
+ logger.info(`Cleaned ${removed} orphan lock/tmp file(s) in ${memoryDir()}`);
113
+ }
102
114
  }
103
- const removed = cleanOrphanFiles(memoryDir(effectiveCwd));
104
- if (removed > 0) {
105
- logger.info(`Cleaned ${removed} orphan lock/tmp file(s) in ${memoryDir(effectiveCwd)}`);
115
+ else if (root.cwd) {
116
+ // For init/setup, still respect explicit --cwd but nothing else
117
+ process.chdir(path.resolve(root.cwd));
106
118
  }
107
119
  });
108
120
  // --- init ---
@@ -1,4 +1,6 @@
1
- import { memoryExists, withStoreLock } from '../core/io.js';
1
+ import { memoryExists } from '../core/io.js';
2
+ import { mutate } from '../core/mutation-pipeline.js';
3
+ import { rebuildProjectMd } from '../core/markdown.js';
2
4
  import { loadCandidate, archiveCandidate, resolveIdOrAlias } from '../core/candidates.js';
3
5
  import { loadState, persistState } from '../core/state.js';
4
6
  import { generateIdWithLabel, nowISO } from '../core/ids.js';
@@ -36,7 +38,7 @@ export function acceptCandidate(id, by, cwd, byId) {
36
38
  requireMinimumTrustLevel(actorIdentity, 'trusted');
37
39
  const actor = actorIdentity.agent_name;
38
40
  let promotedItemId = '';
39
- withStoreLock(cwd, () => {
41
+ mutate({ cwd }, () => {
40
42
  const state = loadState(cwd);
41
43
  switch (candidate.type) {
42
44
  case 'constraint': {
@@ -136,6 +138,7 @@ export function acceptCandidate(id, by, cwd, byId) {
136
138
  after: { type: candidate.type, text: candidate.text },
137
139
  reason: 'trusted-agent',
138
140
  }, cwd);
141
+ rebuildProjectMd(loadState(cwd), cwd);
139
142
  });
140
143
  return {
141
144
  candidate_id: resolvedId,
@@ -1,7 +1,8 @@
1
1
  import { buildOperationalIdentity } from '../core/identity.js';
2
- import { memoryExists, memoryPath, withStoreLock, writeFileAtomic } from '../core/io.js';
2
+ import { memoryExists } from '../core/io.js';
3
+ import { mutate } from '../core/mutation-pipeline.js';
3
4
  import { saveClaim, generateClaimId, listClaims } from '../core/claims.js';
4
- import { generateMarkdown } from '../core/markdown.js';
5
+ import { rebuildProjectMd } from '../core/markdown.js';
5
6
  import { loadState, saveState } from '../core/state.js';
6
7
  import { nowISO } from '../core/ids.js';
7
8
  import { requireMinimumTrustLevel, requireRegisteredAgentIdentity } from '../core/agent-registry.js';
@@ -72,7 +73,7 @@ export function runClaim(description, options) {
72
73
  status: 'active',
73
74
  expires_at: options.ttl ? parseTtl(options.ttl) : undefined,
74
75
  };
75
- withStoreLock(options.cwd, () => {
76
+ mutate({ cwd: options.cwd }, () => {
76
77
  if (plan) {
77
78
  if (!plan.assignee) {
78
79
  plan.assignee = actor.agent;
@@ -84,7 +85,7 @@ export function runClaim(description, options) {
84
85
  saveState(state, options.cwd);
85
86
  }
86
87
  saveClaim(claim, options.cwd);
87
- writeFileAtomic(memoryPath('project.md', options.cwd), generateMarkdown(plan ? state : loadState(options.cwd), options.cwd));
88
+ rebuildProjectMd(plan ? state : loadState(options.cwd), options.cwd);
88
89
  });
89
90
  const planInfo = claim.plan_id ? ` [plan ${claim.plan_id}]` : '';
90
91
  const ttlInfo = claim.expires_at ? ` (expires ${claim.expires_at.slice(0, 16).replace('T', ' ')})` : '';
@@ -1,8 +1,9 @@
1
1
  import { resolveAgentScope, resolveCurrentAgentName } from '../core/agent-registry.js';
2
2
  import { loadConfig } from '../core/config.js';
3
3
  import { createInstruction } from '../core/instructions.js';
4
- import { memoryExists, memoryPath, withStoreLock, writeFileAtomic } from '../core/io.js';
5
- import { generateMarkdown } from '../core/markdown.js';
4
+ import { memoryExists } from '../core/io.js';
5
+ import { mutate } from '../core/mutation-pipeline.js';
6
+ import { rebuildProjectMd } from '../core/markdown.js';
6
7
  import { loadState } from '../core/state.js';
7
8
  import { scanText } from '../core/security.js';
8
9
  import { validateCliInput } from '../core/input-validation.js';
@@ -26,7 +27,7 @@ export function runInstruction(text, options = {}) {
26
27
  }
27
28
  }
28
29
  let entry;
29
- withStoreLock(cwd, () => {
30
+ mutate({ cwd }, () => {
30
31
  entry = createInstruction(text, {
31
32
  layer,
32
33
  scope,
@@ -34,7 +35,7 @@ export function runInstruction(text, options = {}) {
34
35
  author: options.author ?? resolveCurrentAgentName(cwd),
35
36
  supersedes: options.supersedes,
36
37
  }, cwd);
37
- writeFileAtomic(memoryPath('project.md', cwd), generateMarkdown(loadState(cwd)));
38
+ rebuildProjectMd(loadState(cwd), cwd);
38
39
  });
39
40
  if (!entry) {
40
41
  console.error('Error: failed to persist instruction.');
@@ -667,6 +667,11 @@ export class McpTaskRunner {
667
667
  onInternalError;
668
668
  active;
669
669
  queue = [];
670
+ _totalExecuted = 0;
671
+ _totalCancelled = 0;
672
+ _peakQueueDepth = 0;
673
+ _lastDurationMs = 0;
674
+ _lastWaitMs = 0;
670
675
  constructor(options) {
671
676
  this.executeTool = options.executeTool;
672
677
  this.onResult = options.onResult;
@@ -678,19 +683,35 @@ export class McpTaskRunner {
678
683
  get queuedRequestIds() {
679
684
  return this.queue.map((task) => task.requestId);
680
685
  }
686
+ /** Current single-writer queue metrics. */
687
+ get metrics() {
688
+ return {
689
+ totalExecuted: this._totalExecuted,
690
+ totalCancelled: this._totalCancelled,
691
+ queueDepth: this.queue.length,
692
+ peakQueueDepth: this._peakQueueDepth,
693
+ lastDurationMs: this._lastDurationMs,
694
+ lastWaitMs: this._lastWaitMs,
695
+ };
696
+ }
681
697
  enqueue(requestId, payload) {
682
698
  this.queue.push({
683
699
  requestId,
684
700
  payload,
685
701
  controller: new AbortController(),
686
702
  cancelled: false,
703
+ enqueuedAt: performance.now(),
687
704
  });
705
+ if (this.queue.length > this._peakQueueDepth) {
706
+ this._peakQueueDepth = this.queue.length;
707
+ }
688
708
  this.drain();
689
709
  }
690
710
  cancel(requestId) {
691
711
  if (this.active && this.active.requestId === requestId) {
692
712
  this.active.cancelled = true;
693
713
  this.active.controller.abort();
714
+ this._totalCancelled++;
694
715
  return 'active';
695
716
  }
696
717
  const index = this.queue.findIndex((task) => task.requestId === requestId);
@@ -698,6 +719,7 @@ export class McpTaskRunner {
698
719
  const [task] = this.queue.splice(index, 1);
699
720
  task.cancelled = true;
700
721
  task.controller.abort();
722
+ this._totalCancelled++;
701
723
  return 'queued';
702
724
  }
703
725
  return 'missing';
@@ -722,6 +744,7 @@ export class McpTaskRunner {
722
744
  return;
723
745
  }
724
746
  if (next.cancelled) {
747
+ this._totalCancelled++;
725
748
  this.drain();
726
749
  return;
727
750
  }
@@ -729,6 +752,8 @@ export class McpTaskRunner {
729
752
  void this.runTask(next);
730
753
  }
731
754
  async runTask(task) {
755
+ const startedAt = performance.now();
756
+ this._lastWaitMs = startedAt - task.enqueuedAt;
732
757
  try {
733
758
  const outcome = await this.executeTool(task.payload, task.controller.signal);
734
759
  if (!task.cancelled) {
@@ -741,6 +766,8 @@ export class McpTaskRunner {
741
766
  }
742
767
  }
743
768
  finally {
769
+ this._lastDurationMs = performance.now() - startedAt;
770
+ this._totalExecuted++;
744
771
  if (this.active === task) {
745
772
  this.active = undefined;
746
773
  }
@@ -1,6 +1,7 @@
1
1
  import { loadState, saveState } from '../core/state.js';
2
- import { memoryExists, memoryPath, withStoreLock, writeFileAtomic } from '../core/io.js';
3
- import { generateMarkdown } from '../core/markdown.js';
2
+ import { memoryExists } from '../core/io.js';
3
+ import { mutate } from '../core/mutation-pipeline.js';
4
+ import { rebuildProjectMd } from '../core/markdown.js';
4
5
  import { deleteRuntimeNote, listRuntimeNotes } from '../core/runtime.js';
5
6
  import { expireStaleActiveClaims } from '../core/claims.js';
6
7
  export function runPrune(options = {}) {
@@ -13,7 +14,7 @@ export function runPrune(options = {}) {
13
14
  let prunedCount = 0;
14
15
  let expiredClaimsCount = 0;
15
16
  let expiredNotesCount = 0;
16
- withStoreLock(cwd, () => {
17
+ mutate({ cwd }, () => {
17
18
  const state = loadState(cwd);
18
19
  const originalLength = state.active_constraints.length;
19
20
  for (const c of state.active_constraints) {
@@ -38,7 +39,7 @@ export function runPrune(options = {}) {
38
39
  }
39
40
  }
40
41
  }
41
- writeFileAtomic(memoryPath('project.md', cwd), generateMarkdown(loadState(cwd), cwd));
42
+ rebuildProjectMd(loadState(cwd), cwd);
42
43
  });
43
44
  if (options.expired) {
44
45
  console.log(`✔ Pruned ${prunedCount} expired constraints, ${expiredNotesCount} expired runtime notes, ${expiredClaimsCount} expired claims.`);
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import { memoryExists } from '../core/io.js';
3
+ import { rebuildProjectMd } from '../core/markdown.js';
3
4
  import { loadConfig } from '../core/config.js';
4
5
  import { buildOperationalIdentity } from '../core/identity.js';
5
6
  import { loadState, persistState } from '../core/state.js';
@@ -265,6 +266,7 @@ function promoteCandidateToState(candidate, cwd) {
265
266
  }
266
267
  }
267
268
  persistState(state, cwd);
269
+ rebuildProjectMd(loadState(cwd), cwd);
268
270
  return promotedItemId;
269
271
  }
270
272
  export function mapEventTypeToCandidateType(eventType) {
@@ -1,7 +1,7 @@
1
1
  import { memoryExists } from '../core/io.js';
2
- import { memoryPath, withStoreLock, writeFileAtomic } from '../core/io.js';
2
+ import { mutate } from '../core/mutation-pipeline.js';
3
3
  import { loadClaim, listClaims, releaseClaim } from '../core/claims.js';
4
- import { generateMarkdown } from '../core/markdown.js';
4
+ import { rebuildProjectMd } from '../core/markdown.js';
5
5
  import { loadState, saveState } from '../core/state.js';
6
6
  export function runReleaseClaim(id, options = {}) {
7
7
  if (!memoryExists(options.cwd)) {
@@ -10,7 +10,7 @@ export function runReleaseClaim(id, options = {}) {
10
10
  }
11
11
  try {
12
12
  let claim = loadClaim(id, options.cwd);
13
- withStoreLock(options.cwd, () => {
13
+ mutate({ cwd: options.cwd }, () => {
14
14
  const existing = loadClaim(id, options.cwd);
15
15
  claim = releaseClaim(id, options.cwd);
16
16
  let state = loadState(options.cwd);
@@ -31,7 +31,7 @@ export function runReleaseClaim(id, options = {}) {
31
31
  saveState(state, options.cwd);
32
32
  }
33
33
  }
34
- writeFileAtomic(memoryPath('project.md', options.cwd), generateMarkdown(state, options.cwd));
34
+ rebuildProjectMd(state, options.cwd);
35
35
  });
36
36
  console.log(`✔ Claim [${id}] released (was: ${claim.agent} → ${claim.scope})`);
37
37
  }
@@ -1,7 +1,8 @@
1
1
  import { execSync } from 'node:child_process';
2
- import { memoryExists, memoryPath, withStoreLock, writeFileAtomic } from '../core/io.js';
2
+ import { memoryExists } from '../core/io.js';
3
+ import { mutate } from '../core/mutation-pipeline.js';
3
4
  import { listClaims, releaseClaim } from '../core/claims.js';
4
- import { generateMarkdown } from '../core/markdown.js';
5
+ import { rebuildProjectMd } from '../core/markdown.js';
5
6
  import { loadState, saveState } from '../core/state.js';
6
7
  function normPath(p) {
7
8
  return p.replace(/\\/g, '/').replace(/^\.\//, '');
@@ -36,7 +37,7 @@ export function runReleaseClaims(options = {}) {
36
37
  if (toRelease.length === 0)
37
38
  process.exit(0);
38
39
  let released = 0;
39
- withStoreLock(options.cwd, () => {
40
+ mutate({ cwd: options.cwd }, () => {
40
41
  let state = loadState(options.cwd);
41
42
  for (const claim of toRelease) {
42
43
  try {
@@ -59,7 +60,7 @@ export function runReleaseClaims(options = {}) {
59
60
  catch { /* skip individual failures */ }
60
61
  }
61
62
  state = loadState(options.cwd);
62
- writeFileAtomic(memoryPath('project.md', options.cwd), generateMarkdown(state, options.cwd));
63
+ rebuildProjectMd(state, options.cwd);
63
64
  });
64
65
  if (released > 0) {
65
66
  console.log(`brainclaw: ${released} claim(s) auto-released after merge.`);
@@ -106,8 +106,8 @@ function listProjects(wsRoot, json) {
106
106
  });
107
107
  }
108
108
  }
109
- // Discover child projects
110
- const children = scanNestedBrainclawProjects(wsRoot, 4);
109
+ // Discover child projects (depth 7 covers deep workspace layouts like /srv/dev/repos/global/applications/*/...)
110
+ const children = scanNestedBrainclawProjects(wsRoot, 7);
111
111
  for (const child of children) {
112
112
  const childPath = path.resolve(child.path);
113
113
  if (childPath === wsRoot)
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { JsonStore } from './json-store.js';
3
- import { resolveEntityDir, withStoreLock } from './io.js';
3
+ import { resolveEntityDir } from './io.js';
4
+ import { mutate } from './mutation-pipeline.js';
4
5
  import { AiSurfaceTaskRequestSchema } from './schema.js';
5
6
  function surfaceTasksDir(cwd, mode = 'read') {
6
7
  return resolveEntityDir('surface-tasks', cwd ?? process.cwd(), mode);
@@ -20,7 +21,7 @@ export function ensureAiSurfaceTasksDir(cwd) {
20
21
  }
21
22
  }
22
23
  export function saveAiSurfaceTask(task, cwd) {
23
- withStoreLock(cwd, () => {
24
+ mutate({ cwd }, () => {
24
25
  ensureAiSurfaceTasksDir(cwd);
25
26
  const writeStore = new JsonStore({
26
27
  dirPath: surfaceTasksDir(cwd, 'write'),
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { memoryDir, withStoreLock } from './io.js';
3
+ import { memoryDir } from './io.js';
4
+ import { mutate } from './mutation-pipeline.js';
4
5
  import { nowISO } from './ids.js';
5
6
  import { logger } from './logger.js';
6
7
  import { appendEvent } from './event-log.js';
@@ -23,7 +24,7 @@ function auditLogPath(cwd) {
23
24
  }
24
25
  export function appendAuditEntry(entry, cwd) {
25
26
  try {
26
- withStoreLock(cwd, () => {
27
+ mutate({ cwd }, () => {
27
28
  const full = {
28
29
  timestamp: nowISO(),
29
30
  actor: entry.actor,
@@ -4,7 +4,8 @@ import path from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { JsonStore } from './json-store.js';
6
6
  import { generateId, generateIdWithLabel, nowISO } from './ids.js';
7
- import { memoryPath, resolveEntityDir, withStoreLock, writeFileAtomic } from './io.js';
7
+ import { resolveEntityDir } from './io.js';
8
+ import { mutate } from './mutation-pipeline.js';
8
9
  import { BootstrapApplicationReceiptSchema, BootstrapInterviewAnswerSchema, BootstrapInterviewPlanSchema, BootstrapInterviewQuestionSchema, BootstrapImportPlanDocumentSchema, BootstrapProfileDocumentSchema, BootstrapSuggestionDocumentSchema, MemorySeedDocumentSchema, } from './schema.js';
9
10
  import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
10
11
  import { analyzeRepository } from './repo-analysis.js';
@@ -13,7 +14,7 @@ import { buildAgentToolingContext } from './agent-context.js';
13
14
  import { createInstruction, loadInstructions, saveInstruction } from './instructions.js';
14
15
  import { resolveCurrentAgentName } from './agent-registry.js';
15
16
  import { loadState, persistState } from './state.js';
16
- import { generateMarkdown } from './markdown.js';
17
+ import { rebuildProjectMd } from './markdown.js';
17
18
  const README_CANDIDATES = ['README.md', 'README', 'README.txt', 'README.mdx'];
18
19
  const DOC_HINTS = ['docs', 'doc'];
19
20
  const MAKEFILE_NAME = 'Makefile';
@@ -1241,7 +1242,7 @@ export function applyBootstrapImport(options = {}) {
1241
1242
  const managedArtifacts = [];
1242
1243
  let createdCount = 0;
1243
1244
  let skippedCount = 0;
1244
- withStoreLock(cwd, () => {
1245
+ mutate({ cwd }, () => {
1245
1246
  const state = loadState(cwd);
1246
1247
  const activeInstructionKeys = new Set(loadInstructions(cwd)
1247
1248
  .filter((entry) => entry.active)
@@ -1380,7 +1381,7 @@ export function applyBootstrapImport(options = {}) {
1380
1381
  persistState(state, cwd, { writeProjectMarkdown: false });
1381
1382
  }
1382
1383
  if (createdCount > 0) {
1383
- writeFileAtomic(memoryPath('project.md', cwd), generateMarkdown(loadState(cwd), cwd));
1384
+ rebuildProjectMd(loadState(cwd), cwd);
1384
1385
  }
1385
1386
  });
1386
1387
  const receipt = BootstrapApplicationReceiptSchema.parse({
@@ -1414,7 +1415,7 @@ export function uninstallBootstrapImport(cwd) {
1414
1415
  let deactivatedCount = 0;
1415
1416
  let deletedCount = 0;
1416
1417
  let skippedCount = 0;
1417
- withStoreLock(resolvedCwd, () => {
1418
+ mutate({ cwd: resolvedCwd }, () => {
1418
1419
  const state = loadState(resolvedCwd);
1419
1420
  const instructions = loadInstructions(resolvedCwd);
1420
1421
  let stateChanged = false;
@@ -1459,7 +1460,7 @@ export function uninstallBootstrapImport(cwd) {
1459
1460
  persistState(state, resolvedCwd, { writeProjectMarkdown: false });
1460
1461
  }
1461
1462
  if (deactivatedCount > 0 || deletedCount > 0) {
1462
- writeFileAtomic(memoryPath('project.md', resolvedCwd), generateMarkdown(loadState(resolvedCwd), resolvedCwd));
1463
+ rebuildProjectMd(loadState(resolvedCwd), resolvedCwd);
1463
1464
  }
1464
1465
  });
1465
1466
  const nextReceipt = BootstrapApplicationReceiptSchema.parse({
@@ -2,7 +2,8 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { CandidateSchema } from './schema.js';
5
- import { resolveEntityDir, withStoreLock } from './io.js';
5
+ import { resolveEntityDir } from './io.js';
6
+ import { mutate } from './mutation-pipeline.js';
6
7
  import { nowISO, getNextShortLabel } from './ids.js';
7
8
  import { JsonStore } from './json-store.js';
8
9
  function inboxDir(cwd, mode = 'read') {
@@ -35,7 +36,7 @@ function candidateStore(dest = 'pending', cwd) {
35
36
  });
36
37
  }
37
38
  export function saveCandidate(candidate, cwd) {
38
- withStoreLock(cwd, () => {
39
+ mutate({ cwd }, () => {
39
40
  ensureInboxDirs(cwd);
40
41
  candidateStore('pending', cwd).save(CandidateSchema.parse(candidate));
41
42
  });
@@ -51,7 +52,7 @@ export function listCandidates(status, cwd) {
51
52
  return status ? candidates.filter((candidate) => candidate.status === status) : candidates;
52
53
  }
53
54
  export function archiveCandidate(candidate, dest, cwd) {
54
- withStoreLock(cwd, () => {
55
+ mutate({ cwd }, () => {
55
56
  ensureInboxDirs(cwd);
56
57
  candidateStore(dest, cwd).save(CandidateSchema.parse(candidate));
57
58
  candidateStore('pending', cwd).delete(candidate.id);
@@ -1,7 +1,8 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import { ClaimSchema } from './schema.js';
4
- import { resolveEntityDir, withStoreLock } from './io.js';
4
+ import { resolveEntityDir } from './io.js';
5
+ import { mutate } from './mutation-pipeline.js';
5
6
  import { nowISO } from './ids.js';
6
7
  import { JsonStore } from './json-store.js';
7
8
  function claimsDir(cwd, mode = 'read') {
@@ -22,7 +23,7 @@ function claimStore(cwd) {
22
23
  });
23
24
  }
24
25
  export function saveClaim(claim, cwd) {
25
- withStoreLock(cwd, () => {
26
+ mutate({ cwd }, () => {
26
27
  ensureClaimsDir(cwd);
27
28
  const writeStore = new JsonStore({
28
29
  dirPath: claimsDir(cwd, 'write'),
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
- import { resolveEntityDir, withStoreLock } from './io.js';
2
+ import { resolveEntityDir } from './io.js';
3
+ import { mutate } from './mutation-pipeline.js';
3
4
  import { generateId } from './ids.js';
4
5
  import { InstructionEntrySchema } from './schema.js';
5
6
  import { JsonStore } from './json-store.js';
@@ -19,7 +20,7 @@ export function loadInstructions(cwd) {
19
20
  return instructionStore(cwd).list();
20
21
  }
21
22
  export function saveInstruction(entry, cwd) {
22
- withStoreLock(cwd, () => {
23
+ mutate({ cwd }, () => {
23
24
  instructionStore(cwd, 'write').save(InstructionEntrySchema.parse(entry));
24
25
  });
25
26
  }
package/dist/core/io.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { withLock, cleanStaleLocks } from './lock.js';
4
+ export { mutate } from './mutation-pipeline.js';
4
5
  export const MEMORY_DIR = '.brainclaw';
5
6
  const STORE_LOCK_BASENAME = '.store-mutation';
6
7
  const RETRYABLE_RENAME_ERROR_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
package/dist/core/lock.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- const DEFAULT_TIMEOUT_MS = 2000;
3
+ const DEFAULT_TIMEOUT_MS = 5000;
4
4
  const LOCK_RETRY_INTERVAL_MS = 50;
5
- const LOCK_EXPIRY_MS = 5000;
5
+ const LOCK_EXPIRY_MS = 10000;
6
6
  const heldLocks = new Map();
7
7
  function lockFilePath(targetPath) {
8
8
  return targetPath + '.lock';
@@ -1,6 +1,8 @@
1
1
  import { listClaims } from './claims.js';
2
2
  import { loadInstructions } from './instructions.js';
3
+ import { memoryPath, writeFileAtomic } from './io.js';
3
4
  import { isTrapActive } from './traps.js';
5
+ import { logger } from './logger.js';
4
6
  export function generateMarkdown(state, cwd) {
5
7
  const lines = ['# Project Memory', ''];
6
8
  const instructions = loadInstructions(cwd).filter((entry) => entry.active);
@@ -117,4 +119,20 @@ export function generateMarkdown(state, cwd) {
117
119
  lines.push('');
118
120
  return lines.join('\n');
119
121
  }
122
+ /**
123
+ * Rebuild `.brainclaw/project.md` from canonical state.
124
+ *
125
+ * This is a **derived view** — it can always be regenerated from the
126
+ * canonical JSON files. Call this once at the end of a top-level mutation,
127
+ * not inside every nested helper. Best-effort: failures are logged but
128
+ * never propagate to the caller.
129
+ */
130
+ export function rebuildProjectMd(state, cwd) {
131
+ try {
132
+ writeFileAtomic(memoryPath('project.md', cwd), generateMarkdown(state, cwd));
133
+ }
134
+ catch (err) {
135
+ logger.debug('Failed to rebuild project.md:', err);
136
+ }
137
+ }
120
138
  //# sourceMappingURL=markdown.js.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Mutation pipeline — single entry point for all .brainclaw/ store mutations.
3
+ *
4
+ * Every write operation against the store MUST go through `mutate()`.
5
+ * This ensures:
6
+ * 1. Store-wide serialization via the advisory file lock
7
+ * 2. Consistent error handling and timeout behavior
8
+ * 3. Observable mutation metadata for debugging and auditing
9
+ *
10
+ * @module
11
+ */
12
+ import { ensureMemoryDir, storeLockPath } from './io.js';
13
+ import { withLock } from './lock.js';
14
+ import { logger } from './logger.js';
15
+ /** Default timeout for store-wide lock acquisition (ms). */
16
+ export const STORE_LOCK_TIMEOUT_MS = 5_000;
17
+ export function mutate(optionsOrFn, maybeFn) {
18
+ const options = typeof optionsOrFn === 'function' ? {} : optionsOrFn;
19
+ const fn = typeof optionsOrFn === 'function' ? optionsOrFn : maybeFn;
20
+ const cwd = options.cwd ?? process.cwd();
21
+ const timeoutMs = options.timeoutMs ?? STORE_LOCK_TIMEOUT_MS;
22
+ ensureMemoryDir(cwd, options.preferredDirName);
23
+ const lockTarget = storeLockPath(cwd, options.preferredDirName);
24
+ const start = performance.now();
25
+ try {
26
+ const value = withLock(lockTarget, () => fn(cwd), timeoutMs);
27
+ const durationMs = performance.now() - start;
28
+ if (durationMs > 1_000) {
29
+ logger.debug(`Slow mutation: ${durationMs.toFixed(0)}ms (cwd=${cwd})`);
30
+ }
31
+ return value;
32
+ }
33
+ catch (err) {
34
+ const durationMs = performance.now() - start;
35
+ logger.debug(`Mutation failed after ${durationMs.toFixed(0)}ms: ${err instanceof Error ? err.message : String(err)}`);
36
+ throw err;
37
+ }
38
+ }
39
+ //# sourceMappingURL=mutation-pipeline.js.map
@@ -2,7 +2,8 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { resolveCurrentHostId, sanitizeHostId } from './host.js';
5
- import { resolveEntityDir, withStoreLock } from './io.js';
5
+ import { resolveEntityDir } from './io.js';
6
+ import { mutate } from './mutation-pipeline.js';
6
7
  import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
7
8
  import { RuntimeNoteSchema } from './schema.js';
8
9
  import { commitMemoryChange } from './memory-git.js';
@@ -40,7 +41,7 @@ export function saveRuntimeNote(note, cwd) {
40
41
  const persistedNote = visibility === 'shared'
41
42
  ? { ...note, visibility, host_id: hostId }
42
43
  : { ...note, visibility, host_id: hostId };
43
- withStoreLock(cwd, () => {
44
+ mutate({ cwd }, () => {
44
45
  ensureRuntimeDir(note.agent, cwd, visibility, hostId);
45
46
  const filepath = visibility === 'shared'
46
47
  ? path.join(sharedAgentDir(note.agent, cwd, 'write'), `${note.id}.json`)
@@ -58,7 +59,7 @@ export function runtimeNotePath(note, cwd) {
58
59
  : path.join(hostAgentDir(visibility, hostId, note.agent, cwd), `${note.id}.json`);
59
60
  }
60
61
  export function deleteRuntimeNote(note, cwd) {
61
- return withStoreLock(cwd, () => {
62
+ return mutate({ cwd }, () => {
62
63
  const filepath = runtimeNotePath(note, cwd);
63
64
  if (!fs.existsSync(filepath)) {
64
65
  return false;
@@ -1,11 +1,12 @@
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, memoryPath, resolveEntityDir, withStoreLock, writeFileAtomic } from './io.js';
4
+ import { ensureMemoryDir, resolveEntityDir } from './io.js';
5
+ import { mutate } from './mutation-pipeline.js';
5
6
  import { commitMemoryChange } from './memory-git.js';
6
7
  import { appendEvent } from './event-log.js';
7
8
  import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
8
- import { generateMarkdown } from './markdown.js';
9
+ import { rebuildProjectMd } from './markdown.js';
9
10
  export function emptyState() {
10
11
  return {
11
12
  version: 1,
@@ -83,10 +84,10 @@ function writeStateDirectories(state, cwd) {
83
84
  }
84
85
  export function persistState(state, cwd, options = {}) {
85
86
  const effectiveCwd = cwd ?? process.cwd();
86
- withStoreLock(effectiveCwd, () => {
87
+ mutate({ cwd: effectiveCwd }, () => {
87
88
  writeStateDirectories(state, effectiveCwd);
88
89
  if (options.writeProjectMarkdown ?? true) {
89
- writeFileAtomic(memoryPath('project.md', effectiveCwd), generateMarkdown(state, effectiveCwd));
90
+ rebuildProjectMd(state, effectiveCwd);
90
91
  }
91
92
  appendEvent({
92
93
  action: options.eventAction ?? 'update',
@@ -257,10 +257,29 @@ Likely persisted fields:
257
257
 
258
258
  This should stay clearly separate from canonical memory items such as decisions and constraints.
259
259
 
260
+ ## Current Implementation (v0.23.0)
261
+
262
+ `brainclaw switch` provides the first layer of project navigation:
263
+
264
+ - `brainclaw switch <name-or-path>` — set active project by name or relative path
265
+ - `brainclaw switch --list` — discover available projects in workspace
266
+ - `resolveProjectRef()` — shared resolver for CLI and MCP (name, path, registry lookup)
267
+ - `resolveEffectiveCwd()` — single source of truth: `--cwd` > `BRAINCLAW_PROJECT` env > active-project > cwd
268
+ - MCP tools automatically resolve the active project
269
+
270
+ This covers the most common agent friction (cwd-dependent resolution) without requiring the full `project_ref` model yet.
271
+
260
272
  ## Migration Strategy
261
273
 
262
274
  The migration should be low-risk and incremental.
263
275
 
276
+ Phase 0 (done):
277
+
278
+ - `brainclaw switch` with name/path resolution
279
+ - global `--cwd` option
280
+ - `BRAINCLAW_PROJECT` environment variable
281
+ - `resolveEffectiveCwd()` used by CLI and MCP
282
+
264
283
  Phase 1:
265
284
 
266
285
  - introduce `project_ref` in workspace discovery/registry
package/docs/cli.md CHANGED
@@ -11,6 +11,53 @@ For capable coding agents, prefer MCP for dynamic runtime state:
11
11
 
12
12
  Use the CLI when a human operator is driving the workflow, when you are scripting setup or release operations, or when MCP is not the integration path.
13
13
 
14
+ ## Global Options
15
+
16
+ All commands support these global options:
17
+
18
+ | Option | Description |
19
+ |---|---|
20
+ | `--cwd <path>` | Override working directory for this invocation. Bypasses active project and env var resolution. |
21
+ | `--verbose` | Show info-level log messages on stderr |
22
+ | `--debug` | Show debug-level log messages on stderr |
23
+
24
+ **Effective cwd resolution priority** (highest wins):
25
+
26
+ 1. `--cwd` flag
27
+ 2. `BRAINCLAW_PROJECT` environment variable (project name or path)
28
+ 3. Active project set via `brainclaw switch`
29
+ 4. `process.cwd()` (shell working directory)
30
+
31
+ ---
32
+
33
+ ## Multi-Project Navigation
34
+
35
+ ### `brainclaw switch [project]`
36
+
37
+ Set the active project for subsequent CLI and MCP commands. This eliminates the need to `cd` into a subproject directory in multi-project workspaces. The active project is persisted per-workspace in `.brainclaw/active-project.json`.
38
+
39
+ | Option | Description |
40
+ |---|---|
41
+ | `--list` | List all available projects in the workspace |
42
+ | `--clear` | Clear the active project (revert to cwd default) |
43
+ | `--json` | Output as JSON |
44
+
45
+ The `<project>` argument accepts:
46
+ - **Project name** — matched against the global registry and workspace config
47
+ - **Relative path** — resolved from the workspace root (e.g. `apps/lodestar`)
48
+ - **Absolute path** — used directly
49
+
50
+ ```bash
51
+ brainclaw switch --list # discover available projects
52
+ brainclaw switch lodestar # switch by project name
53
+ brainclaw switch apps/lodestar # switch by relative path
54
+ brainclaw switch # show current active project
55
+ brainclaw switch --clear # clear, revert to cwd
56
+ brainclaw --cwd /other/path status # one-off override without switching
57
+ ```
58
+
59
+ **MCP usage:** The active project also affects MCP tools. When `bclaw_get_context()` is called without an explicit path, it resolves context from the active project's store. Agents can also use `BRAINCLAW_PROJECT=<name>` environment variable for the same effect.
60
+
14
61
  ---
15
62
 
16
63
  ## Initialize and Inspect
@@ -32,7 +32,7 @@ Examples: constraints, decisions, traps, completed plans, handoffs.
32
32
 
33
33
  Shared traps now have a lifecycle too: `active`, `resolved`, or `expired`. Active views such as generated context, status, and `project.md` prioritize only active traps so old machine-setup issues stop polluting the current working set, while the canonical memory still keeps resolved traps for audit and search.
34
34
 
35
- These live in `.brainclaw/store.json` and are shared via Git (or by reading the same file).
35
+ These live as individual JSON files under `.brainclaw/memory/` (constraints, decisions, traps, instructions) and `.brainclaw/coordination/` (plans, claims, handoffs). They are versioned in `.brainclaw/.git` and shared via Git.
36
36
 
37
37
  ### Runtime memory
38
38
  Operational observations that may be short-lived, host-specific, or private.
@@ -59,8 +59,9 @@ brainclaw makes this context visible and versionable.
59
59
 
60
60
  brainclaw keeps:
61
61
 
62
- - canonical structured JSON as the source of truth (`.brainclaw/store.json`)
63
- - a generated readable view in each agent's native format (`CLAUDE.md`, `.cursor/rules/brainclaw.md`, etc.)
62
+ - canonical structured JSON as the source of truth (individual files under `.brainclaw/memory/` and `.brainclaw/coordination/`)
63
+ - a derived readable view (`project.md`) regenerated best-effort from canonical state
64
+ - native agent instruction files (`CLAUDE.md`, `.cursor/rules/brainclaw.md`, etc.) generated via `brainclaw export`
64
65
 
65
66
  This balances machine reliability with human readability.
66
67
 
@@ -38,3 +38,44 @@ If shared memory is absent, that should not always be interpreted as "there is n
38
38
  It may simply mean the workspace has not been onboarded yet.
39
39
 
40
40
  This lets a single machine support multiple very different workspaces without forcing one static instruction layer to fit all of them equally well.
41
+
42
+ ## Multi-project workspaces
43
+
44
+ A workspace may contain multiple brainclaw-initialized child projects (each with its own `.brainclaw/` store). In this topology:
45
+
46
+ - The workspace root holds shared instructions, constraints, and coordination state
47
+ - Each child project holds project-specific memory (decisions, traps, plans)
48
+ - The store chain walks upward: child → repo → workspace → user
49
+
50
+ ### Working with child projects
51
+
52
+ Agents and operators can address child projects without `cd`:
53
+
54
+ ```bash
55
+ brainclaw switch apps/lodestar # set active project
56
+ brainclaw plan list # now targets lodestar's store
57
+ brainclaw switch --clear # back to workspace root
58
+ ```
59
+
60
+ Or use environment variables:
61
+
62
+ ```bash
63
+ export BRAINCLAW_PROJECT=lodestar
64
+ brainclaw context # resolves lodestar's store
65
+ ```
66
+
67
+ Or one-off overrides:
68
+
69
+ ```bash
70
+ brainclaw --cwd apps/lodestar plan list
71
+ ```
72
+
73
+ ### Project discovery
74
+
75
+ `brainclaw switch --list` discovers child projects via:
76
+
77
+ 1. Global project registry
78
+ 2. Workspace config `projects.known`
79
+ 3. Filesystem scan for subdirectories containing `.brainclaw/`
80
+
81
+ The bootstrap analysis (`analyzeRepository`) also detects brainclaw-native workspace complexity (child stores, folder strategy, known projects) alongside classic monorepo markers.
@@ -101,6 +101,41 @@ brainclaw adds all generated files to `.gitignore` automatically during init.
101
101
 
102
102
  Exception: use `--shared` if you intentionally want the main instruction file (e.g., CLAUDE.md) versioned for the whole team.
103
103
 
104
+ ## Multi-project workspaces
105
+
106
+ When a workspace contains multiple brainclaw-initialized child projects, agents need to target the correct project store. There are three mechanisms (from most to least ergonomic):
107
+
108
+ ### 1. `brainclaw switch` (persistent)
109
+
110
+ An operator or agent sets the active project once, and all subsequent commands resolve against it:
111
+
112
+ ```bash
113
+ brainclaw switch apps/lodestar # set active project
114
+ brainclaw plan list # targets lodestar
115
+ bclaw_get_context() # MCP also targets lodestar
116
+ brainclaw switch --clear # back to workspace root
117
+ ```
118
+
119
+ ### 2. `BRAINCLAW_PROJECT` environment variable
120
+
121
+ Set in the shell or agent configuration. Useful for CI/CD or when the agent can control its environment:
122
+
123
+ ```bash
124
+ export BRAINCLAW_PROJECT=lodestar
125
+ ```
126
+
127
+ ### 3. `--cwd` flag (one-off override)
128
+
129
+ For a single command without changing the active project:
130
+
131
+ ```bash
132
+ brainclaw --cwd apps/lodestar plan list
133
+ ```
134
+
135
+ **Priority**: `--cwd` > `BRAINCLAW_PROJECT` > active project > shell cwd.
136
+
137
+ **Discovery**: use `brainclaw switch --list` to see all available projects in the workspace.
138
+
104
139
  ## Session lifecycle
105
140
 
106
141
  ### Starting work
@@ -14,7 +14,7 @@ brainclaw export --format claude-md --write
14
14
 
15
15
  - use MCP as the default runtime path for dynamic retrieval and writes
16
16
  - keep `CLAUDE.md` lightweight and behavioral: it should tell Claude Code when to call Brainclaw, not carry all mutable workspace state
17
- - use `.brainclaw/project.md` as a readable fallback baseline, not as the primary live source of truth
17
+ - use `.brainclaw/project.md` as a readable fallback (it is a derived view, regenerated best-effort run `brainclaw rebuild` if stale)
18
18
  - use hooks or workflow checks when a stronger reminder is needed
19
19
 
20
20
  ## Key idea
@@ -14,7 +14,7 @@ brainclaw export --format agents-md --write
14
14
 
15
15
  - use MCP as the default runtime path for fresh context, plans, claims, and runtime notes
16
16
  - keep `AGENTS.md` lightweight and behavioral: it should remind Codex how to use Brainclaw, not duplicate live state
17
- - use `.brainclaw/project.md` only as a readable fallback when MCP is unavailable or when a human wants a simple snapshot
17
+ - use `.brainclaw/project.md` only as a readable fallback (derived view, regenerated best-effort run `brainclaw rebuild` if stale)
18
18
  - encourage plans, claims, and handoffs during multi-step work
19
19
 
20
20
  ## Good role for brainclaw here
@@ -14,7 +14,7 @@ brainclaw export --format copilot-instructions --write
14
14
 
15
15
  - use MCP whenever the Copilot surface supports it for fresh context and coordination views
16
16
  - keep `.github/copilot-instructions.md` lightweight and behavioral
17
- - use `.brainclaw/project.md` as readable fallback, not as the only live context source
17
+ - use `.brainclaw/project.md` as readable fallback (derived view, regenerated best-effort may need `brainclaw rebuild` if stale)
18
18
  - use plans, claims, and handoffs to reduce ambiguity across sessions
19
19
 
20
20
  ## Why this matters
@@ -14,7 +14,7 @@ brainclaw export --format cursor-rules --write
14
14
 
15
15
  - use MCP as the default dynamic path for context, board state, plans, and claims
16
16
  - let the generated `.cursor/rules/brainclaw.md` tell Cursor when to consult Brainclaw and how to stay inside the workflow
17
- - use `.brainclaw/project.md` only as readable fallback shared state
17
+ - use `.brainclaw/project.md` only as readable fallback (derived view, may be stale — run `brainclaw rebuild` to refresh)
18
18
  - rely on claims and plans when multiple agents or humans are active in the same repo
19
19
 
20
20
  ## Key point
@@ -58,7 +58,7 @@ This keeps session continuity inside Brainclaw instead of pushing the agent back
58
58
  | Runtime writes with session continuity | MCP |
59
59
  | Local behavioral reminders inside the agent UI | native agent files |
60
60
  | Human inspection or scripting | CLI |
61
- | Simple readable fallback | `.brainclaw/project.md` |
61
+ | Simple readable fallback | `.brainclaw/project.md` (derived view, may be stale) |
62
62
 
63
63
  ## Starting The Server
64
64
 
@@ -87,16 +87,26 @@ Interview answers are keyed by question ID and may contain:
87
87
  - `response_boolean`
88
88
  - optional explicit `suggestions` when the agent wants to confirm exact canonical memory items
89
89
 
90
+ ## Mutation Safety
91
+
92
+ The MCP server serializes all mutations through a single-writer queue (`McpTaskRunner`). When an agent calls a write tool (e.g. `bclaw_claim`, `bclaw_write_note`, `bclaw_create_plan`), the request is enqueued and executed one at a time. This guarantees:
93
+
94
+ - no concurrent writes from the same MCP connection
95
+ - no partial state from interleaved mutations
96
+ - deterministic ordering of operations
97
+
98
+ A secondary file-based lock (`mutate()`) provides cross-process safety in case CLI commands run alongside MCP. But for agents, MCP is the safe path by design — no extra precautions needed.
99
+
90
100
  ## Important Rule
91
101
 
92
- If the agent has MCP available, do not treat the CLI as the primary runtime interface.
102
+ If the agent has MCP available, do not treat the CLI as the primary runtime interface. All agent mutations MUST go through MCP tools.
93
103
 
94
104
  The CLI remains valuable for:
95
105
 
96
- - setup
106
+ - setup and initialization
97
107
  - bootstrap by a human operator
98
- - scripting
108
+ - scripting and automation
99
109
  - release and packaging
100
110
  - debugging and fallback access
101
111
 
102
- But for capable agents, MCP should be the first-class path for dynamic state.
112
+ But for capable agents, MCP is the first-class path for both reads and writes.
@@ -87,9 +87,9 @@ The developer can dial back individual surfaces if needed, but the default is fu
87
87
 
88
88
  ## Sequential collaboration, not parallel editing
89
89
 
90
- For now, brainclaw works best when one agent works at a time in a given checkout. The next agent can pick up where the previous one stopped, using shared plans, claims, handoffs, and memory.
90
+ brainclaw's store mutations are serialized (MCP single-writer queue + file-based lock), so memory writes are safe even under contention. However, running multiple agents in parallel on the same checkout can still cause Git conflicts and confusing file-level state.
91
91
 
92
- Running multiple agents in parallel on the same checkout will create conflicts. Git worktree isolation per agent is planned but not yet available.
92
+ For now, brainclaw works best when one agent works at a time in a given checkout. The next agent can pick up where the previous one stopped, using shared plans, claims, handoffs, and memory. Git worktree isolation per agent is planned but not yet available.
93
93
 
94
94
  ## Next reads
95
95
 
@@ -74,7 +74,9 @@ This keeps non-code work visible to the project without overloading the active c
74
74
 
75
75
  ## Important: one agent at a time
76
76
 
77
- For now, use brainclaw for sequential collaboration. One agent works, finishes, and the next one picks up from shared context. Running multiple agents in parallel on the same checkout will cause conflicts.
77
+ brainclaw serializes all store mutations (file lock + MCP single-writer queue), so writes are safe. But running multiple agents in parallel on the same checkout can still cause Git conflicts and confusing state transitions.
78
+
79
+ Use brainclaw for sequential collaboration: one agent works, finishes, and the next one picks up from shared context. Use `bclaw_session_end` to hand off cleanly.
78
80
 
79
81
  ## Next reads
80
82
 
package/docs/storage.md CHANGED
@@ -6,37 +6,62 @@ brainclaw is local-first and workspace-centric.
6
6
 
7
7
  ```text
8
8
  .brainclaw/
9
- project.md Human & agent readable view (auto-generated)
10
- config.yaml Project configuration
11
- instructions/ Layered shared instructions
12
- plans/ Shared plan items
13
- constraints/ ← Canonical constraint entries
14
- decisions/ ← Canonical decision entries
15
- traps/ ← Canonical trap entries
16
- handoffs/ ← Canonical handoff entries
9
+ config.yaml Project configuration
10
+ project.md Derived readable view (best-effort, regenerable)
11
+ events.jsonl Append-only event log (agent notifications)
12
+ audit.log Append-only audit trail (before/after snapshots)
13
+ memory/
14
+ constraints/ ← Canonical constraint entries (one JSON per entity)
15
+ decisions/ ← Canonical decision entries
16
+ traps/ ← Canonical trap entries
17
+ instructions/ ← Layered shared instructions
18
+ coordination/
19
+ plans/ ← Shared plan items
20
+ claims/ ← Active scope claims
21
+ handoffs/ ← Handoff records
22
+ sessions/ ← Session state
23
+ runtime/ ← Runtime notes (shared, machine, private)
24
+ inbox/ ← Candidate review queue
25
+ discovery/
26
+ bootstrap/ ← Bootstrap profiles and seeds
27
+ agents/ ← Agent registration and identity
17
28
  ```
18
29
 
19
30
  ## Design principles
20
31
 
21
32
  ### Canonical state is split
22
- Each entity is stored as its own JSON file.
33
+ Each entity is stored as its own JSON file (e.g. `memory/decisions/dec_abc123.json`).
23
34
 
24
35
  Benefits:
25
36
 
26
- - readable diffs
37
+ - readable diffs in `.brainclaw/.git`
27
38
  - easier merges
28
- - clear provenance
29
- - straightforward automation
39
+ - clear provenance per entity
40
+ - O(1) lookup by ID (direct file access)
30
41
  - no giant monolithic memory blob
42
+ - isolated corruption (one bad file does not affect others)
31
43
 
32
- ### Human-readable view is generated
33
- `project.md` is regenerated from canonical state on every write.
44
+ ### Derived views are best-effort
45
+ `project.md` is a **derived view** regenerated from canonical state via `rebuildProjectMd()`. It is not a write target — it can always be rebuilt from the JSON files using `brainclaw rebuild`.
34
46
 
35
- Benefits:
47
+ Failures to regenerate `project.md` are logged but never block mutations. If it gets stale, `brainclaw doctor` detects the drift.
48
+
49
+ ### All mutations go through a single pipeline
50
+ Every write to `.brainclaw/` is serialized through `mutate()` (`src/core/mutation-pipeline.ts`):
51
+
52
+ 1. **Store-wide lock** — advisory file lock at `.brainclaw/.store-mutation` (5s timeout, 10s expiry)
53
+ 2. **Reentrant** — nested mutations within the same process are safe
54
+ 3. **Atomic writes** — individual files use temp-file + rename pattern
55
+ 4. **Git versioned** — `.brainclaw/.git` tracks all changes for rollback
56
+
57
+ For agents using MCP (the recommended path), mutations are additionally serialized by the MCP server's `McpTaskRunner` — a FIFO queue that runs one task at a time. This provides two layers of write safety:
58
+
59
+ | Layer | Scope | Mechanism |
60
+ |---|---|---|
61
+ | **McpTaskRunner** | Single MCP server process | FIFO queue, one active Worker thread |
62
+ | **mutate() file lock** | Cross-process (CLI + MCP) | Advisory `.store-mutation` lock file |
36
63
 
37
- - agents can read a simple file
38
- - humans get an inspectable summary
39
- - the source of truth remains structured
64
+ Agents should always use MCP tools for mutations. The CLI is for human operators, scripting, and fallback workflows.
40
65
 
41
66
  ### Topology can vary
42
67
 
@@ -57,21 +82,23 @@ Depending on configuration, storage may be:
57
82
  - handoffs
58
83
  - plans
59
84
 
60
- ## What may stay more operational
85
+ ## What stays operational
61
86
 
62
87
  - machine-local runtime notes
63
88
  - private notes
64
- - short-lived observations
89
+ - short-lived observations (with optional TTL)
65
90
  - reflective candidates awaiting review
91
+ - event log and audit trail
66
92
 
67
93
  ## Why this model matters
68
94
 
69
95
  The storage model is part of the product value:
70
96
 
71
- - local-first
72
- - inspectable
73
- - Git-friendly
74
- - reversible
97
+ - local-first — no cloud dependency
98
+ - inspectable — plain text + JSON
99
+ - Git-friendly — entity-per-file, readable diffs
100
+ - reversible — `.brainclaw/.git` history + rollback
101
+ - mutation-safe — serialized writes, atomic file operations
75
102
  - suitable for both humans and agents
76
103
 
77
104
  ## Related pages
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainclaw",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "bin": {