brainclaw 0.23.1 → 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.
@@ -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.`);
@@ -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',
@@ -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
 
@@ -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.1",
3
+ "version": "0.24.0",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "bin": {