brainclaw 1.20.4 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/cli/register-cloud.js +63 -0
  3. package/dist/cli.js +2 -3
  4. package/dist/commands/cloud.js +198 -0
  5. package/dist/commands/export.js +3 -3
  6. package/dist/commands/init.js +11 -0
  7. package/dist/commands/mcp-write-claims.js +162 -46
  8. package/dist/commands/mcp-write-entities.js +67 -0
  9. package/dist/commands/mcp.js +64 -1
  10. package/dist/commands/session-end.js +0 -102
  11. package/dist/commands/session-start.js +0 -23
  12. package/dist/commands/switch.js +41 -12
  13. package/dist/core/actions.js +25 -1
  14. package/dist/core/agent-files.js +19 -0
  15. package/dist/core/agentruns.js +68 -10
  16. package/dist/core/assignments.js +94 -19
  17. package/dist/core/claims.js +13 -24
  18. package/dist/core/config.js +58 -0
  19. package/dist/core/context-diff.js +28 -11
  20. package/dist/core/coordination.js +1 -3
  21. package/dist/core/entity-locator.js +404 -0
  22. package/dist/core/federation-attestation.js +96 -0
  23. package/dist/core/federation-canonical.js +95 -0
  24. package/dist/core/federation-hpke.js +213 -0
  25. package/dist/core/federation-inbound.js +187 -0
  26. package/dist/core/federation-keyring.js +241 -0
  27. package/dist/core/federation-message.js +5 -5
  28. package/dist/core/federation-outbox-v2.js +125 -0
  29. package/dist/core/federation-pairing.js +213 -0
  30. package/dist/core/federation-projection.js +336 -0
  31. package/dist/core/federation-relay.js +223 -0
  32. package/dist/core/federation-state.js +270 -0
  33. package/dist/core/identity.js +9 -1
  34. package/dist/core/ids.js +5 -0
  35. package/dist/core/io.js +39 -1
  36. package/dist/core/operations/relocate.js +40 -10
  37. package/dist/core/schema.js +24 -17
  38. package/dist/core/sequence.js +47 -6
  39. package/dist/core/store-resolution.js +99 -26
  40. package/dist/core/workspace-projects.js +23 -2
  41. package/dist/core/worktree.js +59 -1
  42. package/dist/facts.js +7 -7
  43. package/dist/facts.json +6 -6
  44. package/docs/cli.md +73 -40
  45. package/docs/concepts/federation-v2-rfc.md +275 -0
  46. package/docs/index.md +1 -0
  47. package/package.json +2 -2
  48. package/dist/cli/register-federation.js +0 -258
  49. package/dist/core/federation-cloud.js +0 -245
  50. package/dist/core/federation-outbox.js +0 -292
  51. package/dist/core/federation-signing.js +0 -115
@@ -775,6 +775,23 @@ export const AssignmentSchema = z.object({
775
775
  session_id: z.string().optional(),
776
776
  dispatcher_agent: z.string(),
777
777
  dispatcher_session_id: z.string().optional(),
778
+ /**
779
+ * OWNER project (pln#649 step 1, dec#153): the `project_id` of the store this
780
+ * assignment was created in, captured once and never re-derived. Every
781
+ * read/mutation of this assignment must reach THAT store — that is what makes
782
+ * entity-authoritative routing possible instead of ambient resolution.
783
+ * OPTIONAL by design: assignments written before this field existed must stay
784
+ * loadable — a required field would make every pre-existing record fail
785
+ * schema.parse and drop out of the loaded state. An absent owner means "legacy,
786
+ * fall back to current behaviour", never "refuse".
787
+ *
788
+ * Always derived from the store being written to (createAssignment via
789
+ * resolveOwnerProjectId(cwd)); there is deliberately NO caller override. An
790
+ * override let a record be saved in store A while declaring owner B, which the
791
+ * step-4 refusal would then read as a divergence and reject a correctly routed
792
+ * mutation (review P1-1).
793
+ */
794
+ project_id: z.string().optional(),
778
795
  // Task metadata
779
796
  scope: z.string(),
780
797
  description: z.string(),
@@ -839,6 +856,13 @@ export const AgentRunSchema = z.object({
839
856
  agent: z.string(),
840
857
  agent_id: z.string().optional(),
841
858
  session_id: z.string().optional(),
859
+ /**
860
+ * OWNER project — same contract as Assignment.project_id (pln#649 step 1,
861
+ * dec#153): the `project_id` of the store this run was created in, captured
862
+ * once at creation, always derived from the write cwd and never overridable.
863
+ * Optional so pre-existing records stay loadable.
864
+ */
865
+ project_id: z.string().optional(),
842
866
  transport: AgentRunTransportSchema,
843
867
  status: AgentRunStatusSchema,
844
868
  status_reason: z.string().optional(),
@@ -1151,22 +1175,6 @@ export const RemoteSyncSchema = z.object({
1151
1175
  ssh_key_path: z.string().optional(),
1152
1176
  sync_strategy: z.enum(['pull-only', 'push-pull', 'pr-based']).default('push-pull'),
1153
1177
  });
1154
- export const CloudSyncConfigSchema = z.object({
1155
- enabled: z.boolean().default(false),
1156
- endpoint: z.string().default('https://app.brainclaw.dev'),
1157
- api_key: z.string().optional(),
1158
- /** Remote project this bridge federates into (scopes signed runtime writes). */
1159
- project_id: z.string().optional(),
1160
- /** Approved remote agent identity used to sign runtime writes (pln#100). */
1161
- agent_id: z.string().optional(),
1162
- agent_name: z.string().optional(),
1163
- /**
1164
- * Fail-closed toggle: when true, the bridge refuses to push a runtime write
1165
- * unless it can sign it with an approved agent's Ed25519 key. Absent/false
1166
- * keeps existing API-key-only setups working (signing is additive).
1167
- */
1168
- require_signed: z.boolean().optional(),
1169
- });
1170
1178
  export const SessionSnapshotSchema = z.object({
1171
1179
  schema_version: z.number().int().positive().optional(),
1172
1180
  session_id: z.string(),
@@ -1496,7 +1504,6 @@ export const ConfigSchema = z.object({
1496
1504
  target_audience: z.enum(['human', 'agent']).optional().default('human'),
1497
1505
  openclaw_bridge: z.boolean().optional().default(false),
1498
1506
  remote_sync: RemoteSyncSchema.optional(),
1499
- cloud_sync: CloudSyncConfigSchema.optional(),
1500
1507
  telemetry: z.literal(false),
1501
1508
  allow_network: z.literal(false),
1502
1509
  redaction: RedactionConfigSchema,
@@ -1,8 +1,9 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
  import { JsonStore } from './json-store.js';
3
4
  import { mutate } from './mutation-pipeline.js';
4
5
  import { generateIdWithLabel, nowISO } from './ids.js';
5
- import { resolveEntityDir } from './io.js';
6
+ import { entityRecordDirs, resolveEntityDir } from './io.js';
6
7
  import { SequenceItemSchema, SequenceSchema } from './schema.js';
7
8
  import { refreshLiveCompanions } from '../commands/export.js';
8
9
  import { emitRegistryPostImage, emitRegistryTombstone, registryFaultPoint } from './events/registry-post-image.js';
@@ -15,14 +16,17 @@ export function ensureSequencesDir(cwd) {
15
16
  fs.mkdirSync(dir, { recursive: true });
16
17
  }
17
18
  }
18
- function sequenceStore(cwd, mode = 'read') {
19
+ function sequenceStoreForDir(dirPath) {
19
20
  return new JsonStore({
20
- dirPath: sequencesDir(cwd, mode),
21
+ dirPath,
21
22
  documentType: 'sequence',
22
23
  getId: (sequence) => sequence.id,
23
24
  sort: (a, b) => a.updated_at.localeCompare(b.updated_at),
24
25
  });
25
26
  }
27
+ function sequenceStore(cwd, mode = 'read') {
28
+ return sequenceStoreForDir(sequencesDir(cwd, mode));
29
+ }
26
30
  function normalizeItems(items) {
27
31
  return items
28
32
  .map((item) => SequenceItemSchema.parse(item))
@@ -47,6 +51,19 @@ export function saveSequence(sequence, cwd) {
47
51
  emitRegistryPostImage('sequence', parsed, { created, agent: parsed.author, agent_id: parsed.author_id, session_id: parsed.session_id, cwd });
48
52
  registryFaultPoint('after_registry_journal');
49
53
  store.save(parsed);
54
+ // Converge the other layout, as claims/assignments/runs do: a legacy twin holding a
55
+ // stale status is how a "deleted" record comes back.
56
+ const writeDir = sequencesDir(cwd, 'write');
57
+ for (const dirPath of entityRecordDirs('sequences', cwd ?? process.cwd())) {
58
+ if (dirPath === writeDir)
59
+ continue;
60
+ const legacyPath = path.join(dirPath, `${parsed.id}.json`);
61
+ try {
62
+ if (fs.existsSync(legacyPath))
63
+ fs.unlinkSync(legacyPath);
64
+ }
65
+ catch { /* best effort — the dual-layout list keeps it visible */ }
66
+ }
50
67
  // Auto-refresh live companions after sequence changes (non-fatal)
51
68
  try {
52
69
  refreshLiveCompanions(cwd);
@@ -54,8 +71,28 @@ export function saveSequence(sequence, cwd) {
54
71
  catch { /* best-effort */ }
55
72
  });
56
73
  }
74
+ /**
75
+ * BOTH LAYOUTS, canonical winning a duplicate id.
76
+ *
77
+ * FIXED AT THE LIST LAYER ON PURPOSE, and this is the part a "port the by-id loader"
78
+ * instinct gets wrong (Fable audit): `loadSequence` resolves by id OR short_label, and
79
+ * `getActiveSequence` needs the whole set — both are list-mediated. Rewriting
80
+ * `loadSequence` with `entityRecordPaths` would build a path from a short_label, which is
81
+ * not a filesystem key, so it could not work for either caller. One dual-layout list fixes
82
+ * all three readers.
83
+ *
84
+ * Exposure is nil today: sequences postdate the partitioned layout (measured legacy=0), so
85
+ * this is consistency with the sibling entities, not a field fix.
86
+ */
57
87
  export function listSequences(cwd) {
58
- return sequenceStore(cwd).list().sort((a, b) => {
88
+ const byId = new Map();
89
+ for (const dirPath of entityRecordDirs('sequences', cwd ?? process.cwd())) {
90
+ for (const sequence of sequenceStoreForDir(dirPath).list()) {
91
+ if (!byId.has(sequence.id))
92
+ byId.set(sequence.id, sequence);
93
+ }
94
+ }
95
+ return Array.from(byId.values()).sort((a, b) => {
59
96
  const activeBoost = Number(b.status === 'active') - Number(a.status === 'active');
60
97
  if (activeBoost !== 0)
61
98
  return activeBoost;
@@ -115,8 +152,12 @@ export function deleteSequence(id, cwd) {
115
152
  export function updateSequence(input, cwd) {
116
153
  return mutate({ cwd }, () => {
117
154
  ensureSequencesDir(cwd);
118
- const store = sequenceStore(cwd, 'write');
119
- const current = store.list().find((entry) => entry.id === input.id || entry.short_label === input.id);
155
+ // A FOURTH READER, found by the pin for this change rather than by the audit that
156
+ // prompted it: this looked the record up in the WRITE directory only, so a sequence in
157
+ // the legacy layout was invisible to every update — `Sequence not found` on a record
158
+ // `listSequences` and `loadSequence` could both see. Reads come from the dual-layout
159
+ // list; the write still lands canonical via saveSequence, which then converges the twin.
160
+ const current = listSequences(cwd).find((entry) => entry.id === input.id || entry.short_label === input.id);
120
161
  if (!current) {
121
162
  throw new Error(`Sequence not found: ${input.id}`);
122
163
  }
@@ -3,7 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { loadActiveProject } from './active-project.js';
5
5
  import { loadConfig } from './config.js';
6
- import { loadCurrentSession, loadSessionById } from './identity.js';
6
+ import { loadCurrentSession, loadSessionById, resolveExplicitSessionId } from './identity.js';
7
7
  import { MEMORY_DIR } from './io.js';
8
8
  import { summarizeWorkspaceProjects } from './workspace-projects.js';
9
9
  /**
@@ -142,8 +142,42 @@ export function resolveEffectiveCwdInfo(options = {}) {
142
142
  // anchor, the physical baseCwd, and the workspace root for each; the first
143
143
  // session carrying a still-valid active_project wins (anchor first preserves
144
144
  // prior precedence when the session lives where we expect).
145
+ //
146
+ // pln#648 — the probe set must cover EVERY selector that could have been
147
+ // effective when the session file was written, because that is what decided
148
+ // its directory. Two were missing, and both are reachable:
149
+ // - the cwd_child of step 5: an agent in `apps/api/src` writes its session
150
+ // under `apps/api`, which is neither baseCwd nor the workspace root;
151
+ // - the shared global pointer of step 6: a session created while another
152
+ // agent's `switch --global` was in force lands under THAT project.
153
+ // The second one was reproduced end-to-end on 2026-08-03 (/c/tmp/bclaw-mono,
154
+ // v1.20.4): global pointer = web, `bclaw_switch(api)` reported
155
+ // `{scope: session, name: api}` and `switch --json` read it back as api —
156
+ // while the session file itself sat in `apps/web/.brainclaw/sessions/`, unseen
157
+ // here, so every WRITE silently landed in web. Status green, data in the wrong
158
+ // project: the worst failure mode a shared memory can have. The switch handler
159
+ // finds the record (it resolves cwd first, landing on web, then reads the
160
+ // session there); this resolver did not. Same file, two verdicts.
161
+ // Both new candidates are lazy + memoized and are shared with steps 5/6. When
162
+ // an earlier probe hits, neither helper runs and the cost is unchanged; after
163
+ // all three miss, the pointer probe does add one session read before returning
164
+ // the same `global` answer (review P3-5 — the claim is bounded to the hit path).
165
+ //
166
+ // STRONG IDENTITY REQUIRED ON THE ADDED CANDIDATES (review P1-1/P1-2, both
167
+ // scenarios reproduced by the reviewer). `loadCurrentSession` can return a
168
+ // record this process does not own: the pidless candidate it adopts on
169
+ // agent+user alone (identity.ts ~145 — agent_id and host_id are NOT compared),
170
+ // and the legacy `.current-session` fallback (identity.ts ~158), returned with
171
+ // no agent/user/pid/TTL check whatsoever. Honouring those from a store the
172
+ // agent never named would let ANOTHER instance's stale intent outrank F1/F2
173
+ // physical-child isolation — a behavioural expansion, not a bug fix. So the two
174
+ // NEW candidates accept a session only on strong identity: an explicitly named
175
+ // session id (argument or env — exact-file lookup), or a record whose pid is
176
+ // this very process. The three original candidates keep their historical
177
+ // behaviour untouched: this restriction narrows only what the fix added.
178
+ const explicitSessionId = options.sessionId ?? resolveExplicitSessionId();
145
179
  const probedSessionCwds = new Set();
146
- const probeSessionAt = (candidate) => {
180
+ const probeSessionAt = (candidate, opts) => {
147
181
  if (!candidate)
148
182
  return undefined;
149
183
  const probeCwd = path.resolve(candidate);
@@ -153,19 +187,63 @@ export function resolveEffectiveCwdInfo(options = {}) {
153
187
  const session = options.sessionId
154
188
  ? loadSessionById(options.sessionId, probeCwd)
155
189
  : loadCurrentSession(probeCwd);
156
- const sp = session?.active_project;
190
+ if (!session)
191
+ return undefined;
192
+ // A named id is an exact-file lookup, so the record IS the one asked for; the
193
+ // pid check covers the unnamed case. Anything else is a weak adoption.
194
+ if (opts?.requireStrongIdentity && !explicitSessionId && session.pid !== process.pid) {
195
+ return undefined;
196
+ }
197
+ const sp = session.active_project;
157
198
  if (sp && fs.existsSync(path.join(sp.path, MEMORY_DIR, 'config.yaml'))) {
158
199
  return { cwd: sp.path, active_source: 'session', resolved_project: { path: sp.path, name: sp.name } };
159
200
  }
160
201
  return undefined;
161
202
  };
203
+ // The step-5 child and the step-6 shared pointer, each resolved AT MOST ONCE
204
+ // and shared with the step that owns it — so adding them to the probe set costs
205
+ // no extra disk work, and a probe can never disagree with the step it mirrors.
206
+ let cwdChildResolved = false;
207
+ let cwdChildValue;
208
+ const cwdChildCandidate = () => {
209
+ if (!cwdChildResolved) {
210
+ cwdChildResolved = true;
211
+ // Anchored (step 5): ceiling = the anchor. Unanchored (step 5b, F2
212
+ // trp_71accb07): ceiling = the discovered workspace root — never homedir.
213
+ const ceiling = hasEnvWorkspace ? anchorCwd : resolveWorkspaceRoot(baseCwd, options.storeChainOptions);
214
+ if (ceiling && baseCwd !== path.resolve(ceiling) && isAtOrBelow(baseCwd, ceiling)) {
215
+ const child = findClosestStoreBelow(baseCwd, ceiling);
216
+ // findClosestStoreBelow walks up EXCLUSIVELY of the ceiling, so a
217
+ // single-project repo can never yield its own root store here.
218
+ if (child && path.resolve(child) !== path.resolve(ceiling))
219
+ cwdChildValue = child;
220
+ }
221
+ }
222
+ return cwdChildValue;
223
+ };
224
+ let globalPointerResolved = false;
225
+ let globalPointerValue;
226
+ const globalPointerCandidate = () => {
227
+ if (!globalPointerResolved) {
228
+ globalPointerResolved = true;
229
+ const wsRoot = hasEnvWorkspace ? anchorCwd : resolveWorkspaceRoot(anchorCwd, options.storeChainOptions);
230
+ const active = wsRoot ? loadActiveProject(wsRoot) : undefined;
231
+ if (active && fs.existsSync(path.join(active.path, MEMORY_DIR, 'config.yaml'))) {
232
+ globalPointerValue = { path: active.path, name: active.name };
233
+ }
234
+ }
235
+ return globalPointerValue;
236
+ };
162
237
  // `??` short-circuits: the common case (agent at the anchor, session there)
163
238
  // costs exactly one session load and never walks for the workspace root. The
164
- // baseCwd / workspace-root probes only run when the cheap ones miss — i.e. the
165
- // monorepo case where the session was stored under the physical child.
239
+ // later probes only run when the cheap ones miss — i.e. the monorepo case where
240
+ // the session was stored under whichever project a PREVIOUS resolution picked
241
+ // (physical child, or the shared pointer — pln#648).
166
242
  const sessionHit = probeSessionAt(anchorCwd)
167
243
  ?? probeSessionAt(baseCwd)
168
- ?? probeSessionAt(resolveWorkspaceRoot(baseCwd, options.storeChainOptions));
244
+ ?? probeSessionAt(resolveWorkspaceRoot(baseCwd, options.storeChainOptions))
245
+ ?? probeSessionAt(cwdChildCandidate(), { requireStrongIdentity: true })
246
+ ?? probeSessionAt(globalPointerCandidate()?.path, { requireStrongIdentity: true });
169
247
  if (sessionHit)
170
248
  return sessionHit;
171
249
  // 5. cwd_child — when anchored and the agent is physically inside a child store
@@ -178,11 +256,13 @@ export function resolveEffectiveCwdInfo(options = {}) {
178
256
  // at/below it. `findClosestStoreBelow` walks UP to the ceiling but does NOT prove
179
257
  // baseCwd sits below it — without the `isAtOrBelow` guard a baseCwd OUTSIDE the
180
258
  // anchor could match an unrelated `.brainclaw` before hitting the filesystem root.
181
- if (baseCwd !== anchorCwd && isAtOrBelow(baseCwd, anchorCwd)) {
182
- const child = findClosestStoreBelow(baseCwd, anchorCwd);
183
- if (child && path.resolve(child) !== path.resolve(anchorCwd)) {
184
- return { cwd: child, active_source: 'cwd_child', resolved_project: projectInfo(child) };
185
- }
259
+ //
260
+ // pln#648: the candidate itself now comes from cwdChildCandidate() so this
261
+ // step and the session probe above cannot drift apart. Both anchored (5) and
262
+ // unanchored (5b) cases live in that one helper.
263
+ const cwdChild = cwdChildCandidate();
264
+ if (cwdChild) {
265
+ return { cwd: cwdChild, active_source: 'cwd_child', resolved_project: projectInfo(cwdChild) };
186
266
  }
187
267
  // 5b. cwd_child (NO anchor) — F2 [trp_71accb07]: even without a BRAINCLAW_CWD
188
268
  // anchor, an agent physically inside a child project must resolve THAT
@@ -194,22 +274,15 @@ export function resolveEffectiveCwdInfo(options = {}) {
194
274
  // For a single-project repo this is a strict no-op: findClosestStoreBelow
195
275
  // walks UP to the ceiling EXCLUSIVELY, so it can never return the lone root
196
276
  // store (Codex cadrage non-regression proof, batch 2).
197
- if (!hasEnvWorkspace) {
198
- const physicalRoot = resolveWorkspaceRoot(baseCwd, options.storeChainOptions);
199
- if (physicalRoot && isAtOrBelow(baseCwd, physicalRoot)) {
200
- const child = findClosestStoreBelow(baseCwd, physicalRoot);
201
- if (child && path.resolve(child) !== path.resolve(physicalRoot)) {
202
- return { cwd: child, active_source: 'cwd_child', resolved_project: projectInfo(child) };
203
- }
204
- }
205
- }
277
+ // pln#648: both cases are now the single `cwdChildCandidate()` above.
206
278
  // 6. Global active-project.json from workspace root
207
- const wsRoot = hasEnvWorkspace ? anchorCwd : resolveWorkspaceRoot(anchorCwd, options.storeChainOptions);
208
- if (wsRoot) {
209
- const active = loadActiveProject(wsRoot);
210
- if (active && fs.existsSync(path.join(active.path, MEMORY_DIR, 'config.yaml'))) {
211
- return { cwd: active.path, active_source: 'global', resolved_project: { path: active.path, name: active.name } };
212
- }
279
+ const globalPointer = globalPointerCandidate();
280
+ if (globalPointer) {
281
+ return {
282
+ cwd: globalPointer.path,
283
+ active_source: 'global',
284
+ resolved_project: { path: globalPointer.path, name: globalPointer.name },
285
+ };
213
286
  }
214
287
  // 7. Default
215
288
  return { cwd: anchorCwd, active_source: 'cwd', resolved_project: projectInfo(anchorCwd) };
@@ -68,11 +68,25 @@ export function summarizeWorkspaceProjects(cwd, config) {
68
68
  uses_folder_resolution: usesFolderResolution,
69
69
  };
70
70
  }
71
- export function scanNestedBrainclawProjects(rootDir, maxDepth = 6) {
71
+ /**
72
+ * pln#649 step 3 review P1-1 — the scan plus whether it was TRUNCATED.
73
+ *
74
+ * A caller that turns "no project found" into a refusal must be able to tell that
75
+ * apart from "I stopped looking". Inferring it from the results is not enough, and
76
+ * that mistake was reproduced: with a store at `root/d1/…/d7` and nothing in
77
+ * `d1…d6`, the walk cut the branch without ever returning a project near the
78
+ * ceiling, so a heuristic based on the deepest RESULT reported completeness while
79
+ * the target sat one level below the cut. Truncation is a property of the WALK, so
80
+ * only the walk can report it.
81
+ */
82
+ export function scanNestedBrainclawProjectsDetailed(rootDir, maxDepth = 6) {
72
83
  const resolvedRoot = path.resolve(rootDir);
73
84
  const results = new Map();
85
+ let truncated = false;
74
86
  function walk(dir, depth) {
75
87
  if (depth > maxDepth) {
88
+ // A branch we were asked to descend is being cut: anything below is unseen.
89
+ truncated = true;
76
90
  return;
77
91
  }
78
92
  let entries;
@@ -98,7 +112,14 @@ export function scanNestedBrainclawProjects(rootDir, maxDepth = 6) {
98
112
  }
99
113
  }
100
114
  walk(resolvedRoot, 1);
101
- return [...results.values()].sort((a, b) => a.path.localeCompare(b.path));
115
+ return {
116
+ projects: [...results.values()].sort((a, b) => a.path.localeCompare(b.path)),
117
+ truncated,
118
+ };
119
+ }
120
+ /** Backward-compatible projection: the projects only, for every existing caller. */
121
+ export function scanNestedBrainclawProjects(rootDir, maxDepth = 6) {
122
+ return scanNestedBrainclawProjectsDetailed(rootDir, maxDepth).projects;
102
123
  }
103
124
  function collectRegistryProjectsUnder(rootDir) {
104
125
  const registry = loadGlobalRegistry();
@@ -1365,8 +1365,35 @@ export function removeWorktree(mainWorktreePath, worktreePath, options = {}) {
1365
1365
  if (fs.existsSync(worktreePath)) {
1366
1366
  detachWorktreeJunctions(worktreePath);
1367
1367
  }
1368
+ // pln#647 — PROTOCOL DEBRIS MUST NOT BLOCK AN EXPLICIT REMOVE.
1369
+ //
1370
+ // The bulk `worktree clean` path already knows that a heartbeat, a LANE-RESULT or
1371
+ // a copied .gitignore are brainclaw's OWN artifacts and not user work
1372
+ // (worktreeHasOnlyBirthNoise). This path did not, so `git worktree remove` refused
1373
+ // with "contains modified or untracked files" over a file brainclaw itself wrote
1374
+ // and never gitignores. Observed 2026-08-04 in brainclaw's own repo: two of three
1375
+ // worker worktrees refused removal, blocked by a lone
1376
+ // `.brainclaw-heartbeat-<asgn>`. That is not cosmetic — a worktree surviving its
1377
+ // lane is what makes a re-dispatch on the same loop scope collide
1378
+ // (`spawn_no_worktree`), and the retry then wedges the scope with a claim that has
1379
+ // no worktree (trp#72b4e9b3, whose documented recovery begins by deleting exactly
1380
+ // this heartbeat by hand).
1381
+ //
1382
+ // So: consult the SAME predicate the clean path uses. Only brainclaw's own noise is
1383
+ // forced through; a worktree holding real user work still refuses, and the caller
1384
+ // must pass force explicitly.
1368
1385
  const args = ['worktree', 'remove', worktreePath];
1369
- if (options.force)
1386
+ let force = options.force === true;
1387
+ if (!force && fs.existsSync(worktreePath)) {
1388
+ const status = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=normal'], worktreePath);
1389
+ // `isAutoForcibleDebris`, NOT `worktreeHasOnlyBirthNoise`: the latter also
1390
+ // forgives agent-config dirs, which is safe for gating and post-merge gc but not
1391
+ // for a destruction with no merge gate (review of pln#647).
1392
+ if (status.ok && status.stdout.trim().length > 0 && isAutoForcibleDebris(status.stdout)) {
1393
+ force = true;
1394
+ }
1395
+ }
1396
+ if (force)
1370
1397
  args.push('--force');
1371
1398
  const result = runGit(args, mainWorktreePath);
1372
1399
  if (!result.ok) {
@@ -1415,6 +1442,37 @@ export function worktreeHasOnlyBirthNoise(statusZStdout) {
1415
1442
  || isSystemDirtyPath(norm);
1416
1443
  });
1417
1444
  }
1445
+ /**
1446
+ * The ONLY paths an explicit `worktree remove` may force through on its own.
1447
+ *
1448
+ * NARROWER THAN `worktreeHasOnlyBirthNoise` ON PURPOSE (review of pln#647).
1449
+ * That predicate folds in `isSystemDirtyPath`, which classes agent-config dirs
1450
+ * (`.claude/`, `.cursor/`, `.codex/`) as noise — calibrated for two jobs that destroy
1451
+ * nothing: dispatch GATING, and the POST-MERGE gc where the content is already
1452
+ * integrated. Reusing it for a removal with NO merge gate would let
1453
+ * `brainclaw worktree remove`, without `--force`, silently delete an agent's
1454
+ * uncommitted `.claude/agents/x.md` or a modified tracked `.claude/settings.json`.
1455
+ *
1456
+ * So this lists brainclaw's OWN protocol artifacts by name and nothing else. A
1457
+ * worktree holding anything else — including agent config — still refuses and needs
1458
+ * an explicit force, which is what the pln#647 commit claimed and did not deliver.
1459
+ */
1460
+ export function isAutoForcibleDebris(statusZStdout) {
1461
+ const paths = parsePorcelainZ(statusZStdout);
1462
+ if (paths.length === 0)
1463
+ return false;
1464
+ return paths.every((p) => {
1465
+ const norm = p.replace(/\\/g, '/');
1466
+ return norm === '.gitignore' // copied at birth; CRLF-flipped on Windows
1467
+ || norm === '.brainclaw-worktree.json' // sidecar metadata
1468
+ || norm === 'LANE-RESULT.json' // worker outcome report — harvested
1469
+ || norm === 'REVIEW-FINDINGS.md'
1470
+ || norm === 'REVIEW_FINDINGS.md'
1471
+ || norm === 'TRIAGE-REPORT.json'
1472
+ || norm.startsWith('.brainclaw-heartbeat-') // worker liveness sentinel
1473
+ || norm === '.brainclaw' || norm.startsWith('.brainclaw/'); // coordination store
1474
+ });
1475
+ }
1418
1476
  /**
1419
1477
  * trp#926 (squash-aware GC) — True when every commit on `branch` that is not
1420
1478
  * an ancestor of `baseRef` has a patch-equivalent commit ALREADY on `baseRef`.
package/dist/facts.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // Generated by scripts/emit-site-facts.mjs at build time. Do not edit manually.
2
- // Source: brainclaw v1.20.4 on 2026-08-03T17:19:03.147Z
2
+ // Source: brainclaw v1.22.0 on 2026-08-08T16:03:19.158Z
3
3
  export const FACTS = {
4
- "version": "1.20.4",
5
- "generated_at": "2026-08-03T17:19:03.147Z",
4
+ "version": "1.22.0",
5
+ "generated_at": "2026-08-08T16:03:19.158Z",
6
6
  "tools": {
7
7
  "count": 67,
8
8
  "published_count": 65,
@@ -474,7 +474,7 @@ export const FACTS = {
474
474
  },
475
475
  "bench": {
476
476
  "schema": "brainclaw.bench.v1",
477
- "generated_at": "2026-08-03T17:19:00.995Z",
477
+ "generated_at": "2026-08-08T16:03:17.056Z",
478
478
  "node_version": "v24.18.0",
479
479
  "platform": "linux-x64",
480
480
  "repeats": 3,
@@ -483,7 +483,7 @@ export const FACTS = {
483
483
  "name": "cold_onboard",
484
484
  "volume": "empty",
485
485
  "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
486
- "duration_ms_median": 77,
486
+ "duration_ms_median": 73,
487
487
  "payload_chars_median": 1640,
488
488
  "payload_tokens_est_median": 410
489
489
  },
@@ -491,7 +491,7 @@ export const FACTS = {
491
491
  "name": "warm_work",
492
492
  "volume": "medium",
493
493
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
494
- "duration_ms_median": 130,
494
+ "duration_ms_median": 128,
495
495
  "payload_chars_median": 2626,
496
496
  "payload_tokens_est_median": 657
497
497
  },
@@ -499,7 +499,7 @@ export const FACTS = {
499
499
  "name": "first_edit",
500
500
  "volume": "medium",
501
501
  "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
502
- "duration_ms_median": 12,
502
+ "duration_ms_median": 14,
503
503
  "payload_chars_median": 499,
504
504
  "payload_tokens_est_median": 125
505
505
  }
package/dist/facts.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.20.4",
3
- "generated_at": "2026-08-03T17:19:03.147Z",
2
+ "version": "1.22.0",
3
+ "generated_at": "2026-08-08T16:03:19.158Z",
4
4
  "tools": {
5
5
  "count": 67,
6
6
  "published_count": 65,
@@ -472,7 +472,7 @@
472
472
  },
473
473
  "bench": {
474
474
  "schema": "brainclaw.bench.v1",
475
- "generated_at": "2026-08-03T17:19:00.995Z",
475
+ "generated_at": "2026-08-08T16:03:17.056Z",
476
476
  "node_version": "v24.18.0",
477
477
  "platform": "linux-x64",
478
478
  "repeats": 3,
@@ -481,7 +481,7 @@
481
481
  "name": "cold_onboard",
482
482
  "volume": "empty",
483
483
  "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
484
- "duration_ms_median": 77,
484
+ "duration_ms_median": 73,
485
485
  "payload_chars_median": 1640,
486
486
  "payload_tokens_est_median": 410
487
487
  },
@@ -489,7 +489,7 @@
489
489
  "name": "warm_work",
490
490
  "volume": "medium",
491
491
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
492
- "duration_ms_median": 130,
492
+ "duration_ms_median": 128,
493
493
  "payload_chars_median": 2626,
494
494
  "payload_tokens_est_median": 657
495
495
  },
@@ -497,7 +497,7 @@
497
497
  "name": "first_edit",
498
498
  "volume": "medium",
499
499
  "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
500
- "duration_ms_median": 12,
500
+ "duration_ms_median": 14,
501
501
  "payload_chars_median": 499,
502
502
  "payload_tokens_est_median": 125
503
503
  }