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.
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-cloud.js +63 -0
- package/dist/cli.js +2 -3
- package/dist/commands/cloud.js +198 -0
- package/dist/commands/export.js +3 -3
- package/dist/commands/init.js +11 -0
- package/dist/commands/mcp-write-claims.js +162 -46
- package/dist/commands/mcp-write-entities.js +67 -0
- package/dist/commands/mcp.js +64 -1
- package/dist/commands/session-end.js +0 -102
- package/dist/commands/session-start.js +0 -23
- package/dist/commands/switch.js +41 -12
- package/dist/core/actions.js +25 -1
- package/dist/core/agent-files.js +19 -0
- package/dist/core/agentruns.js +68 -10
- package/dist/core/assignments.js +94 -19
- package/dist/core/claims.js +13 -24
- package/dist/core/config.js +58 -0
- package/dist/core/context-diff.js +28 -11
- package/dist/core/coordination.js +1 -3
- package/dist/core/entity-locator.js +404 -0
- package/dist/core/federation-attestation.js +96 -0
- package/dist/core/federation-canonical.js +95 -0
- package/dist/core/federation-hpke.js +213 -0
- package/dist/core/federation-inbound.js +187 -0
- package/dist/core/federation-keyring.js +241 -0
- package/dist/core/federation-message.js +5 -5
- package/dist/core/federation-outbox-v2.js +125 -0
- package/dist/core/federation-pairing.js +213 -0
- package/dist/core/federation-projection.js +336 -0
- package/dist/core/federation-relay.js +223 -0
- package/dist/core/federation-state.js +270 -0
- package/dist/core/identity.js +9 -1
- package/dist/core/ids.js +5 -0
- package/dist/core/io.js +39 -1
- package/dist/core/operations/relocate.js +40 -10
- package/dist/core/schema.js +24 -17
- package/dist/core/sequence.js +47 -6
- package/dist/core/store-resolution.js +99 -26
- package/dist/core/workspace-projects.js +23 -2
- package/dist/core/worktree.js +59 -1
- package/dist/facts.js +7 -7
- package/dist/facts.json +6 -6
- package/docs/cli.md +73 -40
- package/docs/concepts/federation-v2-rfc.md +275 -0
- package/docs/index.md +1 -0
- package/package.json +2 -2
- package/dist/cli/register-federation.js +0 -258
- package/dist/core/federation-cloud.js +0 -245
- package/dist/core/federation-outbox.js +0 -292
- package/dist/core/federation-signing.js +0 -115
package/dist/core/schema.js
CHANGED
|
@@ -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,
|
package/dist/core/sequence.js
CHANGED
|
@@ -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
|
|
19
|
+
function sequenceStoreForDir(dirPath) {
|
|
19
20
|
return new JsonStore({
|
|
20
|
-
dirPath
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
165
|
-
//
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
|
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();
|
package/dist/core/worktree.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
2
|
+
// Source: brainclaw v1.22.0 on 2026-08-08T16:03:19.158Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.
|
|
5
|
-
"generated_at": "2026-08-
|
|
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-
|
|
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":
|
|
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":
|
|
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":
|
|
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.
|
|
3
|
-
"generated_at": "2026-08-
|
|
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-
|
|
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":
|
|
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":
|
|
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":
|
|
500
|
+
"duration_ms_median": 14,
|
|
501
501
|
"payload_chars_median": 499,
|
|
502
502
|
"payload_tokens_est_median": 125
|
|
503
503
|
}
|