brainclaw 0.23.1 → 0.25.3

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 (39) hide show
  1. package/dist/cli.js +1 -0
  2. package/dist/commands/accept.js +5 -2
  3. package/dist/commands/claim.js +7 -5
  4. package/dist/commands/context-diff.js +62 -4
  5. package/dist/commands/export.js +40 -2
  6. package/dist/commands/init.js +41 -0
  7. package/dist/commands/instruction.js +5 -4
  8. package/dist/commands/mcp.js +64 -5
  9. package/dist/commands/prune.js +5 -4
  10. package/dist/commands/reflect.js +2 -0
  11. package/dist/commands/release-claim.js +4 -4
  12. package/dist/commands/release-claims.js +5 -4
  13. package/dist/commands/runtime-note.js +1 -0
  14. package/dist/core/ai-surface-tasks.js +3 -2
  15. package/dist/core/audit.js +3 -2
  16. package/dist/core/bootstrap.js +7 -6
  17. package/dist/core/candidates.js +4 -3
  18. package/dist/core/claims.js +3 -2
  19. package/dist/core/instructions.js +3 -2
  20. package/dist/core/io.js +1 -0
  21. package/dist/core/lock.js +2 -2
  22. package/dist/core/markdown.js +18 -0
  23. package/dist/core/mutation-pipeline.js +39 -0
  24. package/dist/core/runtime.js +4 -3
  25. package/dist/core/schema.js +8 -0
  26. package/dist/core/state.js +5 -4
  27. package/docs/cli.md +7 -5
  28. package/docs/concepts/memory.md +4 -3
  29. package/docs/concepts/plans-and-claims.md +10 -0
  30. package/docs/integrations/agents.md +2 -1
  31. package/docs/integrations/claude-code.md +1 -1
  32. package/docs/integrations/codex.md +1 -1
  33. package/docs/integrations/copilot.md +1 -1
  34. package/docs/integrations/cursor.md +1 -1
  35. package/docs/integrations/mcp.md +16 -6
  36. package/docs/integrations/overview.md +2 -2
  37. package/docs/quickstart.md +3 -1
  38. package/docs/storage.md +51 -24
  39. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -963,6 +963,7 @@ program
963
963
  .description('Export memory as instructions for IDE/AI tools')
964
964
  .option('--format <format>', 'Format: copilot-instructions, cursor-rules, agents-md, claude-md, gemini-md, windsurf, cline, roo, continue')
965
965
  .option('--detect', 'Auto-detect agent environment and write to its native file')
966
+ .option('--all', 'Write all known agent instruction files at once (claude-md, agents-md, copilot-instructions, cursor-rules, etc.)')
966
967
  .option('--write', 'Write to canonical file path instead of stdout (when --format is given); local files are gitignored by default')
967
968
  .option('--shared', 'Keep the main exported instruction file versionable instead of auto-ignoring it (companions remain local)')
968
969
  .option('--output <file>', 'Write to a specific file path instead of stdout')
@@ -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,10 +1,11 @@
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
- import { requireMinimumTrustLevel, requireRegisteredAgentIdentity } from '../core/agent-registry.js';
8
+ import { requireMinimumTrustLevel, requireRegisteredAgentIdentity, resolveCurrentModel } from '../core/agent-registry.js';
8
9
  import { validateCliTtl } from '../core/input-validation.js';
9
10
  import { resolveTargetStore } from '../core/store-resolution.js';
10
11
  function parseTtl(ttl) {
@@ -71,8 +72,9 @@ export function runClaim(description, options) {
71
72
  plan_id: options.plan,
72
73
  status: 'active',
73
74
  expires_at: options.ttl ? parseTtl(options.ttl) : undefined,
75
+ model: resolveCurrentModel(options.cwd),
74
76
  };
75
- withStoreLock(options.cwd, () => {
77
+ mutate({ cwd: options.cwd }, () => {
76
78
  if (plan) {
77
79
  if (!plan.assignee) {
78
80
  plan.assignee = actor.agent;
@@ -84,7 +86,7 @@ export function runClaim(description, options) {
84
86
  saveState(state, options.cwd);
85
87
  }
86
88
  saveClaim(claim, options.cwd);
87
- writeFileAtomic(memoryPath('project.md', options.cwd), generateMarkdown(plan ? state : loadState(options.cwd), options.cwd));
89
+ rebuildProjectMd(plan ? state : loadState(options.cwd), options.cwd);
88
90
  });
89
91
  const planInfo = claim.plan_id ? ` [plan ${claim.plan_id}]` : '';
90
92
  const ttlInfo = claim.expires_at ? ` (expires ${claim.expires_at.slice(0, 16).replace('T', ' ')})` : '';
@@ -1,5 +1,14 @@
1
1
  import { buildContextDiff, resolveContextDiffSince } from '../core/context-diff.js';
2
+ import { listClaims, isClaimExpired } from '../core/claims.js';
3
+ import { loadInstructions, resolveInstructions } from '../core/instructions.js';
2
4
  import { memoryExists } from '../core/io.js';
5
+ import { loadState } from '../core/state.js';
6
+ import { isTrapActive } from '../core/traps.js';
7
+ /**
8
+ * Hybrid context-diff: always includes critical anchors (active claims,
9
+ * top traps, instructions) so the agent stays grounded, plus the memory
10
+ * delta since last context read.
11
+ */
3
12
  export function runContextDiff(options = {}) {
4
13
  if (!memoryExists(options.cwd)) {
5
14
  console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
@@ -20,13 +29,62 @@ export function runContextDiff(options = {}) {
20
29
  process.exit(1);
21
30
  }
22
31
  if (options.json) {
23
- console.log(JSON.stringify(diff, null, 2));
32
+ const anchors = buildCriticalAnchors(options.cwd);
33
+ console.log(JSON.stringify({ ...diff, anchors }, null, 2));
24
34
  return;
25
35
  }
36
+ const lines = [];
37
+ // --- Critical anchors (always present) ---
38
+ const anchors = buildCriticalAnchors(options.cwd);
39
+ if (anchors.claims.length > 0) {
40
+ lines.push('Active claims:');
41
+ for (const c of anchors.claims) {
42
+ lines.push(`- [${c.id}] ${c.agent} → ${c.scope}: ${c.description}`);
43
+ }
44
+ lines.push('');
45
+ }
46
+ if (anchors.instructions.length > 0) {
47
+ lines.push('Instructions:');
48
+ for (const ins of anchors.instructions) {
49
+ const scope = ins.scope ? `:${ins.scope}` : '';
50
+ lines.push(`- [${ins.id}] <${ins.layer}${scope}> ${ins.text}`);
51
+ }
52
+ lines.push('');
53
+ }
54
+ if (anchors.traps.length > 0) {
55
+ lines.push('Active traps:');
56
+ for (const t of anchors.traps) {
57
+ lines.push(`- [${t.id}] (${t.severity}) ${t.text}`);
58
+ }
59
+ lines.push('');
60
+ }
61
+ // --- Memory delta ---
26
62
  if (diff.counts.total === 0) {
27
- console.log(`${diff.summary} since ${diff.since}.`);
28
- return;
63
+ lines.push(`Memory: no changes since ${diff.since?.slice(0, 16).replace('T', ' ')}.`);
29
64
  }
30
- console.log(`${diff.summary} since ${diff.since}.`);
65
+ else {
66
+ lines.push(`Memory delta (${diff.summary}):`);
67
+ for (const item of diff.changed_items ?? []) {
68
+ lines.push(`- [${item.section}] [${item.id}] ${item.text}`);
69
+ }
70
+ }
71
+ console.log(lines.join('\n'));
72
+ }
73
+ function buildCriticalAnchors(cwd) {
74
+ const activeClaims = listClaims(cwd)
75
+ .filter((c) => c.status === 'active' && !isClaimExpired(c))
76
+ .map((c) => ({ id: c.id, agent: c.agent, scope: c.scope, description: c.description }));
77
+ const instructions = resolveInstructions(loadInstructions(cwd))
78
+ .map((ins) => ({ id: ins.id, layer: ins.layer, scope: ins.scope, text: ins.text }));
79
+ const state = loadState(cwd);
80
+ const traps = state.known_traps
81
+ .filter((t) => isTrapActive(t))
82
+ .sort((a, b) => {
83
+ const severityOrder = { high: 0, medium: 1, low: 2 };
84
+ return (severityOrder[a.severity] ?? 1) - (severityOrder[b.severity] ?? 1);
85
+ })
86
+ .slice(0, 5)
87
+ .map((t) => ({ id: t.id, severity: t.severity, text: t.text }));
88
+ return { claims: activeClaims, instructions, traps };
31
89
  }
32
90
  //# sourceMappingURL=context-diff.js.map
@@ -6,7 +6,7 @@ import { loadConfig, saveConfig } from '../core/config.js';
6
6
  import { isAgentIntegrationName, upsertAgentIntegrationDeclaration } from '../core/agent-integrations.js';
7
7
  import { resolveInstructions, loadInstructions } from '../core/instructions.js';
8
8
  import { detectAiAgent } from '../core/ai-agent-detection.js';
9
- import { resolveExportTarget, resolveExportTargetByFormat, writeExportFile, buildHygieneSection, describeAutoConfigWrite, writeExportCompanionFiles, collectExportGitignoreEntries, ensureGitignoreEntries, } from '../core/agent-files.js';
9
+ import { AGENT_EXPORT_REGISTRY, resolveExportTarget, resolveExportTargetByFormat, writeExportFile, buildHygieneSection, describeAutoConfigWrite, writeExportCompanionFiles, collectExportGitignoreEntries, ensureGitignoreEntries, } from '../core/agent-files.js';
10
10
  import { logger } from '../core/logger.js';
11
11
  import { getAgentCapabilityProfile } from '../core/agent-capability.js';
12
12
  import { renderBrainclawSection } from '../core/instruction-templates.js';
@@ -17,6 +17,10 @@ export function runExport(options) {
17
17
  console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
18
18
  process.exit(1);
19
19
  }
20
+ if (options.all) {
21
+ runExportAll(cwd, options);
22
+ return;
23
+ }
20
24
  if (options.detect) {
21
25
  if (options.shared) {
22
26
  console.error('Error: --shared cannot be used with --detect. Use `brainclaw export --format <format> --write --shared` to publish a specific instruction file.');
@@ -26,7 +30,7 @@ export function runExport(options) {
26
30
  return;
27
31
  }
28
32
  if (!options.format) {
29
- console.error('Error: --format or --detect is required.');
33
+ console.error('Error: --format, --detect, or --all is required.');
30
34
  process.exit(1);
31
35
  }
32
36
  const content = generateExport(options.format, options, cwd);
@@ -90,6 +94,40 @@ function runExportDetect(cwd, options) {
90
94
  }
91
95
  }
92
96
  }
97
+ function runExportAll(cwd, options) {
98
+ // Deduplicate by format (e.g. codex and opencode both use agents-md)
99
+ const seen = new Set();
100
+ const targets = AGENT_EXPORT_REGISTRY.filter((t) => {
101
+ if (seen.has(t.format))
102
+ return false;
103
+ seen.add(t.format);
104
+ return true;
105
+ });
106
+ let written = 0;
107
+ const allGitignoreEntries = [];
108
+ for (const target of targets) {
109
+ try {
110
+ const content = generateExport(target.format, options, cwd);
111
+ const result = writeExportFile(content, target.relativePath, cwd);
112
+ const autoConfigs = writeExportCompanionFiles(target.format, cwd);
113
+ const gitignoreEntries = collectExportGitignoreEntries(cwd, target.relativePath, autoConfigs);
114
+ allGitignoreEntries.push(...gitignoreEntries);
115
+ declareAgentIntegrationFromTarget(cwd, target.agentName, 'manual');
116
+ console.log(`✔ ${target.relativePath} (${result.created ? 'created' : 'updated'})`);
117
+ written++;
118
+ }
119
+ catch (err) {
120
+ logger.debug(`Failed to export ${target.format}:`, err);
121
+ console.warn(`⚠ Skipped ${target.format}: ${err instanceof Error ? err.message : String(err)}`);
122
+ }
123
+ }
124
+ // Consolidate gitignore entries
125
+ if (allGitignoreEntries.length > 0) {
126
+ ensureGitignoreEntries(cwd, [...new Set(allGitignoreEntries)]);
127
+ console.log('✔ Updated .gitignore');
128
+ }
129
+ console.log(`✔ Exported ${written} agent file(s)`);
130
+ }
93
131
  export function writeAgentExportForAgent(agentName, cwd) {
94
132
  const rendered = renderAgentExportForAgent(agentName, cwd);
95
133
  if (!rendered) {
@@ -245,6 +245,8 @@ export async function runInit(options = {}) {
245
245
  if (initMemoryRepo(cwd)) {
246
246
  console.log('✔ Initialized memory git repo for versioning');
247
247
  }
248
+ // Install post-merge hook for auto-release of claims after merge
249
+ installPostMergeHookIfMissing(cwd);
248
250
  const onboardingPreflight = runBootstrapProfile({ cwd, refresh: true });
249
251
  console.log('');
250
252
  console.log('Onboarding preflight:');
@@ -268,6 +270,45 @@ export async function runInit(options = {}) {
268
270
  console.log('');
269
271
  console.log(`Tip: run 'brainclaw context --json' to load the shared memory into your agent session.`);
270
272
  }
273
+ function installPostMergeHookIfMissing(cwd) {
274
+ try {
275
+ let dir = path.resolve(cwd);
276
+ let gitRoot;
277
+ while (true) {
278
+ if (fs.existsSync(path.join(dir, '.git'))) {
279
+ gitRoot = dir;
280
+ break;
281
+ }
282
+ const parent = path.dirname(dir);
283
+ if (parent === dir)
284
+ break;
285
+ dir = parent;
286
+ }
287
+ if (!gitRoot)
288
+ return;
289
+ const hooksDir = path.join(gitRoot, '.git', 'hooks');
290
+ const hookPath = path.join(hooksDir, 'post-merge');
291
+ if (fs.existsSync(hookPath))
292
+ return; // don't overwrite existing hooks
293
+ if (!fs.existsSync(hooksDir))
294
+ fs.mkdirSync(hooksDir, { recursive: true });
295
+ const script = [
296
+ '#!/bin/sh',
297
+ '# brainclaw post-merge hook — auto-release claims on merged files',
298
+ 'BCLAW_CMD=""',
299
+ 'if command -v brainclaw >/dev/null 2>&1; then BCLAW_CMD="brainclaw"',
300
+ 'elif command -v bclaw >/dev/null 2>&1; then BCLAW_CMD="bclaw"',
301
+ 'else BCLAW_CMD="npx --no brainclaw"; fi',
302
+ '$BCLAW_CMD release-claims --from-git-diff 2>/dev/null || true',
303
+ '',
304
+ ].join('\n');
305
+ fs.writeFileSync(hookPath, script, { encoding: 'utf-8', mode: 0o755 });
306
+ console.log('✔ Installed post-merge hook for auto-release of claims');
307
+ }
308
+ catch {
309
+ // Non-critical — skip silently
310
+ }
311
+ }
271
312
  function resolveStorageDir(storageDir) {
272
313
  const candidate = (storageDir ?? MEMORY_DIR).trim();
273
314
  if (candidate !== MEMORY_DIR) {
@@ -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.');
@@ -18,7 +18,7 @@ import { acceptCandidate } from './accept.js';
18
18
  import { rejectCandidate } from './reject.js';
19
19
  import { startSession } from './session-start.js';
20
20
  import { endSession } from './session-end.js';
21
- import { agentCanWriteDirect, AgentIdentityResolutionError, AgentTrustError, listAgentIdentities, requireMinimumTrustLevel, requireRegisteredAgentIdentity, resolveAgentScope, resolveCurrentAgentIdentity, resolveCurrentAgentName, } from '../core/agent-registry.js';
21
+ import { agentCanWriteDirect, AgentIdentityResolutionError, AgentTrustError, listAgentIdentities, requireMinimumTrustLevel, requireRegisteredAgentIdentity, resolveAgentScope, resolveCurrentAgentIdentity, resolveCurrentAgentName, resolveCurrentModel, } from '../core/agent-registry.js';
22
22
  import { appendAuditEntry } from '../core/audit.js';
23
23
  import { nowISO, generateIdWithLabel, generateId } from '../core/ids.js';
24
24
  import { inferProjectFromTarget, loadInstructions, resolveInstructions } from '../core/instructions.js';
@@ -156,6 +156,10 @@ export const MCP_READ_TOOLS = [
156
156
  type: { type: 'string', description: 'Filter by plan type.' },
157
157
  assignee: { type: 'string', description: 'Filter by assignee name.' },
158
158
  project: { type: 'string', description: 'Filter by project namespace.' },
159
+ id: { type: 'string', description: 'Get a single plan by ID (exact match).' },
160
+ limit: { type: 'number', description: 'Maximum number of plans to return (default: 20).' },
161
+ offset: { type: 'number', description: 'Number of plans to skip (for pagination).' },
162
+ compact: { type: 'boolean', description: 'Return only key fields (id, short_label, text, status, priority) to reduce output size.' },
159
163
  },
160
164
  },
161
165
  },
@@ -667,6 +671,11 @@ export class McpTaskRunner {
667
671
  onInternalError;
668
672
  active;
669
673
  queue = [];
674
+ _totalExecuted = 0;
675
+ _totalCancelled = 0;
676
+ _peakQueueDepth = 0;
677
+ _lastDurationMs = 0;
678
+ _lastWaitMs = 0;
670
679
  constructor(options) {
671
680
  this.executeTool = options.executeTool;
672
681
  this.onResult = options.onResult;
@@ -678,19 +687,35 @@ export class McpTaskRunner {
678
687
  get queuedRequestIds() {
679
688
  return this.queue.map((task) => task.requestId);
680
689
  }
690
+ /** Current single-writer queue metrics. */
691
+ get metrics() {
692
+ return {
693
+ totalExecuted: this._totalExecuted,
694
+ totalCancelled: this._totalCancelled,
695
+ queueDepth: this.queue.length,
696
+ peakQueueDepth: this._peakQueueDepth,
697
+ lastDurationMs: this._lastDurationMs,
698
+ lastWaitMs: this._lastWaitMs,
699
+ };
700
+ }
681
701
  enqueue(requestId, payload) {
682
702
  this.queue.push({
683
703
  requestId,
684
704
  payload,
685
705
  controller: new AbortController(),
686
706
  cancelled: false,
707
+ enqueuedAt: performance.now(),
687
708
  });
709
+ if (this.queue.length > this._peakQueueDepth) {
710
+ this._peakQueueDepth = this.queue.length;
711
+ }
688
712
  this.drain();
689
713
  }
690
714
  cancel(requestId) {
691
715
  if (this.active && this.active.requestId === requestId) {
692
716
  this.active.cancelled = true;
693
717
  this.active.controller.abort();
718
+ this._totalCancelled++;
694
719
  return 'active';
695
720
  }
696
721
  const index = this.queue.findIndex((task) => task.requestId === requestId);
@@ -698,6 +723,7 @@ export class McpTaskRunner {
698
723
  const [task] = this.queue.splice(index, 1);
699
724
  task.cancelled = true;
700
725
  task.controller.abort();
726
+ this._totalCancelled++;
701
727
  return 'queued';
702
728
  }
703
729
  return 'missing';
@@ -722,6 +748,7 @@ export class McpTaskRunner {
722
748
  return;
723
749
  }
724
750
  if (next.cancelled) {
751
+ this._totalCancelled++;
725
752
  this.drain();
726
753
  return;
727
754
  }
@@ -729,6 +756,8 @@ export class McpTaskRunner {
729
756
  void this.runTask(next);
730
757
  }
731
758
  async runTask(task) {
759
+ const startedAt = performance.now();
760
+ this._lastWaitMs = startedAt - task.enqueuedAt;
732
761
  try {
733
762
  const outcome = await this.executeTool(task.payload, task.controller.signal);
734
763
  if (!task.cancelled) {
@@ -741,6 +770,8 @@ export class McpTaskRunner {
741
770
  }
742
771
  }
743
772
  finally {
773
+ this._lastDurationMs = performance.now() - startedAt;
774
+ this._totalExecuted++;
744
775
  if (this.active === task) {
745
776
  this.active = undefined;
746
777
  }
@@ -1281,6 +1312,18 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
1281
1312
  }
1282
1313
  if (name === 'bclaw_list_plans') {
1283
1314
  let plans = loadState(cwd).plan_items;
1315
+ // Direct lookup by ID
1316
+ if (args.id) {
1317
+ const plan = plans.find((p) => p.id === String(args.id) || p.short_label === String(args.id));
1318
+ if (!plan) {
1319
+ return { content: [{ type: 'text', text: `Plan '${args.id}' not found.` }], structuredContent: { total: 0, plans: [] } };
1320
+ }
1321
+ return {
1322
+ content: [{ type: 'text', text: `[${plan.id}] ${plan.text} (${plan.status}, ${plan.priority})` }],
1323
+ structuredContent: { total: 1, plans: [plan] },
1324
+ };
1325
+ }
1326
+ // Filters
1284
1327
  if (!args.all) {
1285
1328
  plans = plans.filter((plan) => plan.status !== 'done' && plan.status !== 'dropped');
1286
1329
  }
@@ -1298,11 +1341,16 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
1298
1341
  const project = String(args.project).toLowerCase();
1299
1342
  plans = plans.filter((plan) => plan.project?.toLowerCase() === project);
1300
1343
  }
1301
- const lines = plans.length === 0
1344
+ const totalFiltered = plans.length;
1345
+ // Pagination
1346
+ const offset = Math.max(0, Number(args.offset) || 0);
1347
+ const limit = Math.max(1, Number(args.limit) || 20);
1348
+ const paginated = plans.slice(offset, offset + limit);
1349
+ const lines = paginated.length === 0
1302
1350
  ? ['No plan items found.']
1303
1351
  : [
1304
- `${plans.length} plan item(s):`,
1305
- ...plans.map((plan) => {
1352
+ `${totalFiltered} plan(s)${totalFiltered > paginated.length ? ` (showing ${offset + 1}-${offset + paginated.length})` : ''}:`,
1353
+ ...paginated.map((plan) => {
1306
1354
  const meta = [plan.type ?? 'feat', plan.status, plan.priority];
1307
1355
  if (plan.assignee)
1308
1356
  meta.push(`assignee ${plan.assignee}`);
@@ -1314,9 +1362,15 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
1314
1362
  return `[${plan.id}] ${plan.text} (${meta.join(' · ')})${tags}`;
1315
1363
  }),
1316
1364
  ];
1365
+ // Compact mode: strip heavy fields
1366
+ const outputPlans = args.compact
1367
+ ? paginated.map(({ id, short_label, text, status, priority, tags, assignee, type }) => ({
1368
+ id, short_label, text, status, priority, tags, assignee, type,
1369
+ }))
1370
+ : paginated;
1317
1371
  return {
1318
1372
  content: [{ type: 'text', text: lines.join('\n') }],
1319
- structuredContent: { total: plans.length, plans },
1373
+ structuredContent: { total: totalFiltered, offset, limit, plans: outputPlans },
1320
1374
  };
1321
1375
  }
1322
1376
  if (name === 'bclaw_list_claims') {
@@ -1584,6 +1638,8 @@ export async function executeMcpToolCall(payload) {
1584
1638
  response: toolResponse(handleMcpReadToolCall(name, args, { cwd })),
1585
1639
  };
1586
1640
  }
1641
+ // Resolve model once for all write operations
1642
+ const currentModel = resolveCurrentModel(cwd);
1587
1643
  if (name === 'bclaw_setup') {
1588
1644
  const step = args.step;
1589
1645
  const choice = args.choice ?? '';
@@ -1767,6 +1823,7 @@ export async function executeMcpToolCall(payload) {
1767
1823
  autoReflect: args.autoReflect,
1768
1824
  cwd,
1769
1825
  sessionId: connectionSessionId,
1826
+ model: currentModel,
1770
1827
  }, false);
1771
1828
  return {
1772
1829
  response: toolResponse({
@@ -1819,6 +1876,7 @@ export async function executeMcpToolCall(payload) {
1819
1876
  category: type === 'constraint' ? args.category : undefined,
1820
1877
  outcome: type === 'decision' ? args.outcome : undefined,
1821
1878
  plan_id: candidatePlanId,
1879
+ model: currentModel,
1822
1880
  star_count: 0,
1823
1881
  starred_by: [],
1824
1882
  usage_count: 0,
@@ -1922,6 +1980,7 @@ export async function executeMcpToolCall(payload) {
1922
1980
  created_at: nowISO(),
1923
1981
  status: 'active',
1924
1982
  plan_id: args.planId,
1983
+ model: currentModel,
1925
1984
  }, claimCwd);
1926
1985
  appendAuditEntry({ actor: resolvedIdentity.agent_name, actor_id: resolvedIdentity.agent_id, action: 'claim', item_id: claimId, item_type: 'claim' }, claimCwd);
1927
1986
  const postClaimItems = getTriggeredItems('trigger:post-claim', claimCwd);
@@ -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.`);
@@ -61,6 +61,7 @@ export function createRuntimeNote(text, options, printSuccess = false) {
61
61
  host_id: hostId,
62
62
  expires_at: expiresAt,
63
63
  note_type: 'observation',
64
+ model: options.model,
64
65
  };
65
66
  saveRuntimeNote(note, options.cwd);
66
67
  const result = maybeAutoReflectRuntimeNote(note, options);
@@ -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,