brainclaw 0.19.6 → 0.19.7

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,5 +1,6 @@
1
1
  import { loadState, saveState } from '../core/state.js';
2
- import { memoryExists } from '../core/io.js';
2
+ import { memoryExists, memoryPath, withStoreLock, writeFileAtomic } from '../core/io.js';
3
+ import { generateMarkdown } from '../core/markdown.js';
3
4
  import { deleteRuntimeNote, listRuntimeNotes } from '../core/runtime.js';
4
5
  import { expireStaleActiveClaims } from '../core/claims.js';
5
6
  export function runPrune(options = {}) {
@@ -8,36 +9,37 @@ export function runPrune(options = {}) {
8
9
  console.error('Project memory not initialized. Run `brainclaw init` first.');
9
10
  process.exit(1);
10
11
  }
11
- const state = loadState(cwd);
12
12
  const now = new Date().toISOString();
13
- const originalLength = state.active_constraints.length;
14
- // Transition expired constraints to "expired"
15
- for (const c of state.active_constraints) {
16
- if (c.status === 'active' && c.expires_at && c.expires_at < now) {
17
- c.status = 'expired';
18
- }
19
- }
20
- // Filter out constraints that have been expired for a while to keep memory clean
21
- // For the MVP, we just remove them if they are expired
22
- state.active_constraints = state.active_constraints.filter(c => c.status !== 'expired');
23
- const prunedCount = originalLength - state.active_constraints.length;
24
- saveState(state, cwd);
25
- const expiredClaimsCount = expireStaleActiveClaims(cwd);
13
+ let prunedCount = 0;
14
+ let expiredClaimsCount = 0;
26
15
  let expiredNotesCount = 0;
27
- if (options.expired) {
28
- // Prune expired runtime notes
29
- const notes = listRuntimeNotes(undefined, cwd);
30
- for (const note of notes) {
31
- if (note.expires_at && note.expires_at < now) {
32
- try {
33
- if (deleteRuntimeNote(note, cwd)) {
34
- expiredNotesCount++;
16
+ withStoreLock(cwd, () => {
17
+ const state = loadState(cwd);
18
+ const originalLength = state.active_constraints.length;
19
+ for (const c of state.active_constraints) {
20
+ if (c.status === 'active' && c.expires_at && c.expires_at < now) {
21
+ c.status = 'expired';
22
+ }
23
+ }
24
+ state.active_constraints = state.active_constraints.filter(c => c.status !== 'expired');
25
+ prunedCount = originalLength - state.active_constraints.length;
26
+ saveState(state, cwd);
27
+ expiredClaimsCount = expireStaleActiveClaims(cwd);
28
+ if (options.expired) {
29
+ const notes = listRuntimeNotes(undefined, cwd);
30
+ for (const note of notes) {
31
+ if (note.expires_at && note.expires_at < now) {
32
+ try {
33
+ if (deleteRuntimeNote(note, cwd)) {
34
+ expiredNotesCount++;
35
+ }
35
36
  }
37
+ catch { /* ignore */ }
36
38
  }
37
- catch { /* ignore */ }
38
39
  }
39
40
  }
40
- }
41
+ writeFileAtomic(memoryPath('project.md', cwd), generateMarkdown(loadState(cwd), cwd));
42
+ });
41
43
  if (options.expired) {
42
44
  console.log(`✔ Pruned ${prunedCount} expired constraints, ${expiredNotesCount} expired runtime notes, ${expiredClaimsCount} expired claims.`);
43
45
  }
@@ -1,9 +1,8 @@
1
1
  import fs from 'node:fs';
2
- import { memoryExists, memoryPath, writeFileAtomic } from '../core/io.js';
2
+ import { memoryExists } from '../core/io.js';
3
3
  import { loadConfig } from '../core/config.js';
4
4
  import { buildOperationalIdentity } from '../core/identity.js';
5
- import { loadState, saveState } from '../core/state.js';
6
- import { generateMarkdown } from '../core/markdown.js';
5
+ import { loadState, persistState } from '../core/state.js';
7
6
  import { scanText } from '../core/security.js';
8
7
  import { nowISO, generateIdWithLabel } from '../core/ids.js';
9
8
  import { saveCandidate, generateCandidateIdWithLabel, listCandidates } from '../core/candidates.js';
@@ -265,8 +264,7 @@ function promoteCandidateToState(candidate, cwd) {
265
264
  break;
266
265
  }
267
266
  }
268
- saveState(state, cwd);
269
- writeFileAtomic(memoryPath('project.md', cwd), generateMarkdown(state));
267
+ persistState(state, cwd);
270
268
  return promotedItemId;
271
269
  }
272
270
  export function mapEventTypeToCandidateType(eventType) {
@@ -1,5 +1,5 @@
1
1
  import { memoryExists } from '../core/io.js';
2
- import { memoryPath, writeFileAtomic } from '../core/io.js';
2
+ import { memoryPath, withStoreLock, writeFileAtomic } from '../core/io.js';
3
3
  import { loadClaim, listClaims, releaseClaim } from '../core/claims.js';
4
4
  import { generateMarkdown } from '../core/markdown.js';
5
5
  import { loadState, saveState } from '../core/state.js';
@@ -9,27 +9,30 @@ export function runReleaseClaim(id, options = {}) {
9
9
  process.exit(1);
10
10
  }
11
11
  try {
12
- const existing = loadClaim(id, options.cwd);
13
- const claim = releaseClaim(id, options.cwd);
14
- let state = loadState(options.cwd);
15
- if (existing.plan_id) {
16
- const plan = state.plan_items.find((item) => item.id === existing.plan_id);
17
- if (plan) {
18
- const otherActiveClaims = listClaims(options.cwd).filter((item) => item.status === 'active' && item.plan_id === existing.plan_id);
19
- if (options.planStatus) {
20
- plan.status = options.planStatus;
12
+ let claim = loadClaim(id, options.cwd);
13
+ withStoreLock(options.cwd, () => {
14
+ const existing = loadClaim(id, options.cwd);
15
+ claim = releaseClaim(id, options.cwd);
16
+ let state = loadState(options.cwd);
17
+ if (existing.plan_id) {
18
+ const plan = state.plan_items.find((item) => item.id === existing.plan_id);
19
+ if (plan) {
20
+ const otherActiveClaims = listClaims(options.cwd).filter((item) => item.status === 'active' && item.plan_id === existing.plan_id);
21
+ if (options.planStatus) {
22
+ plan.status = options.planStatus;
23
+ }
24
+ else if (otherActiveClaims.length === 0 && plan.status === 'in_progress') {
25
+ plan.status = 'todo';
26
+ }
27
+ if (otherActiveClaims.length === 0 && plan.assignee === existing.agent) {
28
+ plan.assignee = undefined;
29
+ }
30
+ plan.updated_at = new Date().toISOString();
31
+ saveState(state, options.cwd);
21
32
  }
22
- else if (otherActiveClaims.length === 0 && plan.status === 'in_progress') {
23
- plan.status = 'todo';
24
- }
25
- if (otherActiveClaims.length === 0 && plan.assignee === existing.agent) {
26
- plan.assignee = undefined;
27
- }
28
- plan.updated_at = new Date().toISOString();
29
- saveState(state, options.cwd);
30
33
  }
31
- }
32
- writeFileAtomic(memoryPath('project.md', options.cwd), generateMarkdown(state, options.cwd));
34
+ writeFileAtomic(memoryPath('project.md', options.cwd), generateMarkdown(state, options.cwd));
35
+ });
33
36
  console.log(`✔ Claim [${id}] released (was: ${claim.agent} → ${claim.scope})`);
34
37
  }
35
38
  catch (e) {
@@ -1,5 +1,5 @@
1
1
  import { execSync } from 'node:child_process';
2
- import { memoryExists, memoryPath, writeFileAtomic } from '../core/io.js';
2
+ import { memoryExists, memoryPath, withStoreLock, writeFileAtomic } from '../core/io.js';
3
3
  import { listClaims, releaseClaim } from '../core/claims.js';
4
4
  import { generateMarkdown } from '../core/markdown.js';
5
5
  import { loadState, saveState } from '../core/state.js';
@@ -35,31 +35,32 @@ export function runReleaseClaims(options = {}) {
35
35
  const toRelease = claims.filter((c) => changedFiles.some((f) => scopeMatchesFile(c.scope, f)));
36
36
  if (toRelease.length === 0)
37
37
  process.exit(0);
38
- let state = loadState(options.cwd);
39
38
  let released = 0;
40
- for (const claim of toRelease) {
41
- try {
42
- releaseClaim(claim.id, options.cwd);
43
- released++;
44
- // Revert in_progress plan to todo if no more active claims
45
- if (claim.plan_id) {
46
- state = loadState(options.cwd);
47
- const plan = state.plan_items.find((p) => p.id === claim.plan_id);
48
- if (plan) {
49
- const remaining = listClaims(options.cwd).filter((c) => c.status === 'active' && c.plan_id === claim.plan_id);
50
- if (remaining.length === 0 && plan.status === 'in_progress') {
51
- plan.status = 'todo';
52
- plan.updated_at = new Date().toISOString();
53
- saveState(state, options.cwd);
39
+ withStoreLock(options.cwd, () => {
40
+ let state = loadState(options.cwd);
41
+ for (const claim of toRelease) {
42
+ try {
43
+ releaseClaim(claim.id, options.cwd);
44
+ released++;
45
+ if (claim.plan_id) {
46
+ state = loadState(options.cwd);
47
+ const plan = state.plan_items.find((p) => p.id === claim.plan_id);
48
+ if (plan) {
49
+ const remaining = listClaims(options.cwd).filter((c) => c.status === 'active' && c.plan_id === claim.plan_id);
50
+ if (remaining.length === 0 && plan.status === 'in_progress') {
51
+ plan.status = 'todo';
52
+ plan.updated_at = new Date().toISOString();
53
+ saveState(state, options.cwd);
54
+ }
54
55
  }
55
56
  }
57
+ console.log(`✔ Auto-released claim [${claim.id}]: ${claim.scope}`);
56
58
  }
57
- console.log(`✔ Auto-released claim [${claim.id}]: ${claim.scope}`);
59
+ catch { /* skip individual failures */ }
58
60
  }
59
- catch { /* skip individual failures */ }
60
- }
61
- state = loadState(options.cwd);
62
- writeFileAtomic(memoryPath('project.md', options.cwd), generateMarkdown(state, options.cwd));
61
+ state = loadState(options.cwd);
62
+ writeFileAtomic(memoryPath('project.md', options.cwd), generateMarkdown(state, options.cwd));
63
+ });
63
64
  if (released > 0) {
64
65
  console.log(`brainclaw: ${released} claim(s) auto-released after merge.`);
65
66
  }
@@ -1,6 +1,6 @@
1
1
  import { memoryExists } from '../core/io.js';
2
2
  import { readAuditLog } from '../core/audit.js';
3
- import { loadState, saveState } from '../core/state.js';
3
+ import { loadState, persistState } from '../core/state.js';
4
4
  import { loadCandidate, saveCandidate } from '../core/candidates.js';
5
5
  import { appendAuditEntry } from '../core/audit.js';
6
6
  import { buildOperationalIdentity } from '../core/identity.js';
@@ -128,7 +128,7 @@ function applyRollback(entry, actorName, options) {
128
128
  state.known_traps.push(before);
129
129
  }
130
130
  }
131
- saveState(state);
131
+ persistState(state);
132
132
  }
133
133
  else {
134
134
  return { ok: false, message: `Rollback not supported for item_type '${item_type}'.` };
@@ -1,7 +1,6 @@
1
- import { loadState, saveState } from '../core/state.js';
1
+ import { loadState, persistState } from '../core/state.js';
2
2
  import { resolveCurrentAgentName } from '../core/agent-registry.js';
3
- import { memoryExists, memoryPath, writeFileAtomic } from '../core/io.js';
4
- import { generateMarkdown } from '../core/markdown.js';
3
+ import { memoryExists } from '../core/io.js';
5
4
  import { generateIdWithLabel, nowISO } from '../core/ids.js';
6
5
  import { loadConfig } from '../core/config.js';
7
6
  import { scanText } from '../core/security.js';
@@ -89,8 +88,7 @@ function runToolAdd(name, description, options, cwd) {
89
88
  // For now, store as decision to avoid schema migration
90
89
  // Will migrate to separate tool storage in v0.16
91
90
  state.recent_decisions.push(entry);
92
- saveState(state, cwd);
93
- writeFileAtomic(memoryPath('project.md', cwd), generateMarkdown(state));
91
+ persistState(state, cwd);
94
92
  console.log(`✔ Tool added: [${id}] ${name}`);
95
93
  console.log(' (Stored in decisions for now; will move to dedicated registry in v0.16)');
96
94
  }
@@ -1,11 +1,10 @@
1
- import { loadState, saveState } from '../core/state.js';
1
+ import { loadState, persistState } from '../core/state.js';
2
2
  import { resolveCurrentAgentName } from '../core/agent-registry.js';
3
3
  import { resolveCurrentHostId } from '../core/host.js';
4
4
  import { loadConfig } from '../core/config.js';
5
- import { generateMarkdown } from '../core/markdown.js';
6
5
  import { nowISO } from '../core/ids.js';
7
6
  import { scanText } from '../core/security.js';
8
- import { memoryExists, memoryPath, writeFileAtomic } from '../core/io.js';
7
+ import { memoryExists } from '../core/io.js';
9
8
  import { generateTrapIdWithLabel, saveOperationalTrap } from '../core/traps.js';
10
9
  import { validateCliInput, validateCliTtl } from '../core/input-validation.js';
11
10
  import { resolveTargetStore } from '../core/store-resolution.js';
@@ -49,8 +48,7 @@ export function runTrap(text, options = {}) {
49
48
  };
50
49
  if (visibility === 'shared') {
51
50
  state.known_traps.push(entry);
52
- saveState(state, cwd);
53
- writeFileAtomic(memoryPath('project.md', cwd), generateMarkdown(state, cwd));
51
+ persistState(state, cwd);
54
52
  }
55
53
  else {
56
54
  saveOperationalTrap(entry, cwd);
@@ -1,6 +1,5 @@
1
- import { loadState, saveState } from '../core/state.js';
2
- import { memoryExists, memoryPath, writeFileAtomic } from '../core/io.js';
3
- import { generateMarkdown } from '../core/markdown.js';
1
+ import { loadState, persistState } from '../core/state.js';
2
+ import { memoryExists } from '../core/io.js';
4
3
  export function runUpdateHandoff(id, options = {}) {
5
4
  if (!memoryExists()) {
6
5
  console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
@@ -16,8 +15,7 @@ export function runUpdateHandoff(id, options = {}) {
16
15
  handoff.status = options.status;
17
16
  if (options.to !== undefined)
18
17
  handoff.to = options.to;
19
- saveState(state);
20
- writeFileAtomic(memoryPath('project.md'), generateMarkdown(state));
18
+ persistState(state);
21
19
  console.log(`✔ Handoff updated: [${handoff.id}] ${handoff.from} → ${handoff.to} (${handoff.status})`);
22
20
  }
23
21
  //# sourceMappingURL=update-handoff.js.map
@@ -1,6 +1,5 @@
1
- import { loadState, saveState } from '../core/state.js';
2
- import { memoryExists, memoryPath, writeFileAtomic } from '../core/io.js';
3
- import { generateMarkdown } from '../core/markdown.js';
1
+ import { loadState, persistState } from '../core/state.js';
2
+ import { memoryExists } from '../core/io.js';
4
3
  import { nowISO } from '../core/ids.js';
5
4
  export function runUpdatePlan(id, options = {}) {
6
5
  if (!memoryExists(options.cwd)) {
@@ -30,8 +29,7 @@ export function runUpdatePlan(id, options = {}) {
30
29
  if (options.actualEffort)
31
30
  plan.actual_effort = options.actualEffort;
32
31
  plan.updated_at = timestamp;
33
- saveState(state, options.cwd);
34
- writeFileAtomic(memoryPath('project.md', options.cwd), generateMarkdown(state, options.cwd));
32
+ persistState(state, options.cwd);
35
33
  console.log(`✔ Plan item updated: [${plan.id}] ${plan.text}`);
36
34
  }
37
35
  //# sourceMappingURL=update-plan.js.map
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { ensureMemoryDir, memoryDir, memoryExists } from '../core/io.js';
4
- import { loadState, saveState } from '../core/state.js';
4
+ import { loadState, persistState } from '../core/state.js';
5
5
  import { scanMigrationStatus } from '../core/migration.js';
6
6
  import { commitMemoryChange, initMemoryRepo } from '../core/memory-git.js';
7
7
  import { BRAINCLAW_SECTION_END, BRAINCLAW_SECTION_START, buildBrainclawSection, buildClaudeCodeCommandText, ensureClaudeCodeCommand, hasBrainclawSection, } from '../core/agent-files.js';
@@ -114,7 +114,7 @@ export function runUpgrade(options = {}) {
114
114
  // Execute schema migrations by re-saving state (loadState auto-migrates via Zod parse)
115
115
  if (outdated.length > 0) {
116
116
  const state = loadState(cwd);
117
- saveState(state, cwd);
117
+ persistState(state, cwd);
118
118
  }
119
119
  const refreshedAgentFiles = refreshManagedWorkspaceAgentFiles(cwd);
120
120
  // Clean up empty legacy directories (recursively removes empty subdirs first)
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { memoryDir } from './io.js';
3
+ import { memoryDir, withStoreLock } from './io.js';
4
4
  import { nowISO } from './ids.js';
5
5
  import { logger } from './logger.js';
6
6
  import { appendEvent } from './event-log.js';
@@ -23,31 +23,31 @@ function auditLogPath(cwd) {
23
23
  }
24
24
  export function appendAuditEntry(entry, cwd) {
25
25
  try {
26
- const full = {
27
- timestamp: nowISO(),
28
- actor: entry.actor,
29
- action: entry.action,
30
- item_id: entry.item_id,
31
- item_type: entry.item_type,
32
- before: entry.before,
33
- after: entry.after,
34
- actor_id: entry.actor_id,
35
- reason: entry.reason,
36
- };
37
- // Remove undefined fields for compactness
38
- const line = JSON.stringify(Object.fromEntries(Object.entries(full).filter(([, v]) => v !== undefined)));
39
- fs.appendFileSync(auditLogPath(cwd), line + '\n', 'utf-8');
40
- // Mirror to structured event log
41
- const eventAction = AUDIT_TO_EVENT_ACTION[entry.action];
42
- if (eventAction) {
43
- appendEvent({
44
- action: eventAction,
45
- item_type: entry.item_type ?? 'state',
26
+ withStoreLock(cwd, () => {
27
+ const full = {
28
+ timestamp: nowISO(),
29
+ actor: entry.actor,
30
+ action: entry.action,
46
31
  item_id: entry.item_id,
47
- agent: entry.actor,
48
- agent_id: entry.actor_id,
49
- }, cwd);
50
- }
32
+ item_type: entry.item_type,
33
+ before: entry.before,
34
+ after: entry.after,
35
+ actor_id: entry.actor_id,
36
+ reason: entry.reason,
37
+ };
38
+ const line = JSON.stringify(Object.fromEntries(Object.entries(full).filter(([, v]) => v !== undefined)));
39
+ fs.appendFileSync(auditLogPath(cwd), line + '\n', 'utf-8');
40
+ const eventAction = AUDIT_TO_EVENT_ACTION[entry.action];
41
+ if (eventAction) {
42
+ appendEvent({
43
+ action: eventAction,
44
+ item_type: entry.item_type ?? 'state',
45
+ item_id: entry.item_id,
46
+ agent: entry.actor,
47
+ agent_id: entry.actor_id,
48
+ }, cwd);
49
+ }
50
+ });
51
51
  }
52
52
  catch (err) {
53
53
  logger.debug('Failed to write audit log entry:', err);
@@ -2,7 +2,7 @@ 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 } from './io.js';
5
+ import { resolveEntityDir, withStoreLock } from './io.js';
6
6
  import { nowISO, getNextShortLabel } from './ids.js';
7
7
  import { JsonStore } from './json-store.js';
8
8
  function inboxDir(cwd, mode = 'read') {
@@ -35,8 +35,10 @@ function candidateStore(dest = 'pending', cwd) {
35
35
  });
36
36
  }
37
37
  export function saveCandidate(candidate, cwd) {
38
- ensureInboxDirs(cwd);
39
- candidateStore('pending', cwd).save(CandidateSchema.parse(candidate));
38
+ withStoreLock(cwd, () => {
39
+ ensureInboxDirs(cwd);
40
+ candidateStore('pending', cwd).save(CandidateSchema.parse(candidate));
41
+ });
40
42
  }
41
43
  export function loadCandidate(id, cwd) {
42
44
  return candidateStore('pending', cwd).load(id);
@@ -49,9 +51,11 @@ export function listCandidates(status, cwd) {
49
51
  return status ? candidates.filter((candidate) => candidate.status === status) : candidates;
50
52
  }
51
53
  export function archiveCandidate(candidate, dest, cwd) {
52
- ensureInboxDirs(cwd);
53
- candidateStore(dest, cwd).save(CandidateSchema.parse(candidate));
54
- candidateStore('pending', cwd).delete(candidate.id);
54
+ withStoreLock(cwd, () => {
55
+ ensureInboxDirs(cwd);
56
+ candidateStore(dest, cwd).save(CandidateSchema.parse(candidate));
57
+ candidateStore('pending', cwd).delete(candidate.id);
58
+ });
55
59
  }
56
60
  export function listArchivedCandidates(dest, cwd) {
57
61
  return candidateStore(dest, cwd).list();
@@ -1,7 +1,7 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import { ClaimSchema } from './schema.js';
4
- import { resolveEntityDir } from './io.js';
4
+ import { resolveEntityDir, withStoreLock } from './io.js';
5
5
  import { nowISO } from './ids.js';
6
6
  import { JsonStore } from './json-store.js';
7
7
  function claimsDir(cwd, mode = 'read') {
@@ -22,14 +22,16 @@ function claimStore(cwd) {
22
22
  });
23
23
  }
24
24
  export function saveClaim(claim, cwd) {
25
- ensureClaimsDir(cwd);
26
- const writeStore = new JsonStore({
27
- dirPath: claimsDir(cwd, 'write'),
28
- documentType: 'claim',
29
- getId: (c) => c.id,
30
- sort: (a, b) => a.created_at.localeCompare(b.created_at),
25
+ withStoreLock(cwd, () => {
26
+ ensureClaimsDir(cwd);
27
+ const writeStore = new JsonStore({
28
+ dirPath: claimsDir(cwd, 'write'),
29
+ documentType: 'claim',
30
+ getId: (c) => c.id,
31
+ sort: (a, b) => a.created_at.localeCompare(b.created_at),
32
+ });
33
+ writeStore.save(ClaimSchema.parse(claim));
31
34
  });
32
- writeStore.save(ClaimSchema.parse(claim));
33
35
  }
34
36
  export function loadClaim(id, cwd) {
35
37
  return claimStore(cwd).load(id);
@@ -5,7 +5,7 @@ import { listClaims } from './claims.js';
5
5
  import { inferProjectFromTarget, loadInstructions, resolveInstructions } from './instructions.js';
6
6
  import { buildReputationSummary, findAgentReputationSummary } from './reputation.js';
7
7
  import { listRuntimeNotes } from './runtime.js';
8
- import { loadState, saveState } from './state.js';
8
+ import { loadState, persistState } from './state.js';
9
9
  export function buildCoordinationSnapshot(options = {}) {
10
10
  const config = loadConfig(options.cwd);
11
11
  const state = loadState(options.cwd);
@@ -58,7 +58,7 @@ export function buildCoordinationSnapshot(options = {}) {
58
58
  }
59
59
  }
60
60
  if (changed)
61
- saveState(state, options.cwd);
61
+ persistState(state, options.cwd);
62
62
  }
63
63
  return {
64
64
  project_id: config.project_id,
@@ -1,5 +1,5 @@
1
1
  import fs from 'node:fs';
2
- import { resolveEntityDir } from './io.js';
2
+ import { resolveEntityDir, withStoreLock } from './io.js';
3
3
  import { generateId } from './ids.js';
4
4
  import { InstructionEntrySchema } from './schema.js';
5
5
  import { JsonStore } from './json-store.js';
@@ -19,7 +19,9 @@ export function loadInstructions(cwd) {
19
19
  return instructionStore(cwd).list();
20
20
  }
21
21
  export function saveInstruction(entry, cwd) {
22
- instructionStore(cwd, 'write').save(InstructionEntrySchema.parse(entry));
22
+ withStoreLock(cwd, () => {
23
+ instructionStore(cwd, 'write').save(InstructionEntrySchema.parse(entry));
24
+ });
23
25
  }
24
26
  export function createInstruction(text, options, cwd) {
25
27
  const entries = loadInstructions(cwd);
package/dist/core/io.js CHANGED
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { withLock, cleanStaleLocks } from './lock.js';
4
4
  export const MEMORY_DIR = '.brainclaw';
5
+ const STORE_LOCK_BASENAME = '.store-mutation';
5
6
  const RETRYABLE_RENAME_ERROR_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
6
7
  const DEFAULT_RENAME_RETRY_ATTEMPTS = 6;
7
8
  const DEFAULT_RENAME_RETRY_DELAY_MS = 25;
@@ -72,6 +73,9 @@ export function memoryDir(cwd = process.cwd(), preferredDirName) {
72
73
  export function memoryPath(filename, cwd, preferredDirName) {
73
74
  return path.join(memoryDir(cwd, preferredDirName), filename);
74
75
  }
76
+ export function storeLockPath(cwd, preferredDirName) {
77
+ return memoryPath(STORE_LOCK_BASENAME, cwd, preferredDirName);
78
+ }
75
79
  export function memoryExists(cwd, preferredDirName) {
76
80
  return fs.existsSync(memoryDir(cwd, preferredDirName));
77
81
  }
@@ -95,6 +99,10 @@ export function ensureMemoryDir(cwd, preferredDirName) {
95
99
  }
96
100
  }
97
101
  }
102
+ export function withStoreLock(cwd = process.cwd(), fn, preferredDirName) {
103
+ ensureMemoryDir(cwd, preferredDirName);
104
+ return withLock(storeLockPath(cwd, preferredDirName), fn);
105
+ }
98
106
  /** Check if a path is a file, or a directory with at least one entry. */
99
107
  function hasContent(p) {
100
108
  try {
package/dist/core/lock.js CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  const DEFAULT_TIMEOUT_MS = 2000;
4
4
  const LOCK_RETRY_INTERVAL_MS = 50;
5
5
  const LOCK_EXPIRY_MS = 5000;
6
+ const heldLocks = new Map();
6
7
  function lockFilePath(targetPath) {
7
8
  return targetPath + '.lock';
8
9
  }
@@ -59,22 +60,37 @@ function tryBreakLock(lockPath) {
59
60
  }
60
61
  export function acquireLock(targetPath, timeoutMs = DEFAULT_TIMEOUT_MS) {
61
62
  const lockPath = lockFilePath(targetPath);
63
+ const heldCount = heldLocks.get(lockPath);
64
+ if (heldCount) {
65
+ heldLocks.set(lockPath, heldCount + 1);
66
+ return true;
67
+ }
62
68
  const deadline = Date.now() + timeoutMs;
63
69
  const dir = path.dirname(lockPath);
64
70
  if (!fs.existsSync(dir)) {
65
71
  fs.mkdirSync(dir, { recursive: true });
66
72
  }
67
73
  while (Date.now() < deadline) {
68
- if (tryCreateLock(lockPath))
74
+ if (tryCreateLock(lockPath)) {
75
+ heldLocks.set(lockPath, 1);
69
76
  return true;
70
- if (tryBreakLock(lockPath))
77
+ }
78
+ if (tryBreakLock(lockPath)) {
79
+ heldLocks.set(lockPath, 1);
71
80
  return true;
81
+ }
72
82
  syncSleep(Math.min(LOCK_RETRY_INTERVAL_MS, deadline - Date.now()));
73
83
  }
74
84
  return false;
75
85
  }
76
86
  export function releaseLock(targetPath) {
77
87
  const lockPath = lockFilePath(targetPath);
88
+ const heldCount = heldLocks.get(lockPath);
89
+ if (heldCount && heldCount > 1) {
90
+ heldLocks.set(lockPath, heldCount - 1);
91
+ return;
92
+ }
93
+ heldLocks.delete(lockPath);
78
94
  try {
79
95
  if (fs.existsSync(lockPath)) {
80
96
  fs.unlinkSync(lockPath);
@@ -2,7 +2,7 @@ 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 } from './io.js';
5
+ import { resolveEntityDir, withStoreLock } from './io.js';
6
6
  import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
7
7
  import { RuntimeNoteSchema } from './schema.js';
8
8
  import { commitMemoryChange } from './memory-git.js';
@@ -40,13 +40,15 @@ export function saveRuntimeNote(note, cwd) {
40
40
  const persistedNote = visibility === 'shared'
41
41
  ? { ...note, visibility, host_id: hostId }
42
42
  : { ...note, visibility, host_id: hostId };
43
- ensureRuntimeDir(note.agent, cwd, visibility, hostId);
44
- const filepath = visibility === 'shared'
45
- ? path.join(sharedAgentDir(note.agent, cwd, 'write'), `${note.id}.json`)
46
- : path.join(hostAgentDir(visibility, hostId, note.agent, cwd, 'write'), `${note.id}.json`);
47
- saveVersionedJsonFile('runtime_note', filepath, RuntimeNoteSchema.parse(persistedNote));
48
- appendEvent({ action: 'create', item_type: 'runtime_note', item_id: note.id, agent: note.agent, agent_id: note.agent_id }, cwd);
49
- commitMemoryChange(`runtime note: ${note.note_type ?? 'note'} (${note.agent})`, cwd);
43
+ withStoreLock(cwd, () => {
44
+ ensureRuntimeDir(note.agent, cwd, visibility, hostId);
45
+ const filepath = visibility === 'shared'
46
+ ? path.join(sharedAgentDir(note.agent, cwd, 'write'), `${note.id}.json`)
47
+ : path.join(hostAgentDir(visibility, hostId, note.agent, cwd, 'write'), `${note.id}.json`);
48
+ saveVersionedJsonFile('runtime_note', filepath, RuntimeNoteSchema.parse(persistedNote));
49
+ appendEvent({ action: 'create', item_type: 'runtime_note', item_id: note.id, agent: note.agent, agent_id: note.agent_id }, cwd);
50
+ commitMemoryChange(`runtime note: ${note.note_type ?? 'note'} (${note.agent})`, cwd);
51
+ });
50
52
  }
51
53
  export function runtimeNotePath(note, cwd) {
52
54
  const visibility = note.visibility ?? 'shared';
@@ -56,12 +58,14 @@ export function runtimeNotePath(note, cwd) {
56
58
  : path.join(hostAgentDir(visibility, hostId, note.agent, cwd), `${note.id}.json`);
57
59
  }
58
60
  export function deleteRuntimeNote(note, cwd) {
59
- const filepath = runtimeNotePath(note, cwd);
60
- if (!fs.existsSync(filepath)) {
61
- return false;
62
- }
63
- fs.unlinkSync(filepath);
64
- return true;
61
+ return withStoreLock(cwd, () => {
62
+ const filepath = runtimeNotePath(note, cwd);
63
+ if (!fs.existsSync(filepath)) {
64
+ return false;
65
+ }
66
+ fs.unlinkSync(filepath);
67
+ return true;
68
+ });
65
69
  }
66
70
  function readAgentNotes(dir, agent) {
67
71
  if (!fs.existsSync(dir))