brainclaw 1.10.2 → 1.11.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.
- package/README.md +21 -0
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +17 -1
- package/dist/commands/code-map.js +17 -2
- package/dist/commands/context-diff.js +31 -0
- package/dist/commands/doctor.js +23 -1
- package/dist/commands/mcp.js +48 -6
- package/dist/commands/move.js +35 -0
- package/dist/commands/session-end.js +8 -1
- package/dist/commands/session-start.js +8 -1
- package/dist/commands/setup.js +80 -22
- package/dist/core/agent-files.js +93 -3
- package/dist/core/agent-registry.js +16 -1
- package/dist/core/code-map/backend.js +76 -15
- package/dist/core/code-map/cascade.js +116 -0
- package/dist/core/code-map/refresh.js +0 -0
- package/dist/core/hook-log.js +43 -0
- package/dist/core/instruction-templates.js +1 -1
- package/dist/core/loops/store.js +31 -1
- package/dist/core/operations/relocate.js +130 -0
- package/dist/core/store-resolution.js +52 -8
- package/dist/core/worktree.js +86 -0
- package/dist/facts.js +7 -6
- package/dist/facts.json +6 -5
- package/docs/cli.md +26 -4
- package/docs/code-map.md +28 -4
- package/docs/concepts/dispatch-lifecycle.md +26 -0
- package/docs/integrations/mcp.md +2 -0
- package/docs/mcp-schema-changelog.md +27 -3
- package/package.json +1 -1
package/dist/core/agent-files.js
CHANGED
|
@@ -1095,6 +1095,98 @@ function replaceBrainclawHooks(entries, canonical) {
|
|
|
1095
1095
|
kept.push(canonical);
|
|
1096
1096
|
return kept;
|
|
1097
1097
|
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Canonical Claude Code session-hook commands.
|
|
1100
|
+
*
|
|
1101
|
+
* `--hook` (pln#596) makes session-start / context-diff / session-end degrade to
|
|
1102
|
+
* exit 0 + ~/.brainclaw/hook.log on failure instead of hard-erroring the prompt
|
|
1103
|
+
* loop (trp#917). `2>/dev/null` is kept to suppress incidental stderr noise —
|
|
1104
|
+
* the actionable diagnostic now goes to hook.log, not stderr, so it survives the
|
|
1105
|
+
* redirect (the bug was the non-zero EXIT, not the stream).
|
|
1106
|
+
*/
|
|
1107
|
+
function buildClaudeCodeHookCommands(bclawBin) {
|
|
1108
|
+
return {
|
|
1109
|
+
session: `f=.claude/.bclaw-session; if [ ! -f "$f" ]; then touch "$f"; ${bclawBin} session-start --include-context --hook 2>/dev/null; else ${bclawBin} context-diff --hook 2>/dev/null; fi`,
|
|
1110
|
+
stop: `rm -f .claude/.bclaw-session; ${bclawBin} session-end --auto-release --reflect --reflect-handoff --dispatch-review --hook 2>/dev/null`,
|
|
1111
|
+
checkEvents: `${bclawBin} check-events 2>/dev/null`,
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Sanitize the brainclaw session hooks in ONE Claude Code settings file: collapse
|
|
1116
|
+
* every recognized brainclaw hook (across UserPromptSubmit / Stop / PostToolUse)
|
|
1117
|
+
* to a single canonical entry, repairing stale/broken forms (e.g. the legacy
|
|
1118
|
+
* `node session-start` with the cli.js arg dropped, or dead install paths).
|
|
1119
|
+
*
|
|
1120
|
+
* Only touches events that ALREADY contain a brainclaw hook — it never injects
|
|
1121
|
+
* hooks into a file (or event) that lacked them, so user-scope settings without
|
|
1122
|
+
* brainclaw hooks stay untouched. This is the cross-scope counterpart to
|
|
1123
|
+
* `ensureClaudeCodeSettings`, which only rewrites the cwd project file (pln#596 /
|
|
1124
|
+
* trp#918: setup's git-repo discovery never reaches the launch dir or user scope,
|
|
1125
|
+
* so broken hooks accumulate exactly where the agent executes them).
|
|
1126
|
+
*/
|
|
1127
|
+
export function fixClaudeCodeHooksInFile(filePath) {
|
|
1128
|
+
if (!fs.existsSync(filePath))
|
|
1129
|
+
return { filePath, existed: false, changed: false, collapsed: 0 };
|
|
1130
|
+
const existing = readJsonObject(filePath); // returns {} for missing, undefined for unparseable
|
|
1131
|
+
if (existing === undefined)
|
|
1132
|
+
return { filePath, existed: true, changed: false, collapsed: 0 };
|
|
1133
|
+
const hooksObj = isJsonObject(existing.hooks) ? existing.hooks : undefined;
|
|
1134
|
+
if (!hooksObj)
|
|
1135
|
+
return { filePath, existed: true, changed: false, collapsed: 0 };
|
|
1136
|
+
const bclawBin = getBclawCliParts().map(quoteShellArg).join(' ');
|
|
1137
|
+
const cmds = buildClaudeCodeHookCommands(bclawBin);
|
|
1138
|
+
const countBrainclawHooks = (arr) => Array.isArray(arr)
|
|
1139
|
+
? arr.reduce((n, e) => n + (isJsonObject(e) && Array.isArray(e.hooks)
|
|
1140
|
+
? e.hooks.filter((h) => isJsonObject(h) && typeof h.command === 'string' && isBrainclawHookCommand(h.command)).length
|
|
1141
|
+
: 0), 0)
|
|
1142
|
+
: 0;
|
|
1143
|
+
const events = [
|
|
1144
|
+
['UserPromptSubmit', buildCommandHookEntry(cmds.session)],
|
|
1145
|
+
['Stop', buildCommandHookEntry(cmds.stop)],
|
|
1146
|
+
['PostToolUse', buildMatchedCommandHookEntry('mcp__brainclaw__', cmds.checkEvents)],
|
|
1147
|
+
];
|
|
1148
|
+
const nextHooks = { ...hooksObj };
|
|
1149
|
+
let collapsed = 0;
|
|
1150
|
+
let touched = false;
|
|
1151
|
+
for (const [event, canonical] of events) {
|
|
1152
|
+
const arr = Array.isArray(hooksObj[event]) ? hooksObj[event] : undefined;
|
|
1153
|
+
if (!arr)
|
|
1154
|
+
continue;
|
|
1155
|
+
const before = countBrainclawHooks(arr);
|
|
1156
|
+
if (before === 0)
|
|
1157
|
+
continue; // never add hooks to an event that had none
|
|
1158
|
+
nextHooks[event] = replaceBrainclawHooks(arr, canonical);
|
|
1159
|
+
collapsed += Math.max(0, before - 1); // N brainclaw entries → 1 canonical
|
|
1160
|
+
touched = true;
|
|
1161
|
+
}
|
|
1162
|
+
if (!touched)
|
|
1163
|
+
return { filePath, existed: true, changed: false, collapsed: 0 };
|
|
1164
|
+
const { updated } = writeJsonFileIfChanged(filePath, { ...existing, hooks: nextHooks });
|
|
1165
|
+
return { filePath, existed: true, changed: updated, collapsed };
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Run `fixClaudeCodeHooksInFile` across every Claude Code settings scope that can
|
|
1169
|
+
* carry brainclaw hooks: user-scope (`~/.claude/settings*.json`) and the cwd
|
|
1170
|
+
* project (`<cwd>/.claude/settings*.json`). Independent of git-repo discovery.
|
|
1171
|
+
*/
|
|
1172
|
+
export function fixClaudeCodeHooksAllScopes(cwd, homeDir) {
|
|
1173
|
+
const candidates = [
|
|
1174
|
+
path.join(homeDir, '.claude', 'settings.json'),
|
|
1175
|
+
path.join(homeDir, '.claude', 'settings.local.json'),
|
|
1176
|
+
path.join(cwd, '.claude', 'settings.json'),
|
|
1177
|
+
path.join(cwd, '.claude', 'settings.local.json'),
|
|
1178
|
+
];
|
|
1179
|
+
const seen = new Set();
|
|
1180
|
+
const results = [];
|
|
1181
|
+
for (const candidate of candidates) {
|
|
1182
|
+
const resolved = path.resolve(candidate);
|
|
1183
|
+
if (seen.has(resolved))
|
|
1184
|
+
continue;
|
|
1185
|
+
seen.add(resolved);
|
|
1186
|
+
results.push(fixClaudeCodeHooksInFile(resolved));
|
|
1187
|
+
}
|
|
1188
|
+
return results;
|
|
1189
|
+
}
|
|
1098
1190
|
export function ensureProjectDevDependency(cwd) {
|
|
1099
1191
|
const filePath = path.join(cwd, 'package.json');
|
|
1100
1192
|
if (!fs.existsSync(filePath))
|
|
@@ -1242,10 +1334,8 @@ export function ensureClaudeCodeSettings(cwd) {
|
|
|
1242
1334
|
// binary resolution succeeded (hidden by 2>/dev/null).
|
|
1243
1335
|
const hooks = isJsonObject(existing.hooks) ? { ...existing.hooks } : {};
|
|
1244
1336
|
const bclawBin = getBclawCliParts().map(quoteShellArg).join(' ');
|
|
1245
|
-
const sessionCommand = `f=.claude/.bclaw-session; if [ ! -f "$f" ]; then touch "$f"; ${bclawBin} session-start --include-context 2>/dev/null; else ${bclawBin} context-diff 2>/dev/null; fi`;
|
|
1246
|
-
const stopCommand = `rm -f .claude/.bclaw-session; ${bclawBin} session-end --auto-release --reflect --reflect-handoff --dispatch-review 2>/dev/null`;
|
|
1247
1337
|
// PostToolUse — check for unseen events after any brainclaw MCP tool call
|
|
1248
|
-
const checkEventsCommand =
|
|
1338
|
+
const { session: sessionCommand, stop: stopCommand, checkEvents: checkEventsCommand } = buildClaudeCodeHookCommands(bclawBin);
|
|
1249
1339
|
hooks.UserPromptSubmit = replaceBrainclawHooks(Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : [], buildCommandHookEntry(sessionCommand));
|
|
1250
1340
|
hooks.Stop = replaceBrainclawHooks(Array.isArray(hooks.Stop) ? hooks.Stop : [], buildCommandHookEntry(stopCommand));
|
|
1251
1341
|
hooks.PostToolUse = replaceBrainclawHooks(Array.isArray(hooks.PostToolUse) ? hooks.PostToolUse : [], buildMatchedCommandHookEntry('mcp__brainclaw__', checkEventsCommand));
|
|
@@ -314,6 +314,18 @@ export function resolveCurrentAgentIdentity(cwd, preferredDirName, _homeDir) {
|
|
|
314
314
|
// In multi-agent setups this always resolves to the wrong agent.
|
|
315
315
|
// The field remains in config for display (status, doctor) and for resolveExistingCurrentAgent
|
|
316
316
|
// which is used during setup/init only.
|
|
317
|
+
// Single-registered-agent fallback (pln#596). When there is NO identity signal
|
|
318
|
+
// at all — no env id/name AND no detected native agent — but exactly one agent
|
|
319
|
+
// is registered in this scope, resolving it is unambiguous. This is the
|
|
320
|
+
// solo-dev / single-CLI case (a fresh hook with no BRAINCLAW_AGENT_NAME). The
|
|
321
|
+
// pln#562 guard against config.current_agent only matters at ≥2 agents, so this
|
|
322
|
+
// preserves multi-agent safety: ≥2 registered → still falls through to undefined.
|
|
323
|
+
if (!envAgentId && !envAgentName && !detected) {
|
|
324
|
+
const registered = listAgentIdentities(cwd, preferredDirName);
|
|
325
|
+
if (registered.length === 1) {
|
|
326
|
+
return registered[0];
|
|
327
|
+
}
|
|
328
|
+
}
|
|
317
329
|
return undefined;
|
|
318
330
|
}
|
|
319
331
|
export function resolveRegisteredAgentIdentity(options = {}) {
|
|
@@ -416,7 +428,10 @@ export function requireRegisteredAgentIdentity(options = {}) {
|
|
|
416
428
|
throw new AgentIdentityResolutionError(`Environment agent '${normalizedEnv}' is not registered.`, { agent_name: normalizedEnv });
|
|
417
429
|
}
|
|
418
430
|
}
|
|
419
|
-
throw new AgentIdentityResolutionError('No registered agent identity resolved.
|
|
431
|
+
throw new AgentIdentityResolutionError('No registered agent identity resolved. Pass `--agent <name>` or `--agent-id <id>`, '
|
|
432
|
+
+ 'set $BRAINCLAW_AGENT_NAME, or register an agent with `brainclaw register-agent <name>` '
|
|
433
|
+
+ '— a single registered agent is then resolved automatically. '
|
|
434
|
+
+ '(`--set-current` alone does NOT affect resolution.)');
|
|
420
435
|
}
|
|
421
436
|
/**
|
|
422
437
|
* Resolve agent identity for session start, returning both the resolved identity and whether
|
|
@@ -15,6 +15,8 @@ import { refresh as runRefresh } from './refresh.js';
|
|
|
15
15
|
import { applyGitHeadDrift } from './freshness.js';
|
|
16
16
|
import { brief as runBrief, find as runFind } from './query.js';
|
|
17
17
|
import { defaultMemoryReader } from './memory-reader.js';
|
|
18
|
+
import { listNestedProjects, refreshWorkspaceCascade } from './cascade.js';
|
|
19
|
+
import { loadConfig } from '../config.js';
|
|
18
20
|
/** spec §9 caps the brief reading list at 12 files. */
|
|
19
21
|
export const BRIEF_FILE_CAP = 12;
|
|
20
22
|
function badge(status, details = {}) {
|
|
@@ -45,6 +47,30 @@ function readCurrentGitHead(root) {
|
|
|
45
47
|
return null;
|
|
46
48
|
}
|
|
47
49
|
}
|
|
50
|
+
/** True when `cwd` is a multi-project workspace (gates the cascade paths). */
|
|
51
|
+
function isMultiProjectWorkspace(cwd) {
|
|
52
|
+
try {
|
|
53
|
+
return loadConfig(cwd).project_mode === 'multi-project';
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Per-child store recap for `status(cascade)` in a multi-project workspace. */
|
|
60
|
+
function buildCascadeStatus(rootCwd) {
|
|
61
|
+
const root = rootCwd ?? process.cwd();
|
|
62
|
+
const children = listNestedProjects(root).map((abs) => {
|
|
63
|
+
const m = readManifest(abs);
|
|
64
|
+
return {
|
|
65
|
+
path: path.relative(root, abs).replace(/\\/g, '/') || '.',
|
|
66
|
+
store_exists: m ? true : storeExists(abs),
|
|
67
|
+
freshness: m ? m.freshness.status : 'missing_index',
|
|
68
|
+
files_indexed: m ? m.stats.files_indexed : null,
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
const indexed = children.filter((c) => c.freshness !== 'missing_index').length;
|
|
72
|
+
return { children, indexed_children: indexed, total_children: children.length };
|
|
73
|
+
}
|
|
48
74
|
/**
|
|
49
75
|
* P0 JSONL-backed query backend. Reads the durable file store (manifest +
|
|
50
76
|
* shards + indexes); no graph DB. find()/brief() are stubbed for Sprint 1.
|
|
@@ -63,26 +89,31 @@ export class JsonlBackend {
|
|
|
63
89
|
}
|
|
64
90
|
async status(input) {
|
|
65
91
|
const manifest = readManifest(input.cwd, input.preferredDirName);
|
|
66
|
-
|
|
67
|
-
|
|
92
|
+
const result = manifest
|
|
93
|
+
? {
|
|
94
|
+
store_exists: true,
|
|
95
|
+
freshness_badge: this.withHeadDrift(badge(manifest.freshness.status, {
|
|
96
|
+
stale_file_count: manifest.freshness.stale_file_count,
|
|
97
|
+
partial_reason: manifest.freshness.partial_reason,
|
|
98
|
+
}), manifest, input.cwd),
|
|
99
|
+
stats: {
|
|
100
|
+
files_indexed: manifest.stats.files_indexed,
|
|
101
|
+
nodes: manifest.stats.nodes,
|
|
102
|
+
edges: manifest.stats.edges,
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
: {
|
|
68
106
|
store_exists: storeExists(input.cwd, input.preferredDirName),
|
|
69
107
|
freshness_badge: badge('missing_index'),
|
|
70
108
|
stats: null,
|
|
71
109
|
};
|
|
110
|
+
// Multi-project recap (opt-in): per-child store presence + freshness, so a
|
|
111
|
+
// status at the monorepo root surfaces the 27-children-missing-index state
|
|
112
|
+
// instead of just the (now child-scoped) root store. DGX Finding 2.
|
|
113
|
+
if (input.cascade && isMultiProjectWorkspace(input.cwd)) {
|
|
114
|
+
result.cascade = buildCascadeStatus(input.cwd);
|
|
72
115
|
}
|
|
73
|
-
|
|
74
|
-
stale_file_count: manifest.freshness.stale_file_count,
|
|
75
|
-
partial_reason: manifest.freshness.partial_reason,
|
|
76
|
-
});
|
|
77
|
-
return {
|
|
78
|
-
store_exists: true,
|
|
79
|
-
freshness_badge: this.withHeadDrift(base, manifest, input.cwd),
|
|
80
|
-
stats: {
|
|
81
|
-
files_indexed: manifest.stats.files_indexed,
|
|
82
|
-
nodes: manifest.stats.nodes,
|
|
83
|
-
edges: manifest.stats.edges,
|
|
84
|
-
},
|
|
85
|
-
};
|
|
116
|
+
return result;
|
|
86
117
|
}
|
|
87
118
|
/**
|
|
88
119
|
* Real refresh (spec §7): resolves project identity (input -> manifest ->
|
|
@@ -92,6 +123,36 @@ export class JsonlBackend {
|
|
|
92
123
|
*/
|
|
93
124
|
async refresh(input) {
|
|
94
125
|
const scope = input.scope ?? 'changed';
|
|
126
|
+
// Multi-project cascade (opt-in): refresh every nested brainclaw project +
|
|
127
|
+
// a child-scoped root store. No-op outside a multi-project workspace — fall
|
|
128
|
+
// through to the normal single-project refresh below. DGX Finding 2.
|
|
129
|
+
if (input.cascade && isMultiProjectWorkspace(input.cwd)) {
|
|
130
|
+
const cascade = await refreshWorkspaceCascade({
|
|
131
|
+
rootCwd: input.cwd ?? process.cwd(),
|
|
132
|
+
scope,
|
|
133
|
+
ownerAgent: input.ownerAgent ?? null,
|
|
134
|
+
ownerAgentId: input.ownerAgentId ?? null,
|
|
135
|
+
});
|
|
136
|
+
const root = cascade.root_result;
|
|
137
|
+
const allProjects = [root, ...cascade.children];
|
|
138
|
+
// A cascade is only fully "acquired" when EVERY project got its lock; if a
|
|
139
|
+
// child or the root was skipped under a live writer, surface that instead
|
|
140
|
+
// of reporting a clean lock_acquired=true over a partial cascade (codex review).
|
|
141
|
+
const skipped = allProjects.filter((p) => !p.lock_acquired);
|
|
142
|
+
return {
|
|
143
|
+
ran: allProjects.some((p) => p.ran),
|
|
144
|
+
scope,
|
|
145
|
+
lock_acquired: skipped.length === 0,
|
|
146
|
+
freshness_badge: badge(root.freshness, {
|
|
147
|
+
files_parsed: root.files_parsed,
|
|
148
|
+
children_refreshed: cascade.children_refreshed,
|
|
149
|
+
}),
|
|
150
|
+
...(skipped.length > 0
|
|
151
|
+
? { lock_status: `${skipped.length} project(s) skipped (lock held): ${skipped.map((p) => p.path).join(', ')}` }
|
|
152
|
+
: {}),
|
|
153
|
+
cascade,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
95
156
|
const manifest = readManifest(input.cwd, input.preferredDirName);
|
|
96
157
|
const projectRoot = input.projectRoot ?? manifest?.project_root ?? input.cwd ?? process.cwd();
|
|
97
158
|
const projectId = input.projectId ?? manifest?.project_id ?? `prj_${path.basename(path.resolve(projectRoot))}`;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monorepo Code Map cascade (DGX Finding 2, 2026-06-22).
|
|
3
|
+
*
|
|
4
|
+
* Plain `refresh` is topology-blind: run at a multi-project workspace root it
|
|
5
|
+
* builds ONE monolithic index that descends every child subtree, while the 27
|
|
6
|
+
* sibling projects keep `missing_index`. The cascade (opt-in: `--cascade` /
|
|
7
|
+
* `bclaw_code_refresh(cascade=true)`) instead refreshes EACH nested brainclaw
|
|
8
|
+
* project into its own `<child>/.brainclaw/code/` store, and refreshes the root
|
|
9
|
+
* store SCOPED to the files no child owns.
|
|
10
|
+
*
|
|
11
|
+
* Zero double-indexing — and correct under nesting — by a single rule: when
|
|
12
|
+
* refreshing project P, exclude the subtree of every OTHER discovered project
|
|
13
|
+
* that sits strictly under P. So a file is indexed by exactly one project: the
|
|
14
|
+
* most specific brainclaw project that contains it.
|
|
15
|
+
*
|
|
16
|
+
* Topology source = `scanNestedBrainclawProjects` (a pure filesystem scan for
|
|
17
|
+
* nested `.brainclaw/config.yaml`), so it works regardless of
|
|
18
|
+
* `projects.strategy` (folder/manual) and matches what `bclaw_switch --list`
|
|
19
|
+
* shows. Children without a `.brainclaw/` are NOT created here — the cascade
|
|
20
|
+
* refreshes existing brainclaw projects, it does not initialise new ones.
|
|
21
|
+
*/
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { scanNestedBrainclawProjects } from '../workspace-projects.js';
|
|
24
|
+
import { loadConfig } from '../config.js';
|
|
25
|
+
import { refresh as runRefresh } from './refresh.js';
|
|
26
|
+
import { readManifest } from './store.js';
|
|
27
|
+
function toPosix(p) {
|
|
28
|
+
return p.replace(/\\/g, '/');
|
|
29
|
+
}
|
|
30
|
+
/** True when `dir` is strictly below `ancestor` (not equal, not above, same drive). */
|
|
31
|
+
function isStrictlyUnder(dir, ancestor) {
|
|
32
|
+
const rel = path.relative(path.resolve(ancestor), path.resolve(dir));
|
|
33
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
34
|
+
}
|
|
35
|
+
function projectIdFor(cwd, fallbackId) {
|
|
36
|
+
const manifest = readManifest(cwd);
|
|
37
|
+
if (manifest?.project_id)
|
|
38
|
+
return manifest.project_id;
|
|
39
|
+
if (fallbackId)
|
|
40
|
+
return fallbackId;
|
|
41
|
+
try {
|
|
42
|
+
const id = loadConfig(cwd).project_id;
|
|
43
|
+
if (id)
|
|
44
|
+
return id;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
/* no config — fall through to a cwd-derived default */
|
|
48
|
+
}
|
|
49
|
+
return `prj_${path.basename(path.resolve(cwd))}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Nested brainclaw projects strictly under `rootCwd`, as absolute paths, deduped
|
|
53
|
+
* and sorted. Pure filesystem scan (strategy-agnostic). Shared by the refresh
|
|
54
|
+
* cascade and the `status --cascade` recap so both agree on the project set.
|
|
55
|
+
*/
|
|
56
|
+
export function listNestedProjects(rootCwd) {
|
|
57
|
+
const root = path.resolve(rootCwd);
|
|
58
|
+
return Array.from(new Set(scanNestedBrainclawProjects(root)
|
|
59
|
+
.map((c) => path.resolve(c.path))
|
|
60
|
+
.filter((abs) => abs !== root && isStrictlyUnder(abs, root)))).sort();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Refresh the whole multi-project workspace: every nested brainclaw project +
|
|
64
|
+
* a child-scoped root store. Callers should only invoke this when the root is a
|
|
65
|
+
* multi-project workspace (the backend gates on `project_mode`).
|
|
66
|
+
*/
|
|
67
|
+
export async function refreshWorkspaceCascade(input) {
|
|
68
|
+
const rootCwd = path.resolve(input.rootCwd);
|
|
69
|
+
// Enumerate nested brainclaw projects strictly under the root (FS scan, so it
|
|
70
|
+
// is strategy-agnostic). De-dup + sort by path for deterministic output.
|
|
71
|
+
const childAbsPaths = listNestedProjects(rootCwd);
|
|
72
|
+
// Every project to refresh, root first.
|
|
73
|
+
const allProjects = [rootCwd, ...childAbsPaths];
|
|
74
|
+
const refreshOne = async (projectCwd, isRoot) => {
|
|
75
|
+
// Exclude the subtree of every OTHER project that sits strictly under this
|
|
76
|
+
// one → each file is owned by exactly the most specific project.
|
|
77
|
+
const nestedUnder = allProjects.filter((p) => p !== projectCwd && isStrictlyUnder(p, projectCwd));
|
|
78
|
+
const extraIgnorePatterns = nestedUnder.map((p) => `${toPosix(path.relative(projectCwd, p))}/**`);
|
|
79
|
+
const projectId = projectIdFor(projectCwd);
|
|
80
|
+
const result = await runRefresh({
|
|
81
|
+
projectId,
|
|
82
|
+
projectRoot: projectCwd,
|
|
83
|
+
scope: input.scope,
|
|
84
|
+
cwd: projectCwd,
|
|
85
|
+
extraIgnorePatterns,
|
|
86
|
+
ownerAgent: input.ownerAgent ?? null,
|
|
87
|
+
ownerAgentId: input.ownerAgentId ?? null,
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
path: isRoot ? '.' : toPosix(path.relative(rootCwd, projectCwd)),
|
|
91
|
+
project_id: projectId,
|
|
92
|
+
is_root: isRoot,
|
|
93
|
+
ran: result.ran,
|
|
94
|
+
lock_acquired: result.lock_acquired,
|
|
95
|
+
files_parsed: result.files_parsed,
|
|
96
|
+
files_compacted: result.files_compacted,
|
|
97
|
+
freshness: result.freshness.status,
|
|
98
|
+
...(result.lock_status ? { lock_status: result.lock_status } : {}),
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
// Children first, then the root (sequential — each holds its own project lock
|
|
102
|
+
// briefly; never blocks bclaw_work, rule 8).
|
|
103
|
+
const children = [];
|
|
104
|
+
for (const childCwd of childAbsPaths) {
|
|
105
|
+
children.push(await refreshOne(childCwd, false));
|
|
106
|
+
}
|
|
107
|
+
const rootResult = await refreshOne(rootCwd, true);
|
|
108
|
+
return {
|
|
109
|
+
is_cascade: true,
|
|
110
|
+
root: rootCwd,
|
|
111
|
+
root_result: rootResult,
|
|
112
|
+
children,
|
|
113
|
+
children_refreshed: children.length,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=cascade.js.map
|
|
Binary file
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Append a one-line, timestamped diagnostic to ~/.brainclaw/hook.log.
|
|
6
|
+
*
|
|
7
|
+
* brainclaw session hooks (UserPromptSubmit / Stop) historically wrapped every
|
|
8
|
+
* CLI call in `2>/dev/null`, which turned an actionable failure (e.g. "no
|
|
9
|
+
* registered agent identity resolved") into a contentless "hook error: No
|
|
10
|
+
* stderr output" on every prompt (trp#917). Hook-mode commands now degrade to
|
|
11
|
+
* exit 0 and drop a line here instead, so the failure is silent to the agent's
|
|
12
|
+
* prompt loop but still debuggable.
|
|
13
|
+
*
|
|
14
|
+
* Best-effort and never throws — a logging failure must not break a hook.
|
|
15
|
+
*/
|
|
16
|
+
const MAX_LOG_BYTES = 256 * 1024;
|
|
17
|
+
export function hookLogPath(homeDir = os.homedir()) {
|
|
18
|
+
return path.join(homeDir, '.brainclaw', 'hook.log');
|
|
19
|
+
}
|
|
20
|
+
export function logHookDiagnostic(message, homeDir = os.homedir()) {
|
|
21
|
+
try {
|
|
22
|
+
const file = hookLogPath(homeDir);
|
|
23
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
24
|
+
// Size-cap: when the log grows past the cap, keep only the tail so it never
|
|
25
|
+
// balloons unbounded across thousands of prompts.
|
|
26
|
+
try {
|
|
27
|
+
const stat = fs.statSync(file);
|
|
28
|
+
if (stat.size > MAX_LOG_BYTES) {
|
|
29
|
+
const tail = fs.readFileSync(file, 'utf-8').slice(-Math.floor(MAX_LOG_BYTES / 2));
|
|
30
|
+
fs.writeFileSync(file, tail, 'utf-8');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* no existing file — nothing to truncate */
|
|
35
|
+
}
|
|
36
|
+
const line = `${new Date().toISOString()} ${message.replace(/\s+/g, ' ').trim()}\n`;
|
|
37
|
+
fs.appendFileSync(file, line, 'utf-8');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* best-effort — never break a hook over logging */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=hook-log.js.map
|
|
@@ -319,7 +319,7 @@ function renderAvailableTools() {
|
|
|
319
319
|
'## brainclaw — available tools',
|
|
320
320
|
'',
|
|
321
321
|
'**Entry:** `bclaw_work(intent, compact?, budget_tokens?)` · `bclaw_context(kind=memory|execution|board|board_summary|delta)`',
|
|
322
|
-
'**Canonical grammar** (entities: plan, decision, constraint, trap, handoff, runtime_note, candidate, sequence, claim, action, assignment, agent_run): `bclaw_find`, `bclaw_get`, `bclaw_create`, `bclaw_update`, `bclaw_remove`, `bclaw_transition`. Reads accept `budget_tokens` and `project` (cross-project routing — unknown names throw).',
|
|
322
|
+
'**Canonical grammar** (entities: plan, decision, constraint, trap, handoff, runtime_note, candidate, sequence, claim, action, assignment, agent_run): `bclaw_find`, `bclaw_get`, `bclaw_create`, `bclaw_update`, `bclaw_remove`, `bclaw_transition`. Reads accept `budget_tokens` and `project` (cross-project routing — unknown names throw). `bclaw_move(entity, id, to_project)` relocates an item to another project id-preserving in a multi-project workspace (plan/decision/constraint/trap/handoff/sequence; execution entities stay local).',
|
|
323
323
|
'**Session/claims:** `bclaw_session_start`, `bclaw_session_end`, `bclaw_claim`, `bclaw_release_claim` · **steps:** `bclaw_add_step`, `bclaw_complete_step`, `bclaw_update_step`, `bclaw_delete_step` · **sequences:** `bclaw_list_sequences`, `bclaw_create_sequence`, `bclaw_update_sequence`, `bclaw_delete_sequence`',
|
|
324
324
|
'**Inbox:** `bclaw_read_inbox`, `bclaw_ack_message`, `bclaw_send_message`, `bclaw_correct_handoff` · **capture:** `bclaw_write_note`, `bclaw_quick_capture(text, type?)` · **search:** `bclaw_search` · **setup:** `bclaw_setup`, `bclaw_bootstrap`, `bclaw_switch`, `bclaw_release_notes`',
|
|
325
325
|
'**Escalation (orchestrators):** `bclaw_coordinate(intent=review|consult|assign|ideate)` · `bclaw_dispatch(intent=execute)` on an active sequence · `bclaw_loop(intent=turn|complete_turn|advance|close)` to drive turns · `bclaw_dispatch_status(target_id)` to verify',
|
package/dist/core/loops/store.js
CHANGED
|
@@ -3,7 +3,9 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { memoryDir, writeFileAtomic } from '../io.js';
|
|
5
5
|
import { nowISO } from '../ids.js';
|
|
6
|
-
import {
|
|
6
|
+
import { logger } from '../logger.js';
|
|
7
|
+
import { convergeAssignmentToTerminal, loadAssignment } from '../assignments.js';
|
|
8
|
+
import { gcWorktreeIfHarvested } from '../worktree.js';
|
|
7
9
|
import { writeProjectMdSafe } from './hooks/bootstrap-write.js';
|
|
8
10
|
import { notifyOperatorOnInputRequested } from './hooks/notify-operator.js';
|
|
9
11
|
import { DEFAULT_PROTOCOLS, LoopArtifactSchema, LoopEventSchema, LoopThreadSchema, } from './types.js';
|
|
@@ -433,6 +435,34 @@ export function closeLoop(input, cwd) {
|
|
|
433
435
|
}
|
|
434
436
|
catch { /* never block loop close on assignment convergence */ }
|
|
435
437
|
}
|
|
438
|
+
// pln#594: GC the dispatched sub-agent worktrees now the loop is done, so
|
|
439
|
+
// review/dispatch worktrees stop accumulating under ~/.brainclaw/worktrees/.
|
|
440
|
+
// Only on a COMPLETED close — cancelled/blocked keep their worktree (+ run
|
|
441
|
+
// logs) for forensics. Best-effort (never blocks the close) and each removal
|
|
442
|
+
// is guarded inside gcWorktreeIfHarvested (skips alive / dirty / un-integrated
|
|
443
|
+
// worktrees, junction-safe). Opt out with BRAINCLAW_NO_WORKTREE_GC=1.
|
|
444
|
+
if (input.final_status === 'completed' && process.env.BRAINCLAW_NO_WORKTREE_GC !== '1') {
|
|
445
|
+
const mainCwd = cwd ?? process.cwd();
|
|
446
|
+
const seen = new Set();
|
|
447
|
+
for (const slot of next.slots) {
|
|
448
|
+
if (!slot.assignment_id)
|
|
449
|
+
continue;
|
|
450
|
+
try {
|
|
451
|
+
const worktreePath = loadAssignment(slot.assignment_id, cwd)?.worktree_path;
|
|
452
|
+
if (!worktreePath || seen.has(worktreePath))
|
|
453
|
+
continue;
|
|
454
|
+
seen.add(worktreePath);
|
|
455
|
+
const decision = gcWorktreeIfHarvested(mainCwd, worktreePath);
|
|
456
|
+
if (decision.removed) {
|
|
457
|
+
logger.info(`loop ${input.id} close: removed worktree ${decision.path} (${decision.reason})`);
|
|
458
|
+
}
|
|
459
|
+
else if (decision.reason !== 'already gone') {
|
|
460
|
+
logger.debug(`loop ${input.id} close: kept worktree ${decision.path} — ${decision.reason}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
catch { /* never block loop close on worktree GC */ }
|
|
464
|
+
}
|
|
465
|
+
}
|
|
436
466
|
return next;
|
|
437
467
|
}
|
|
438
468
|
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-project relocation (pln#595) — `bclaw move` / `bclaw_move`.
|
|
3
|
+
*
|
|
4
|
+
* Moves a brainclaw item from one project store to another in a multi-project
|
|
5
|
+
* workspace, PRESERVING ITS ID (so `pln#`/`dec#` references stay stable). Born
|
|
6
|
+
* from the monorepo switch bug (DGX Finding 1): items were created in the wrong
|
|
7
|
+
* store and there was no relocation helper — only raw file surgery or a recreate
|
|
8
|
+
* that mints a new id and breaks references.
|
|
9
|
+
*
|
|
10
|
+
* Scope (v1): the portable knowledge / coordination entities stored one file per
|
|
11
|
+
* id under a single directory — plan, decision, constraint, trap, handoff,
|
|
12
|
+
* sequence. Execution-local entities (claim, assignment, agent_run, session) are
|
|
13
|
+
* intentionally NOT relocatable — they belong to the project where the work ran
|
|
14
|
+
* (cross_project_signaling_vs_execution). candidate/runtime_note are deferred
|
|
15
|
+
* (inbox / visibility-split storage) — a follow-up.
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { resolveEntityDir, writeFileAtomic } from '../io.js';
|
|
20
|
+
import { getEntitySpec } from '../entity-registry.js';
|
|
21
|
+
import { appendAuditEntry } from '../audit.js';
|
|
22
|
+
import { resolveProjectCwd } from '../cross-project.js';
|
|
23
|
+
import { listClaims } from '../claims.js';
|
|
24
|
+
import { listSequences } from '../sequence.js';
|
|
25
|
+
/**
|
|
26
|
+
* Relocatable entity → the store subdir holding its flat `<id>.json` files.
|
|
27
|
+
*
|
|
28
|
+
* v1 covers only the SHARED, flat-per-id stores. Trap is deliberately limited to
|
|
29
|
+
* the shared `traps/` dir: the host/private visibility variants are host-SCOPED
|
|
30
|
+
* (`traps-hosts/<host>/<id>.json`, `traps-private/<host>/<id>.json` — see
|
|
31
|
+
* saveOperationalTrap), so a flat `<variant>/<id>.json` lookup would both miss
|
|
32
|
+
* them and, if it found one, flatten away the host directory (codex review of
|
|
33
|
+
* pln#595). Host/private traps are deferred alongside candidate/runtime_note
|
|
34
|
+
* until relocation preserves the host scope.
|
|
35
|
+
*/
|
|
36
|
+
const RELOCATABLE_SUBDIRS = {
|
|
37
|
+
plan: ['plans'],
|
|
38
|
+
decision: ['decisions'],
|
|
39
|
+
constraint: ['constraints'],
|
|
40
|
+
trap: ['traps'],
|
|
41
|
+
handoff: ['handoffs'],
|
|
42
|
+
sequence: ['sequences'],
|
|
43
|
+
};
|
|
44
|
+
export const RELOCATABLE_ENTITIES = Object.keys(RELOCATABLE_SUBDIRS);
|
|
45
|
+
/**
|
|
46
|
+
* Relocate one entity, id-preserving. Throws on every unsafe condition
|
|
47
|
+
* (non-relocatable entity, same source/target, missing source, target collision,
|
|
48
|
+
* unknown target project, or a live claim unless `force`). Audits BOTH stores.
|
|
49
|
+
*/
|
|
50
|
+
export function relocateEntity(input) {
|
|
51
|
+
const baseCwd = input.cwd ?? process.cwd();
|
|
52
|
+
const subdirs = RELOCATABLE_SUBDIRS[input.entity];
|
|
53
|
+
if (!subdirs) {
|
|
54
|
+
throw new Error(`Cannot move entity '${input.entity}': only portable knowledge/coordination entities are relocatable `
|
|
55
|
+
+ `(${RELOCATABLE_ENTITIES.join(', ')}). Execution-local entities (claim, assignment, agent_run, session, …) `
|
|
56
|
+
+ `stay in the project where the work ran.`);
|
|
57
|
+
}
|
|
58
|
+
if (!input.id?.trim())
|
|
59
|
+
throw new Error('move requires an entity id.');
|
|
60
|
+
const fromCwd = path.resolve(input.fromProject ? resolveProjectCwd(input.fromProject, baseCwd) : baseCwd);
|
|
61
|
+
const toCwd = path.resolve(resolveProjectCwd(input.toProject, baseCwd)); // throws on unknown target
|
|
62
|
+
if (fromCwd === toCwd) {
|
|
63
|
+
throw new Error(`Source and target are the same project (${toCwd}). Nothing to move.`);
|
|
64
|
+
}
|
|
65
|
+
// Locate the source file across the entity's candidate subdirs.
|
|
66
|
+
let srcFile;
|
|
67
|
+
let foundSubdir;
|
|
68
|
+
for (const sd of subdirs) {
|
|
69
|
+
const candidate = path.join(resolveEntityDir(sd, fromCwd, 'read'), `${input.id}.json`);
|
|
70
|
+
if (fs.existsSync(candidate)) {
|
|
71
|
+
srcFile = candidate;
|
|
72
|
+
foundSubdir = sd;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!srcFile || !foundSubdir) {
|
|
77
|
+
throw new Error(`${input.entity} '${input.id}' not found in source project (${fromCwd}).`);
|
|
78
|
+
}
|
|
79
|
+
// Validate before moving — never silently relocate a corrupt record.
|
|
80
|
+
let raw;
|
|
81
|
+
try {
|
|
82
|
+
raw = JSON.parse(fs.readFileSync(srcFile, 'utf-8'));
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
throw new Error(`${input.entity} '${input.id}' is unreadable JSON: ${err.message}`, { cause: err });
|
|
86
|
+
}
|
|
87
|
+
getEntitySpec(input.entity).schema.parse(raw);
|
|
88
|
+
// Collision guard — never overwrite an item already in the target.
|
|
89
|
+
const dstDir = resolveEntityDir(foundSubdir, toCwd, 'write');
|
|
90
|
+
const dstFile = path.join(dstDir, `${input.id}.json`);
|
|
91
|
+
if (fs.existsSync(dstFile)) {
|
|
92
|
+
throw new Error(`${input.entity} '${input.id}' already exists in the target project — refusing to overwrite.`);
|
|
93
|
+
}
|
|
94
|
+
// Reference guards (plans): refuse to move work under a live claim; warn on
|
|
95
|
+
// sequences that still point at it (v1 does not rewrite refs).
|
|
96
|
+
const warnings = [];
|
|
97
|
+
if (input.entity === 'plan') {
|
|
98
|
+
const liveClaims = listClaims(fromCwd).filter((c) => c.status === 'active' && c.plan_id === input.id);
|
|
99
|
+
if (liveClaims.length > 0 && !input.force) {
|
|
100
|
+
throw new Error(`${input.id} has ${liveClaims.length} active claim(s) in the source project — refusing to move work mid-flight. `
|
|
101
|
+
+ `Release the claim(s) first, or pass force to override.`);
|
|
102
|
+
}
|
|
103
|
+
if (liveClaims.length > 0) {
|
|
104
|
+
warnings.push(`moved despite ${liveClaims.length} active claim(s) still in the source project (force).`);
|
|
105
|
+
}
|
|
106
|
+
const refSeqs = listSequences(fromCwd).filter((s) => (s.items ?? []).some((it) => it.planId === input.id));
|
|
107
|
+
for (const s of refSeqs) {
|
|
108
|
+
warnings.push(`sequence ${s.id} in the source project still references this plan (items not rewritten).`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Perform the move: write target first (atomic), then remove the source — so a
|
|
112
|
+
// crash mid-move leaves a duplicate (recoverable) rather than nothing.
|
|
113
|
+
fs.mkdirSync(dstDir, { recursive: true });
|
|
114
|
+
writeFileAtomic(dstFile, `${JSON.stringify(raw, null, 2)}\n`);
|
|
115
|
+
fs.rmSync(srcFile);
|
|
116
|
+
// Audit BOTH stores so provenance survives the move.
|
|
117
|
+
const actor = input.actor ?? 'unknown';
|
|
118
|
+
const auditCommon = {
|
|
119
|
+
action: 'move',
|
|
120
|
+
actor,
|
|
121
|
+
...(input.actorId ? { actor_id: input.actorId } : {}),
|
|
122
|
+
item_id: input.id,
|
|
123
|
+
item_type: input.entity,
|
|
124
|
+
scope: foundSubdir,
|
|
125
|
+
};
|
|
126
|
+
appendAuditEntry({ ...auditCommon, reason: `moved to ${toCwd}` }, fromCwd);
|
|
127
|
+
appendAuditEntry({ ...auditCommon, reason: `moved from ${fromCwd}` }, toCwd);
|
|
128
|
+
return { entity: input.entity, id: input.id, from: fromCwd, to: toCwd, subdir: foundSubdir, warnings };
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=relocate.js.map
|