brainclaw 1.8.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/README.md +592 -505
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +138 -13
  4. package/dist/commands/add-step.js +1 -1
  5. package/dist/commands/bootstrap.js +2 -26
  6. package/dist/commands/check-security-mcp.js +50 -33
  7. package/dist/commands/check-security.js +86 -43
  8. package/dist/commands/claim.js +22 -21
  9. package/dist/commands/confirm.js +26 -0
  10. package/dist/commands/context-diff.js +1 -1
  11. package/dist/commands/dispatch-watch.js +142 -0
  12. package/dist/commands/doctor.js +113 -2
  13. package/dist/commands/estimation-report.js +115 -16
  14. package/dist/commands/harvest.js +286 -23
  15. package/dist/commands/hooks.js +73 -73
  16. package/dist/commands/init.js +124 -22
  17. package/dist/commands/install-hooks.js +78 -78
  18. package/dist/commands/loops-handlers.js +4 -0
  19. package/dist/commands/mcp-read-handlers.js +253 -41
  20. package/dist/commands/mcp.js +664 -102
  21. package/dist/commands/memory.js +21 -17
  22. package/dist/commands/migrate.js +81 -17
  23. package/dist/commands/prune.js +78 -4
  24. package/dist/commands/reflect.js +26 -20
  25. package/dist/commands/register-agent.js +57 -1
  26. package/dist/commands/repair.js +20 -0
  27. package/dist/commands/session-end.js +15 -6
  28. package/dist/commands/session-start.js +18 -1
  29. package/dist/commands/setup-security.js +39 -18
  30. package/dist/commands/setup.js +26 -27
  31. package/dist/commands/stale.js +16 -2
  32. package/dist/commands/switch.js +26 -5
  33. package/dist/commands/uninstall.js +126 -34
  34. package/dist/commands/update-step.js +6 -0
  35. package/dist/commands/version.js +1 -1
  36. package/dist/commands/worktree.js +60 -0
  37. package/dist/core/actions.js +12 -3
  38. package/dist/core/agent-capability.js +30 -17
  39. package/dist/core/agent-files.js +963 -666
  40. package/dist/core/agent-integrations.js +0 -3
  41. package/dist/core/agent-inventory.js +67 -0
  42. package/dist/core/agent-registry.js +163 -29
  43. package/dist/core/agentrun-reconciler.js +33 -2
  44. package/dist/core/agentruns.js +7 -1
  45. package/dist/core/ai-agent-detection.js +31 -44
  46. package/dist/core/archival.js +15 -9
  47. package/dist/core/assignment-reconciler.js +56 -0
  48. package/dist/core/assignment-sweeper.js +127 -4
  49. package/dist/core/assignments.js +69 -11
  50. package/dist/core/bootstrap.js +233 -67
  51. package/dist/core/brainclaw-version.js +22 -0
  52. package/dist/core/candidates.js +21 -1
  53. package/dist/core/claims.js +313 -150
  54. package/dist/core/codev-prompts.js +38 -38
  55. package/dist/core/config.js +6 -1
  56. package/dist/core/context-diff.js +148 -20
  57. package/dist/core/context.js +129 -8
  58. package/dist/core/coordination.js +22 -3
  59. package/dist/core/default-profiles/doctor.yaml +11 -11
  60. package/dist/core/default-profiles/janitor.yaml +11 -11
  61. package/dist/core/default-profiles/onboarder.yaml +11 -11
  62. package/dist/core/default-profiles/reviewer.yaml +13 -13
  63. package/dist/core/dispatch-status.js +79 -5
  64. package/dist/core/dispatcher.js +65 -12
  65. package/dist/core/entity-operations.js +74 -27
  66. package/dist/core/entity-registry.js +31 -5
  67. package/dist/core/event-log.js +138 -21
  68. package/dist/core/events/checkpoint.js +258 -0
  69. package/dist/core/events/genesis.js +220 -0
  70. package/dist/core/events/journal.js +507 -0
  71. package/dist/core/events/materialize.js +126 -0
  72. package/dist/core/events/registry-post-image.js +110 -0
  73. package/dist/core/events/verify.js +109 -0
  74. package/dist/core/execution-adapters.js +23 -0
  75. package/dist/core/execution.js +1 -1
  76. package/dist/core/facade-schema.js +38 -0
  77. package/dist/core/gc-semantic.js +130 -5
  78. package/dist/core/handoff-snapshot.js +68 -0
  79. package/dist/core/ids.js +19 -8
  80. package/dist/core/instruction-templates.js +34 -115
  81. package/dist/core/io.js +39 -3
  82. package/dist/core/json-store.js +10 -1
  83. package/dist/core/lock.js +153 -28
  84. package/dist/core/loops/bootstrap-acquire.js +25 -1
  85. package/dist/core/loops/facade-schema.js +2 -0
  86. package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
  87. package/dist/core/loops/index.js +1 -0
  88. package/dist/core/loops/presets/bootstrap.js +7 -0
  89. package/dist/core/loops/store.js +17 -0
  90. package/dist/core/loops/verbs.js +24 -2
  91. package/dist/core/markdown.js +8 -76
  92. package/dist/core/mcp-command-resolution.js +245 -0
  93. package/dist/core/memory-compactor.js +5 -3
  94. package/dist/core/memory-lifecycle.js +282 -0
  95. package/dist/core/merge-risk.js +150 -0
  96. package/dist/core/messaging.js +10 -3
  97. package/dist/core/migration.js +11 -1
  98. package/dist/core/observer-mode.js +26 -0
  99. package/dist/core/operations/memory-mutation.js +90 -65
  100. package/dist/core/operations/plan.js +27 -1
  101. package/dist/core/protocol-skills.js +210 -0
  102. package/dist/core/reflection-safety.js +6 -7
  103. package/dist/core/reputation.js +84 -2
  104. package/dist/core/runtime-signals.js +72 -10
  105. package/dist/core/runtime.js +84 -1
  106. package/dist/core/schema.js +114 -0
  107. package/dist/core/search.js +19 -2
  108. package/dist/core/security-detectors.js +125 -0
  109. package/dist/core/security-extract.js +189 -0
  110. package/dist/core/security-guard.js +217 -139
  111. package/dist/core/security-packages.js +121 -0
  112. package/dist/core/security-scoring.js +76 -9
  113. package/dist/core/security.js +34 -2
  114. package/dist/core/sequence.js +11 -2
  115. package/dist/core/setup-flow.js +141 -13
  116. package/dist/core/spawn-check.js +16 -2
  117. package/dist/core/staleness.js +73 -2
  118. package/dist/core/state.js +250 -54
  119. package/dist/core/store-resolution.js +45 -12
  120. package/dist/core/worktree.js +90 -26
  121. package/dist/facts.js +8 -8
  122. package/dist/facts.json +7 -7
  123. package/docs/PROTOCOL.md +223 -0
  124. package/docs/adapters/openclaw.md +43 -43
  125. package/docs/architecture/project-refs.md +328 -328
  126. package/docs/cli.md +2097 -2096
  127. package/docs/concepts/coordination.md +52 -52
  128. package/docs/concepts/coordinator-runbook.md +129 -0
  129. package/docs/concepts/dispatch-lifecycle.md +245 -245
  130. package/docs/concepts/event-log-store.md +928 -0
  131. package/docs/concepts/ideation-loop.md +317 -317
  132. package/docs/concepts/loop-engine.md +520 -511
  133. package/docs/concepts/mcp-governance.md +268 -268
  134. package/docs/concepts/memory.md +89 -88
  135. package/docs/concepts/multi-agent-workflows.md +167 -167
  136. package/docs/concepts/observer-protocol.md +361 -0
  137. package/docs/concepts/parallel-merge-protocol.md +71 -0
  138. package/docs/concepts/plans-and-claims.md +217 -174
  139. package/docs/concepts/project-md-convention.md +35 -35
  140. package/docs/concepts/runtime-notes.md +38 -38
  141. package/docs/concepts/skills.md +78 -0
  142. package/docs/concepts/troubleshooting.md +254 -254
  143. package/docs/concepts/workspace-bootstrapping.md +142 -81
  144. package/docs/context-format-changelog.md +35 -35
  145. package/docs/context-format.md +48 -48
  146. package/docs/index.md +65 -65
  147. package/docs/integrations/agents.md +162 -162
  148. package/docs/integrations/claude-code.md +23 -23
  149. package/docs/integrations/cline.md +87 -88
  150. package/docs/integrations/codex.md +2 -2
  151. package/docs/integrations/continue.md +60 -60
  152. package/docs/integrations/copilot.md +82 -80
  153. package/docs/integrations/cursor.md +23 -23
  154. package/docs/integrations/kilocode.md +72 -72
  155. package/docs/integrations/mcp.md +377 -377
  156. package/docs/integrations/mistral-vibe.md +122 -122
  157. package/docs/integrations/openclaw.md +99 -98
  158. package/docs/integrations/opencode.md +84 -84
  159. package/docs/integrations/overview.md +122 -122
  160. package/docs/integrations/roo.md +74 -74
  161. package/docs/integrations/windsurf.md +83 -83
  162. package/docs/mcp-schema-changelog.md +360 -329
  163. package/docs/playbooks/integration/index.md +121 -121
  164. package/docs/playbooks/orchestration.md +37 -0
  165. package/docs/playbooks/productivity/index.md +99 -99
  166. package/docs/playbooks/team/index.md +117 -117
  167. package/docs/product/agent-first-model.md +184 -184
  168. package/docs/product/entity-model-audit.md +462 -462
  169. package/docs/product/positioning.md +86 -86
  170. package/docs/quickstart-existing-project.md +107 -107
  171. package/docs/quickstart.md +148 -147
  172. package/docs/release-maintenance.md +79 -79
  173. package/docs/reputation.md +52 -52
  174. package/docs/review.md +45 -45
  175. package/docs/security.md +212 -53
  176. package/docs/server-operations.md +118 -118
  177. package/docs/storage.md +110 -108
  178. package/package.json +86 -69
@@ -1,17 +1,19 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
+ import path from 'node:path';
3
4
  import { ClaimSchema } from './schema.js';
4
5
  import { resolveEntityDir } from './io.js';
5
6
  import { mutate } from './mutation-pipeline.js';
6
7
  import { nowISO } from './ids.js';
7
8
  import { JsonStore } from './json-store.js';
8
9
  import { loadConfig } from './config.js';
9
- import { createWorktree, resetWorktreeToRef } from './worktree.js';
10
+ import { createWorktree, resetWorktreeToRef, removeWorktree, sanitizeBranchComponent } from './worktree.js';
10
11
  import { appendAuditEntry } from './audit.js';
11
12
  import { refreshLiveCompanions } from '../commands/export.js';
12
13
  import { loadSessionById } from './identity.js';
13
14
  import { loadState, persistState } from './state.js';
14
15
  import { createRuntimeEvent } from './events.js';
16
+ import { emitRegistryPostImage, registryFaultPoint } from './events/registry-post-image.js';
15
17
  /** Parse duration string like '4h', '30m' to ms. */
16
18
  function parseTtl(value) {
17
19
  const match = /^(\d+)([mhd])$/i.exec(value.trim());
@@ -28,35 +30,75 @@ function parseTtl(value) {
28
30
  function claimsDir(cwd, mode = 'read') {
29
31
  return resolveEntityDir('claims', cwd ?? process.cwd(), mode);
30
32
  }
33
+ function claimDirs(cwd) {
34
+ const effectiveCwd = cwd ?? process.cwd();
35
+ return Array.from(new Set([
36
+ claimsDir(effectiveCwd, 'write'),
37
+ claimsDir(effectiveCwd, 'read'),
38
+ ]));
39
+ }
31
40
  export function ensureClaimsDir(cwd) {
32
41
  const dir = claimsDir(cwd, 'write');
33
42
  if (!fs.existsSync(dir)) {
34
43
  fs.mkdirSync(dir, { recursive: true });
35
44
  }
36
45
  }
37
- function claimStore(cwd) {
46
+ function claimStoreForDir(dirPath) {
38
47
  return new JsonStore({
39
- dirPath: claimsDir(cwd, 'read'),
48
+ dirPath,
40
49
  documentType: 'claim',
41
50
  getId: (claim) => claim.id,
42
51
  sort: (a, b) => a.created_at.localeCompare(b.created_at),
43
52
  });
44
53
  }
45
- export function saveClaim(claim, cwd) {
46
- mutate({ cwd }, () => {
47
- ensureClaimsDir(cwd);
48
- const writeStore = new JsonStore({
49
- dirPath: claimsDir(cwd, 'write'),
50
- documentType: 'claim',
51
- getId: (c) => c.id,
52
- sort: (a, b) => a.created_at.localeCompare(b.created_at),
53
- });
54
- writeStore.save(ClaimSchema.parse(claim));
55
- // Auto-refresh live companions after claim changes (non-fatal)
54
+ function writeClaimStore(cwd) {
55
+ return claimStoreForDir(claimsDir(cwd, 'write'));
56
+ }
57
+ function loadClaimFromAnyDir(id, cwd) {
58
+ for (const dirPath of claimDirs(cwd)) {
59
+ const store = claimStoreForDir(dirPath);
60
+ if (store.exists(id))
61
+ return store.load(id);
62
+ }
63
+ throw new Error(`claim '${id}' not found`);
64
+ }
65
+ function saveClaimUnlocked(claim, cwd, options) {
66
+ ensureClaimsDir(cwd);
67
+ const store = writeClaimStore(cwd);
68
+ const parsed = ClaimSchema.parse(claim);
69
+ // pln#568 (I2): journal the post-image BEFORE the projection write, so a
70
+ // crash can only leave the journal ahead of the projection, never behind.
71
+ const created = !store.exists(parsed.id);
72
+ emitRegistryPostImage('claim', parsed, { created, agent: parsed.agent, agent_id: parsed.agent_id, session_id: parsed.session_id, cwd });
73
+ registryFaultPoint('after_registry_journal');
74
+ store.save(parsed);
75
+ const writeDir = claimsDir(cwd, 'write');
76
+ for (const dirPath of claimDirs(cwd)) {
77
+ if (dirPath === writeDir)
78
+ continue;
79
+ const legacyPath = path.join(dirPath, `${claim.id}.json`);
80
+ try {
81
+ if (fs.existsSync(legacyPath))
82
+ fs.unlinkSync(legacyPath);
83
+ }
84
+ catch {
85
+ // Best effort: listClaims() reads both dirs, so a missed cleanup remains visible.
86
+ }
87
+ }
88
+ // Auto-refresh live companions after claim changes (non-fatal). Sweep loops
89
+ // pass refreshCompanions:false and refresh ONCE after the loop — review
90
+ // follow-up O5: a per-save refresh inside the critical section compounded an
91
+ // O(store) cost on every iteration.
92
+ if (options?.refreshCompanions !== false) {
56
93
  try {
57
94
  refreshLiveCompanions(cwd);
58
95
  }
59
96
  catch { /* best-effort */ }
97
+ }
98
+ }
99
+ export function saveClaim(claim, cwd) {
100
+ mutate({ cwd }, () => {
101
+ saveClaimUnlocked(claim, cwd);
60
102
  });
61
103
  }
62
104
  /**
@@ -84,22 +126,63 @@ export function acquireClaimScope(input, cwd) {
84
126
  plan_id: input.plan_id,
85
127
  model: input.model,
86
128
  };
87
- saveClaim(claim, cwd);
129
+ saveClaimUnlocked(claim, cwd);
88
130
  return { acquired: true, claim };
89
131
  });
90
132
  }
91
133
  export function loadClaim(id, cwd) {
92
- return claimStore(cwd).load(id);
134
+ return loadClaimFromAnyDir(id, cwd);
93
135
  }
94
136
  export function listClaims(cwd) {
95
- return claimStore(cwd).list();
137
+ const byId = new Map();
138
+ for (const dirPath of claimDirs(cwd)) {
139
+ for (const claim of claimStoreForDir(dirPath).list()) {
140
+ if (!byId.has(claim.id))
141
+ byId.set(claim.id, claim);
142
+ }
143
+ }
144
+ return Array.from(byId.values()).sort((a, b) => a.created_at.localeCompare(b.created_at));
96
145
  }
97
- export function releaseClaim(id, cwd) {
98
- const claim = loadClaim(id, cwd);
99
- claim.status = 'released';
100
- claim.released_at = nowISO();
101
- saveClaim(claim, cwd);
102
- return claim;
146
+ function assertReleaseOwnership(claim, auth) {
147
+ if (!auth)
148
+ return { overrideUsed: false };
149
+ const ownerMatches = (auth.session_id !== undefined && claim.session_id !== undefined && auth.session_id === claim.session_id)
150
+ || (auth.agent_id !== undefined && claim.agent_id !== undefined && auth.agent_id === claim.agent_id)
151
+ || (auth.agent !== undefined && claim.agent === auth.agent);
152
+ if (ownerMatches)
153
+ return { overrideUsed: false };
154
+ if (auth.override)
155
+ return { overrideUsed: true };
156
+ throw new Error(`claim '${claim.id}' is held by '${claim.agent}'${claim.session_id ? ` (session ${claim.session_id})` : ''}; `
157
+ + `caller '${auth.agent ?? auth.agent_id ?? auth.session_id ?? 'unknown'}' does not own it. `
158
+ + 'Coordinator-level callers may release with override.');
159
+ }
160
+ function auditReleaseOverride(claim, auth, cwd) {
161
+ appendAuditEntry({
162
+ actor: auth.agent ?? 'coordinator',
163
+ actor_id: auth.agent_id,
164
+ action: 'release_claim',
165
+ item_id: claim.id,
166
+ item_type: 'claim',
167
+ scope: claim.scope,
168
+ session_id: auth.session_id,
169
+ after: { ownership_override: true, claim_owner: claim.agent },
170
+ }, cwd);
171
+ }
172
+ export function releaseClaim(id, cwd, auth) {
173
+ let overrideUsed = false;
174
+ const released = mutate({ cwd }, () => {
175
+ const claim = loadClaim(id, cwd);
176
+ overrideUsed = assertReleaseOwnership(claim, auth).overrideUsed;
177
+ claim.status = 'released';
178
+ claim.released_at = nowISO();
179
+ saveClaimUnlocked(claim, cwd);
180
+ return claim;
181
+ });
182
+ if (overrideUsed && auth) {
183
+ auditReleaseOverride(released, auth, cwd);
184
+ }
185
+ return released;
103
186
  }
104
187
  /**
105
188
  * Release a claim and optionally cascade the status to its linked plan.
@@ -113,83 +196,95 @@ export function releaseClaim(id, cwd) {
113
196
  * Emits `plan_cascade_to_done` runtime event when auto-transitioning to done.
114
197
  */
115
198
  export function releaseClaimWithCascade(id, options = {}) {
116
- const { planStatus, cwd } = options;
117
- // Release the claim (idempotent: already-released claims are returned as-is)
118
- const claim = loadClaim(id, cwd);
119
- if (claim.status === 'released') {
199
+ const { planStatus, cwd, auth } = options;
200
+ let overrideUsed = false;
201
+ const result = mutate({ cwd }, () => {
202
+ // Release the claim (idempotent: already-released claims are returned as-is)
203
+ const claim = loadClaim(id, cwd);
204
+ if (claim.status === 'released') {
205
+ return { claim, planTransitioned: false };
206
+ }
207
+ overrideUsed = assertReleaseOwnership(claim, auth).overrideUsed;
208
+ claim.status = 'released';
209
+ claim.released_at = nowISO();
210
+ saveClaimUnlocked(claim, cwd);
211
+ // No cascade requested or no linked plan
212
+ if (!planStatus || !claim.plan_id) {
213
+ return { claim, planTransitioned: false };
214
+ }
215
+ const state = loadState(cwd);
216
+ const plan = state.plan_items.find((item) => item.id === claim.plan_id);
217
+ if (!plan) {
218
+ return { claim, planTransitioned: false };
219
+ }
220
+ const ts = nowISO();
221
+ if (planStatus === 'blocked') {
222
+ // Always propagate blocked status to plan
223
+ plan.status = 'blocked';
224
+ plan.updated_at = ts;
225
+ persistState(state, cwd);
226
+ return { claim, planTransitioned: true, planId: plan.id, newPlanStatus: 'blocked' };
227
+ }
228
+ if (planStatus === 'done') {
229
+ // Count OTHER active claims on the same plan (current claim already released above)
230
+ const otherActive = listClaims(cwd).filter((c) => c.status === 'active' && c.plan_id === claim.plan_id && c.id !== id);
231
+ if (otherActive.length > 0) {
232
+ const planWarning = `Plan has ${otherActive.length} other active claim(s); staying in_progress`;
233
+ return {
234
+ claim,
235
+ planTransitioned: false,
236
+ planWarning,
237
+ planId: plan.id,
238
+ newPlanStatus: plan.status,
239
+ otherActiveClaimsCount: otherActive.length,
240
+ };
241
+ }
242
+ // Last active claim released → auto-transition plan to done
243
+ plan.status = 'done';
244
+ if (!plan.completed_at)
245
+ plan.completed_at = ts;
246
+ plan.updated_at = ts;
247
+ persistState(state, cwd);
248
+ return { claim, planTransitioned: true, planId: plan.id, newPlanStatus: 'done' };
249
+ }
250
+ // planStatus='todo', 'in_progress', or other — no cascade
120
251
  return { claim, planTransitioned: false };
121
- }
122
- claim.status = 'released';
123
- claim.released_at = nowISO();
124
- saveClaim(claim, cwd);
252
+ });
125
253
  appendAuditEntry({
126
- actor: claim.agent,
127
- actor_id: claim.agent_id,
254
+ actor: result.claim.agent,
255
+ actor_id: result.claim.agent_id,
128
256
  action: 'release_claim',
129
257
  item_id: id,
130
258
  item_type: 'claim',
131
- scope: claim.scope,
132
- session_id: claim.session_id,
133
- host_id: claim.host_id,
259
+ scope: result.claim.scope,
260
+ session_id: result.claim.session_id,
261
+ host_id: result.claim.host_id,
134
262
  }, cwd);
135
- // No cascade requested or no linked plan
136
- if (!planStatus || !claim.plan_id) {
137
- return { claim, planTransitioned: false };
263
+ if (overrideUsed && auth) {
264
+ auditReleaseOverride(result.claim, auth, cwd);
138
265
  }
139
- const state = loadState(cwd);
140
- const plan = state.plan_items.find((item) => item.id === claim.plan_id);
141
- if (!plan) {
142
- return { claim, planTransitioned: false };
143
- }
144
- const ts = nowISO();
145
- if (planStatus === 'blocked') {
146
- // Always propagate blocked status to plan
147
- plan.status = 'blocked';
148
- plan.updated_at = ts;
149
- persistState(state, cwd);
150
- return { claim, planTransitioned: true, planId: plan.id, newPlanStatus: 'blocked' };
266
+ if (result.planWarning && result.planId) {
267
+ appendAuditEntry({
268
+ actor: result.claim.agent,
269
+ action: 'update',
270
+ item_id: result.planId,
271
+ item_type: 'plan',
272
+ after: { cascade_blocked: true, reason: result.planWarning },
273
+ }, cwd);
151
274
  }
152
- if (planStatus === 'done') {
153
- // Count OTHER active claims on the same plan (current claim already released above)
154
- const otherActive = listClaims(cwd).filter((c) => c.status === 'active' && c.plan_id === claim.plan_id && c.id !== id);
155
- if (otherActive.length > 0) {
156
- const planWarning = `Plan has ${otherActive.length} other active claim(s); staying in_progress`;
157
- appendAuditEntry({
158
- actor: claim.agent,
159
- action: 'update',
160
- item_id: plan.id,
161
- item_type: 'plan',
162
- after: { cascade_blocked: true, reason: planWarning },
163
- }, cwd);
164
- return {
165
- claim,
166
- planTransitioned: false,
167
- planWarning,
168
- planId: plan.id,
169
- newPlanStatus: plan.status,
170
- otherActiveClaimsCount: otherActive.length,
171
- };
172
- }
173
- // Last active claim released → auto-transition plan to done
174
- plan.status = 'done';
175
- if (!plan.completed_at)
176
- plan.completed_at = ts;
177
- plan.updated_at = ts;
178
- persistState(state, cwd);
275
+ if (result.newPlanStatus === 'done' && result.planId) {
179
276
  createRuntimeEvent({
180
- agent: claim.agent,
181
- agent_id: claim.agent_id,
277
+ agent: result.claim.agent,
278
+ agent_id: result.claim.agent_id,
182
279
  event_type: 'plan_cascade_to_done',
183
280
  claim_id: id,
184
- plan_id: plan.id,
185
- session_id: claim.session_id,
186
- host_id: claim.host_id,
187
- text: `Plan ${plan.id} auto-transitioned to done — last active claim released by ${claim.agent}`,
281
+ plan_id: result.planId,
282
+ session_id: result.claim.session_id,
283
+ host_id: result.claim.host_id,
284
+ text: `Plan ${result.planId} auto-transitioned to done — last active claim released by ${result.claim.agent}`,
188
285
  }, cwd);
189
- return { claim, planTransitioned: true, planId: plan.id, newPlanStatus: 'done' };
190
286
  }
191
- // planStatus='todo', 'in_progress', or other — no cascade
192
- return { claim, planTransitioned: false };
287
+ return result;
193
288
  }
194
289
  export function generateClaimId() {
195
290
  const rand = crypto.randomBytes(4).toString('hex');
@@ -202,19 +297,26 @@ export function isClaimExpired(claim) {
202
297
  }
203
298
  /** Mark active claims past their expires_at as released. Returns count of expired claims. */
204
299
  export function expireStaleActiveClaims(cwd) {
205
- const store = claimStore(cwd);
206
- const all = store.list();
207
- let count = 0;
208
- const now = nowISO();
209
- for (const claim of all) {
210
- if (claim.status === 'active' && isClaimExpired(claim)) {
211
- claim.status = 'released';
212
- claim.released_at = now;
213
- store.save(claim);
214
- count++;
300
+ return mutate({ cwd }, () => {
301
+ const all = listClaims(cwd);
302
+ let count = 0;
303
+ const now = nowISO();
304
+ for (const claim of all) {
305
+ if (claim.status === 'active' && isClaimExpired(claim)) {
306
+ claim.status = 'released';
307
+ claim.released_at = now;
308
+ saveClaimUnlocked(claim, cwd, { refreshCompanions: false });
309
+ count++;
310
+ }
215
311
  }
216
- }
217
- return count;
312
+ if (count > 0) {
313
+ try {
314
+ refreshLiveCompanions(cwd);
315
+ }
316
+ catch { /* best-effort */ }
317
+ }
318
+ return count;
319
+ });
218
320
  }
219
321
  /** Default stale threshold: 24 hours. */
220
322
  const DEFAULT_STALE_HOURS = 24;
@@ -369,33 +471,40 @@ export function isClaimStale(claim, thresholdHours, cwd) {
369
471
  export function releaseStaleClaimsFromOtherAgents(currentAgent, cwd, currentSessionId) {
370
472
  const config = loadConfig(cwd);
371
473
  const thresholdHours = config.claims?.auto_release_after_hours ?? DEFAULT_STALE_HOURS;
372
- const store = claimStore(cwd);
373
- const all = store.list();
374
- const now = nowISO();
375
- const released = [];
376
- const warned = [];
377
- for (const claim of all) {
378
- if (claim.status !== 'active')
379
- continue;
380
- // Session-aware skip: if the caller names its current session, only that
381
- // session's claims are off-limits. Otherwise fall back to the legacy
382
- // "skip same agent" rule.
383
- if (currentSessionId) {
384
- if (claim.session_id === currentSessionId)
474
+ return mutate({ cwd }, () => {
475
+ const all = listClaims(cwd);
476
+ const now = nowISO();
477
+ const released = [];
478
+ const warned = [];
479
+ for (const claim of all) {
480
+ if (claim.status !== 'active')
481
+ continue;
482
+ // Session-aware skip: if the caller names its current session, only that
483
+ // session's claims are off-limits. Otherwise fall back to the legacy
484
+ // "skip same agent" rule.
485
+ if (currentSessionId) {
486
+ if (claim.session_id === currentSessionId)
487
+ continue;
488
+ }
489
+ else if (claim.agent === currentAgent) {
490
+ continue;
491
+ }
492
+ const { status } = assessClaimLiveness(claim, { thresholdHours, cwd });
493
+ if (status === 'live' || status === 'young')
385
494
  continue;
495
+ claim.status = 'released';
496
+ claim.released_at = now;
497
+ saveClaimUnlocked(claim, cwd, { refreshCompanions: false });
498
+ released.push(claim);
386
499
  }
387
- else if (claim.agent === currentAgent) {
388
- continue;
500
+ if (released.length > 0) {
501
+ try {
502
+ refreshLiveCompanions(cwd);
503
+ }
504
+ catch { /* best-effort */ }
389
505
  }
390
- const { status } = assessClaimLiveness(claim, { thresholdHours, cwd });
391
- if (status === 'live' || status === 'young')
392
- continue;
393
- claim.status = 'released';
394
- claim.released_at = now;
395
- store.save(claim);
396
- released.push(claim);
397
- }
398
- return { released, warned };
506
+ return { released, warned };
507
+ });
399
508
  }
400
509
  /**
401
510
  * Create a coordinator-owned claim with worktree isolation.
@@ -445,8 +554,11 @@ export function createCoordinatorClaim(options) {
445
554
  const claimId = generateClaimId();
446
555
  let worktreePath;
447
556
  let worktreeWarning;
448
- // Create isolated worktree (matching bclaw_claim MCP handler behavior)
449
- const branchSlug = options.scope.replace(/[^a-zA-Z0-9._-]/g, '-').replace(/-+/g, '-').slice(0, 48);
557
+ // Create isolated worktree (matching bclaw_claim MCP handler behavior).
558
+ // can_45316d5c: the slug must be a valid git ref component — scopes like
559
+ // `.github/workflows` previously produced `feat/.github-…` (leading dot),
560
+ // which git rejects and the whole spawn failed.
561
+ const branchSlug = sanitizeBranchComponent(options.scope);
450
562
  const worktreeBranch = `feat/${branchSlug}`;
451
563
  try {
452
564
  worktreePath = createWorktree(options.cwd, worktreeBranch, {
@@ -463,24 +575,58 @@ export function createCoordinatorClaim(options) {
463
575
  catch (err) {
464
576
  worktreeWarning = `Worktree creation failed: ${err instanceof Error ? err.message : String(err)}`;
465
577
  }
466
- saveClaim({
467
- id: claimId,
468
- agent: options.agent,
469
- scope: options.scope,
470
- description: options.description,
471
- plan_id: options.planId,
472
- created_at: nowISO(),
473
- status: 'active',
474
- worktree_path: worktreePath,
475
- }, options.cwd);
476
- appendAuditEntry({
477
- actor: options.dispatcherAgent,
478
- action: 'claim',
479
- item_id: claimId,
480
- item_type: 'claim',
481
- scope: options.scope,
482
- }, options.cwd);
483
- return { claimId, worktreePath, worktreeWarning, reusedExisting: false };
578
+ const result = mutate({ cwd: options.cwd }, () => {
579
+ const racedScopeClaim = listClaims(options.cwd).find((claim) => claim.status === 'active' && claim.scope === options.scope);
580
+ if (racedScopeClaim) {
581
+ if (racedScopeClaim.agent === options.agent) {
582
+ return {
583
+ claimId: racedScopeClaim.id,
584
+ worktreePath: racedScopeClaim.worktree_path,
585
+ worktreeWarning,
586
+ reusedExisting: true,
587
+ };
588
+ }
589
+ return {
590
+ claimId: racedScopeClaim.id,
591
+ worktreePath: racedScopeClaim.worktree_path,
592
+ reusedExisting: true,
593
+ scopeConflict: true,
594
+ conflictAgent: racedScopeClaim.agent,
595
+ };
596
+ }
597
+ saveClaimUnlocked({
598
+ id: claimId,
599
+ agent: options.agent,
600
+ scope: options.scope,
601
+ description: options.description,
602
+ plan_id: options.planId,
603
+ created_at: nowISO(),
604
+ status: 'active',
605
+ worktree_path: worktreePath,
606
+ }, options.cwd);
607
+ return { claimId, worktreePath, worktreeWarning, reusedExisting: false };
608
+ });
609
+ // Review follow-up O1 (lop_e2d566765b8b4ce3): when the in-lock re-check finds
610
+ // a raced claim, the worktree created moments earlier (outside the lock) is
611
+ // orphaned — nobody would ever remove it. Decision: delete it (it is seconds
612
+ // old and contains only birth artifacts; a reuse-pool is not worth the
613
+ // bookkeeping). Best-effort and outside the critical section.
614
+ if (result.reusedExisting && worktreePath && worktreePath !== result.worktreePath) {
615
+ try {
616
+ removeWorktree(options.cwd, worktreePath, { force: true });
617
+ }
618
+ catch { /* best-effort GC — a leftover dir is caught by worktree clean */ }
619
+ }
620
+ if (!result.reusedExisting && !result.scopeConflict) {
621
+ appendAuditEntry({
622
+ actor: options.dispatcherAgent,
623
+ action: 'claim',
624
+ item_id: result.claimId,
625
+ item_type: 'claim',
626
+ scope: options.scope,
627
+ }, options.cwd);
628
+ }
629
+ return result;
484
630
  }
485
631
  // ── Claim lifecycle helpers for multi-instance dispatch ────
486
632
  /**
@@ -488,15 +634,32 @@ export function createCoordinatorClaim(options) {
488
634
  * Called by the dispatcher after sending the inbox message.
489
635
  */
490
636
  export function attachAssignmentMessageToClaim(claimId, messageId, cwd) {
491
- const claim = loadClaim(claimId, cwd);
492
- claim.assignment_message_id = messageId;
493
- saveClaim(claim, cwd);
637
+ mutate({ cwd }, () => {
638
+ const claim = loadClaim(claimId, cwd);
639
+ claim.assignment_message_id = messageId;
640
+ saveClaimUnlocked(claim, cwd);
641
+ });
642
+ }
643
+ /**
644
+ * sprint 1.5 — patch a claim's worktree_path so a coordinator can register a
645
+ * manually created worktree (or correct a stale path) without hand-editing the
646
+ * store. Surfaced through bclaw_update(entity="claim", patch={worktree_path}).
647
+ */
648
+ export function patchClaimWorktreePath(claimId, worktreePath, cwd) {
649
+ return mutate({ cwd }, () => {
650
+ const claim = loadClaim(claimId, cwd);
651
+ claim.worktree_path = worktreePath;
652
+ saveClaimUnlocked(claim, cwd);
653
+ return claim;
654
+ });
494
655
  }
495
656
  /** Link a claim to its Assignment entity (Agent SDK runtime protocol). */
496
657
  export function linkClaimToAssignment(claimId, assignmentId, cwd) {
497
- const claim = loadClaim(claimId, cwd);
498
- claim.assignment_id = assignmentId;
499
- saveClaim(claim, cwd);
658
+ mutate({ cwd }, () => {
659
+ const claim = loadClaim(claimId, cwd);
660
+ claim.assignment_id = assignmentId;
661
+ saveClaimUnlocked(claim, cwd);
662
+ });
500
663
  }
501
664
  /**
502
665
  * Adopt a claim from a spawned instance's session.
@@ -538,7 +701,7 @@ export function adoptClaimSession(claimId, sessionId, cwd) {
538
701
  }
539
702
  claim.session_id = sessionId;
540
703
  claim.adopted_at = nowISO();
541
- saveClaim(claim, cwd);
704
+ saveClaimUnlocked(claim, cwd);
542
705
  return { adopted: true, reason: 'ok' };
543
706
  });
544
707
  }