brainclaw 1.7.1 → 1.7.3
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 +116 -94
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +25 -3
- package/dist/commands/dispatch.js +2 -0
- package/dist/commands/doctor.js +17 -0
- package/dist/commands/harvest.js +124 -1
- package/dist/commands/mcp.js +32 -8
- package/dist/core/agent-capability.js +67 -0
- package/dist/core/agent-inventory.js +54 -7
- package/dist/core/agentrun-reconciler.js +126 -52
- package/dist/core/coordination.js +10 -9
- package/dist/core/dirty-scope.js +11 -5
- package/dist/core/dispatcher.js +109 -29
- package/dist/core/entity-operations.js +54 -1
- package/dist/core/execution-adapters.js +32 -51
- package/dist/core/execution.js +14 -8
- package/dist/core/instruction-templates.js +5 -4
- package/dist/core/runtime-signals.js +102 -0
- package/dist/core/schema.js +18 -0
- package/dist/core/spawn-check.js +125 -0
- package/dist/core/worktree.js +146 -7
- package/dist/facts.js +3 -3
- package/dist/facts.json +2 -2
- package/docs/cli.md +8 -4
- package/docs/integrations/mcp.md +48 -15
- package/docs/mcp-schema-changelog.md +16 -5
- package/docs/playbooks/team/index.md +7 -5
- package/package.json +1 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime spawn signals (pln#520 steps 1 + 4) — the file-based, zero-MCP
|
|
3
|
+
* liveness channel between a dispatched worker and brainclaw.
|
|
4
|
+
*
|
|
5
|
+
* Why files, not the tracked pid: on Windows the ack-wrap spawn runs under
|
|
6
|
+
* `shell:true`, so `child.pid` is the cmd.exe wrapper (which dies early),
|
|
7
|
+
* NOT the real worker (cmd.exe → claude.cmd → node.exe). Reading that pid as
|
|
8
|
+
* dead produced false-negative `pid_dead_at_read` cancellations while the
|
|
9
|
+
* worker was alive and committing (can_f792cacd: 6 workers cancelled, then
|
|
10
|
+
* committed 4-7 min later). The fix is to stop trusting the wrapper pid and
|
|
11
|
+
* trust sentinels the worker / wrapper actually write:
|
|
12
|
+
*
|
|
13
|
+
* - `ack` — pre-exec; the spawn shell touched it BEFORE the agent ran
|
|
14
|
+
* (pln#476). Proves delivery, NOT that work started.
|
|
15
|
+
* - `heartbeat` — the worker writes `work_loop_reached{run_id,nonce}` as its
|
|
16
|
+
* FIRST action (step 0 of the generated brief) and refreshes
|
|
17
|
+
* it periodically. Distinct from `ack`: this is what flips
|
|
18
|
+
* execution_status to `started`.
|
|
19
|
+
* - `completed` / `failed` — emitted MECHANICALLY by the spawn wrapper
|
|
20
|
+
* (`agentcmd && completed || failed`) so a dead wrapper pid
|
|
21
|
+
* is never misread as a silent failure.
|
|
22
|
+
*
|
|
23
|
+
* All paths are absolute under the project coordination dir so a worker in a
|
|
24
|
+
* worktree (or a sandboxed agent without MCP) can write them with a plain
|
|
25
|
+
* shell redirect.
|
|
26
|
+
*
|
|
27
|
+
* @module
|
|
28
|
+
*/
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
function runtimeDir(root) {
|
|
32
|
+
return path.join(root, '.brainclaw', 'coordination', 'runtime');
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Absolute path for a runtime signal sentinel. `ack` keeps its historical
|
|
36
|
+
* `runtime/ack/<id>.ack` location (pln#476); the liveness signals live under
|
|
37
|
+
* `runtime/signal/<id>.<signal>`.
|
|
38
|
+
*/
|
|
39
|
+
export function getRuntimeSignalPath(root, assignmentId, signal) {
|
|
40
|
+
if (signal === 'ack') {
|
|
41
|
+
return path.join(runtimeDir(root), 'ack', `${assignmentId}.ack`);
|
|
42
|
+
}
|
|
43
|
+
return path.join(runtimeDir(root), 'signal', `${assignmentId}.${signal}`);
|
|
44
|
+
}
|
|
45
|
+
/** Absolute path for a captured stream log (`runtime/log/<id>.{stdout,stderr}.log`). */
|
|
46
|
+
export function getRuntimeLogPath(root, assignmentId, stream) {
|
|
47
|
+
return path.join(runtimeDir(root), 'log', `${assignmentId}.${stream}.log`);
|
|
48
|
+
}
|
|
49
|
+
/** Ensure the ack / signal / log directories exist (best-effort, recursive). */
|
|
50
|
+
export function ensureRuntimeDirs(root) {
|
|
51
|
+
const base = runtimeDir(root);
|
|
52
|
+
for (const sub of ['ack', 'signal', 'log']) {
|
|
53
|
+
fs.mkdirSync(path.join(base, sub), { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function signalExists(root, assignmentId, signal) {
|
|
57
|
+
try {
|
|
58
|
+
return fs.existsSync(getRuntimeSignalPath(root, assignmentId, signal));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Read the heartbeat sentinel. The body is expected to be
|
|
66
|
+
* `work_loop_reached{run_id,nonce}` JSON, but a bare `touch` (empty file) still
|
|
67
|
+
* counts as a heartbeat — the mtime alone is a valid life-sign.
|
|
68
|
+
*/
|
|
69
|
+
export function readHeartbeat(root, assignmentId) {
|
|
70
|
+
const p = getRuntimeSignalPath(root, assignmentId, 'heartbeat');
|
|
71
|
+
try {
|
|
72
|
+
const stat = fs.statSync(p);
|
|
73
|
+
const info = { exists: true, mtimeMs: stat.mtimeMs };
|
|
74
|
+
try {
|
|
75
|
+
const raw = fs.readFileSync(p, 'utf-8').trim();
|
|
76
|
+
if (raw) {
|
|
77
|
+
const parsed = JSON.parse(raw);
|
|
78
|
+
if (typeof parsed.run_id === 'string')
|
|
79
|
+
info.runId = parsed.run_id;
|
|
80
|
+
if (typeof parsed.nonce === 'string')
|
|
81
|
+
info.nonce = parsed.nonce;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch { /* empty / non-JSON body — mtime still counts */ }
|
|
85
|
+
return info;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return { exists: false };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Read the tail of a captured stream log (for failed_silent diagnostics). */
|
|
92
|
+
export function readLogTail(root, assignmentId, stream, maxBytes = 2000) {
|
|
93
|
+
try {
|
|
94
|
+
const p = getRuntimeLogPath(root, assignmentId, stream);
|
|
95
|
+
const content = fs.readFileSync(p, 'utf-8');
|
|
96
|
+
return content.length > maxBytes ? content.slice(content.length - maxBytes) : content;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return '';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=runtime-signals.js.map
|
package/dist/core/schema.js
CHANGED
|
@@ -855,7 +855,25 @@ export const RuntimeEventTypeSchema = z.enum([
|
|
|
855
855
|
'run_interrupted',
|
|
856
856
|
'plan_cascade_to_done',
|
|
857
857
|
'candidate_harvested',
|
|
858
|
+
'lane_result_harvested',
|
|
858
859
|
]);
|
|
860
|
+
/**
|
|
861
|
+
* pln#526 — LANE-RESULT convention. A dispatched worker writes a single
|
|
862
|
+
* `LANE-RESULT.json` at its worktree root as its final step (a fallback that
|
|
863
|
+
* works even when bclaw_assignment_update / MCP is unavailable, e.g. sandboxed
|
|
864
|
+
* agents). The coordinator ingests it with `brainclaw harvest <assignment_id>`.
|
|
865
|
+
*/
|
|
866
|
+
export const LaneResultSchema = z.object({
|
|
867
|
+
assignment_id: z.string(),
|
|
868
|
+
status: z.enum(['completed', 'blocked', 'failed']),
|
|
869
|
+
summary: z.string(),
|
|
870
|
+
/** Paths or refs the worker produced (commits, files, docs). */
|
|
871
|
+
artifacts: z.array(z.string()).optional(),
|
|
872
|
+
/** Files the worker changed in the worktree. */
|
|
873
|
+
files_changed: z.array(z.string()).optional(),
|
|
874
|
+
/** Free-form notes (blockers, follow-ups). */
|
|
875
|
+
notes: z.string().optional(),
|
|
876
|
+
});
|
|
859
877
|
export const RuntimeEventSchema = z.object({
|
|
860
878
|
id: z.string(),
|
|
861
879
|
agent: z.string(),
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pln#520 step 2 — `brainclaw doctor --spawn-check`.
|
|
3
|
+
*
|
|
4
|
+
* A real, minimal spawn round-trip per installed agent BEFORE any real dispatch
|
|
5
|
+
* (and in CI). For each CLI-spawnable agent whose binary is on PATH, it spawns
|
|
6
|
+
* an ack-wrapped probe and waits for the ack + completed sentinels on the
|
|
7
|
+
* current host. This validates delivery + the spawn/handshake/sentinel
|
|
8
|
+
* mechanism on the actual agent×OS cell — it would have caught the 6 silent
|
|
9
|
+
* deaths of can_f792cacd before launching them (a worker that spawns but never
|
|
10
|
+
* reaches completion shows up here as `delivered_no_completion`).
|
|
11
|
+
*
|
|
12
|
+
* Uninstalled agents are skipped (`not_installed`), so this is safe to run in
|
|
13
|
+
* CI where most agent CLIs are absent.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { buildInvokeCommand, getSpawnableAgents, getCapabilityProfile, } from './agent-capability.js';
|
|
21
|
+
import { defaultExecutionAdapter, resolveBinaryOnPath } from './execution-adapters.js';
|
|
22
|
+
import { signalExists, readLogTail } from './runtime-signals.js';
|
|
23
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 15_000;
|
|
24
|
+
const DEFAULT_PROBE_PROMPT = 'Reply with exactly: OK';
|
|
25
|
+
async function sleep(ms) {
|
|
26
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
27
|
+
}
|
|
28
|
+
/** Check one agent's spawn round-trip. Exposed for focused testing. */
|
|
29
|
+
export async function checkAgentSpawn(agent, options = {}) {
|
|
30
|
+
const start = Date.now();
|
|
31
|
+
const profile = getCapabilityProfile(agent);
|
|
32
|
+
if (!profile?.invoke_template || !profile?.invoke_binary || !profile.runtime.canBeSpawnedCli) {
|
|
33
|
+
return { agent, status: 'no_template', delivered: false, completed: false, duration_ms: 0, detail: 'no CLI invoke template' };
|
|
34
|
+
}
|
|
35
|
+
const binary = resolveBinaryOnPath(profile.invoke_binary);
|
|
36
|
+
if (!binary) {
|
|
37
|
+
return { agent, binary: profile.invoke_binary, status: 'not_installed', delivered: false, completed: false, duration_ms: 0, detail: `binary '${profile.invoke_binary}' not on PATH` };
|
|
38
|
+
}
|
|
39
|
+
const invoke = options.probeFor?.(agent)
|
|
40
|
+
?? buildInvokeCommand(agent, options.probePrompt ?? DEFAULT_PROBE_PROMPT, { mode: 'consult' });
|
|
41
|
+
if (!invoke) {
|
|
42
|
+
return { agent, binary, status: 'no_template', delivered: false, completed: false, duration_ms: 0, detail: 'could not build invoke command' };
|
|
43
|
+
}
|
|
44
|
+
// Isolated signals root so the probe never pollutes the project's runtime dir.
|
|
45
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), `bclaw-spawncheck-${agent}-`));
|
|
46
|
+
const assignmentId = 'spawn_check';
|
|
47
|
+
try {
|
|
48
|
+
defaultExecutionAdapter.start(invoke, { agent, assignmentId, ackRoot: root, worktreePath: root });
|
|
49
|
+
const timeout = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
50
|
+
const deadline = Date.now() + timeout;
|
|
51
|
+
while (Date.now() < deadline) {
|
|
52
|
+
if (signalExists(root, assignmentId, 'completed'))
|
|
53
|
+
break;
|
|
54
|
+
if (signalExists(root, assignmentId, 'failed'))
|
|
55
|
+
break;
|
|
56
|
+
await sleep(100);
|
|
57
|
+
}
|
|
58
|
+
const delivered = signalExists(root, assignmentId, 'ack');
|
|
59
|
+
const completed = signalExists(root, assignmentId, 'completed');
|
|
60
|
+
const failed = signalExists(root, assignmentId, 'failed');
|
|
61
|
+
const duration_ms = Date.now() - start;
|
|
62
|
+
if (completed) {
|
|
63
|
+
return { agent, binary, status: 'ok', delivered, completed: true, duration_ms, detail: 'ack + completed round-trip' };
|
|
64
|
+
}
|
|
65
|
+
if (failed) {
|
|
66
|
+
const tail = readLogTail(root, assignmentId, 'stderr', 400).trim() || readLogTail(root, assignmentId, 'stdout', 400).trim();
|
|
67
|
+
return { agent, binary, status: 'failed', delivered, completed: false, duration_ms, detail: `wrapper reported failure${tail ? ` — ${tail.replace(/\s+/g, ' ').slice(0, 200)}` : ''}` };
|
|
68
|
+
}
|
|
69
|
+
if (delivered) {
|
|
70
|
+
return { agent, binary, status: 'delivered_no_completion', delivered: true, completed: false, duration_ms, detail: `spawned + ack but no completion within ${timeout}ms (silent-death symptom)` };
|
|
71
|
+
}
|
|
72
|
+
return { agent, binary, status: 'failed', delivered: false, completed: false, duration_ms, detail: `no ack within ${timeout}ms — delivery failed` };
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
try {
|
|
76
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
catch { /* best-effort */ }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function runSpawnCheck(options = {}) {
|
|
82
|
+
const agentNames = options.agents ?? getSpawnableAgents().map((a) => a.name);
|
|
83
|
+
const entries = [];
|
|
84
|
+
for (const agent of agentNames) {
|
|
85
|
+
try {
|
|
86
|
+
entries.push(await checkAgentSpawn(agent, options));
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
entries.push({
|
|
90
|
+
agent, status: 'failed', delivered: false, completed: false, duration_ms: 0,
|
|
91
|
+
detail: `spawn-check threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const installed = entries.filter((e) => e.status !== 'not_installed' && e.status !== 'no_template');
|
|
96
|
+
const ok = installed.filter((e) => e.status === 'ok').length;
|
|
97
|
+
const failures = installed.filter((e) => e.status === 'failed' || e.status === 'delivered_no_completion').length;
|
|
98
|
+
const not_installed = entries.filter((e) => e.status === 'not_installed').length;
|
|
99
|
+
return {
|
|
100
|
+
host_os: process.platform,
|
|
101
|
+
total: entries.length,
|
|
102
|
+
ok,
|
|
103
|
+
failures,
|
|
104
|
+
not_installed,
|
|
105
|
+
entries,
|
|
106
|
+
exit_code: failures > 0 ? 1 : 0,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export function renderSpawnCheckReport(report) {
|
|
110
|
+
const lines = [];
|
|
111
|
+
lines.push(`Spawn-check — ${report.ok} ok / ${report.failures} failed / ${report.not_installed} not installed (host: ${report.host_os})`);
|
|
112
|
+
lines.push('');
|
|
113
|
+
for (const e of report.entries) {
|
|
114
|
+
const icon = e.status === 'ok' ? '✔'
|
|
115
|
+
: e.status === 'not_installed' || e.status === 'no_template' ? '·'
|
|
116
|
+
: '✗';
|
|
117
|
+
lines.push(` ${icon} ${e.agent.padEnd(16)} ${e.status}${e.duration_ms ? ` (${e.duration_ms}ms)` : ''} — ${e.detail}`);
|
|
118
|
+
}
|
|
119
|
+
if (report.failures > 0) {
|
|
120
|
+
lines.push('');
|
|
121
|
+
lines.push('✗ One or more installed agents failed their spawn round-trip — fix before dispatching.');
|
|
122
|
+
}
|
|
123
|
+
return lines.join('\n');
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=spawn-check.js.map
|
package/dist/core/worktree.js
CHANGED
|
@@ -3,6 +3,9 @@ import fs from 'node:fs';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import yaml from 'yaml';
|
|
7
|
+
import { logger } from './logger.js';
|
|
8
|
+
import { parsePorcelainZ, isSystemDirtyPath } from './dirty-scope.js';
|
|
6
9
|
/** Normalizes a path for use in git CLI arguments (forward slashes on Windows). */
|
|
7
10
|
function gitPath(p) {
|
|
8
11
|
return p.replace(/\\/g, '/');
|
|
@@ -37,6 +40,85 @@ export function detectStackSharedPaths(projectRoot) {
|
|
|
37
40
|
}
|
|
38
41
|
return [...result];
|
|
39
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* pln#523 — read declared monorepo workspace globs from npm/yarn/bun
|
|
45
|
+
* `workspaces` (package.json) and pnpm-workspace.yaml. Returns the raw
|
|
46
|
+
* patterns (e.g. "packages/*", "apps/api"); empty when the project is not a
|
|
47
|
+
* workspace root or the manifests are absent/invalid.
|
|
48
|
+
*/
|
|
49
|
+
export function readWorkspacePatterns(projectRoot) {
|
|
50
|
+
const patterns = [];
|
|
51
|
+
// npm / yarn / bun: package.json "workspaces" (array, or { packages: [...] })
|
|
52
|
+
try {
|
|
53
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'));
|
|
54
|
+
const ws = pkg.workspaces;
|
|
55
|
+
if (Array.isArray(ws))
|
|
56
|
+
patterns.push(...ws);
|
|
57
|
+
else if (ws && Array.isArray(ws.packages))
|
|
58
|
+
patterns.push(...ws.packages);
|
|
59
|
+
}
|
|
60
|
+
catch { /* no / invalid package.json — not a node workspace root */ }
|
|
61
|
+
// pnpm: pnpm-workspace.yaml "packages"
|
|
62
|
+
try {
|
|
63
|
+
const parsed = yaml.parse(fs.readFileSync(path.join(projectRoot, 'pnpm-workspace.yaml'), 'utf-8'));
|
|
64
|
+
if (parsed && Array.isArray(parsed.packages))
|
|
65
|
+
patterns.push(...parsed.packages);
|
|
66
|
+
}
|
|
67
|
+
catch { /* no pnpm workspace file */ }
|
|
68
|
+
return [...new Set(patterns)];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* pln#523 — resolve monorepo workspace globs to the per-package `node_modules`
|
|
72
|
+
* directories that actually exist on disk. Hoisted monorepos (all deps at the
|
|
73
|
+
* root) need only the root link from detectStackSharedPaths; this additionally
|
|
74
|
+
* covers packages that keep a LOCAL node_modules (pnpm, nohoist, partial
|
|
75
|
+
* hoisting) so a dispatched worker can build/typecheck a sub-package, not just
|
|
76
|
+
* the root — the exact gap behind a worker stalling on `tsc` in a worktree.
|
|
77
|
+
*
|
|
78
|
+
* Pattern shapes supported without a glob dependency (zero-runtime-dep policy):
|
|
79
|
+
* - exact dir: "apps/api"
|
|
80
|
+
* - single wildcard: "packages/*" → immediate child directories
|
|
81
|
+
* - deep wildcard: "packages/**" → treated as one level ("packages/*")
|
|
82
|
+
* Negations ("!pkg/excluded") are skipped — they only narrow coverage and a
|
|
83
|
+
* missing link degrades gracefully to central validation.
|
|
84
|
+
*
|
|
85
|
+
* Returns relative paths with forward slashes (e.g. "apps/api/node_modules").
|
|
86
|
+
*/
|
|
87
|
+
export function detectWorkspaceNodeModules(projectRoot) {
|
|
88
|
+
const patterns = readWorkspacePatterns(projectRoot);
|
|
89
|
+
if (patterns.length === 0)
|
|
90
|
+
return [];
|
|
91
|
+
const result = new Set();
|
|
92
|
+
const addIfHasNodeModules = (relPkgDir) => {
|
|
93
|
+
const rel = `${relPkgDir.replace(/\\/g, '/').replace(/\/+$/, '')}/node_modules`;
|
|
94
|
+
if (fs.existsSync(path.join(projectRoot, rel)))
|
|
95
|
+
result.add(rel);
|
|
96
|
+
};
|
|
97
|
+
for (const raw of patterns) {
|
|
98
|
+
const pattern = raw.trim();
|
|
99
|
+
if (!pattern || pattern.startsWith('!'))
|
|
100
|
+
continue;
|
|
101
|
+
const wildcardIdx = pattern.indexOf('*');
|
|
102
|
+
if (wildcardIdx === -1) {
|
|
103
|
+
addIfHasNodeModules(pattern);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
// Base dir = the path segment before the first wildcard.
|
|
107
|
+
const base = pattern.slice(0, wildcardIdx).replace(/\/+$/, '');
|
|
108
|
+
let children = [];
|
|
109
|
+
try {
|
|
110
|
+
children = fs
|
|
111
|
+
.readdirSync(path.join(projectRoot, base), { withFileTypes: true })
|
|
112
|
+
.filter((d) => d.isDirectory())
|
|
113
|
+
.map((d) => d.name);
|
|
114
|
+
}
|
|
115
|
+
catch { /* base dir absent — skip this pattern */ }
|
|
116
|
+
for (const child of children) {
|
|
117
|
+
addIfHasNodeModules(base ? `${base}/${child}` : child);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return [...result];
|
|
121
|
+
}
|
|
40
122
|
function canonicalizeScopePath(target) {
|
|
41
123
|
let resolved;
|
|
42
124
|
try {
|
|
@@ -191,6 +273,7 @@ export function findWorktreePathForBranch(worktrees, branchName) {
|
|
|
191
273
|
* Returns the absolute path to the newly created worktree.
|
|
192
274
|
*/
|
|
193
275
|
export function createWorktree(mainWorktreePath, branchName, options = {}) {
|
|
276
|
+
const symlinkWarnings = [];
|
|
194
277
|
const trySymlinkSharedPath = (entryName) => {
|
|
195
278
|
const sourcePath = path.join(mainWorktreePath, entryName);
|
|
196
279
|
const linkPath = path.join(targetPath, entryName);
|
|
@@ -205,8 +288,20 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
|
|
|
205
288
|
}
|
|
206
289
|
fs.symlinkSync(sourcePath, linkPath, 'junction');
|
|
207
290
|
}
|
|
208
|
-
catch {
|
|
209
|
-
//
|
|
291
|
+
catch (err) {
|
|
292
|
+
// pln#523: do NOT swallow silently. A missing node_modules junction is
|
|
293
|
+
// exactly what leaves a dispatched worker unable to build/typecheck in its
|
|
294
|
+
// worktree (it then stalls on `tsc` or npm scripts). Record a structured
|
|
295
|
+
// warning — surfaced in the worktree sidecar + logger — instead of an
|
|
296
|
+
// invisible degradation. Linking remains best-effort (non-fatal).
|
|
297
|
+
const sameVolume = path.parse(sourcePath).root.toLowerCase() === path.parse(targetPath).root.toLowerCase();
|
|
298
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
299
|
+
const hint = sameVolume
|
|
300
|
+
? ''
|
|
301
|
+
: ' (source and worktree are on different volumes — directory junctions require the same volume; deps cannot be linked here, validate builds centrally)';
|
|
302
|
+
const msg = `Failed to link '${entryName}' into worktree: ${reason}${hint}`;
|
|
303
|
+
symlinkWarnings.push(msg);
|
|
304
|
+
logger.warn(`[worktree] ${msg}`);
|
|
210
305
|
}
|
|
211
306
|
};
|
|
212
307
|
// Guard: bare repos have no working tree
|
|
@@ -256,7 +351,15 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
|
|
|
256
351
|
// pln#480: auto-detect shared paths from stack markers + config overrides.
|
|
257
352
|
// `dist` intentionally excluded — build outputs must be per-worktree
|
|
258
353
|
// (EBUSY during clean:dist when MCP/extension holds a handle on junction target).
|
|
259
|
-
|
|
354
|
+
// pln#523: also link per-package node_modules for JS/TS monorepos so workers
|
|
355
|
+
// can build/typecheck sub-packages, not just the root. Set
|
|
356
|
+
// BRAINCLAW_NO_LINK_DEPS=1 to disable auto dependency linking (e.g. when the
|
|
357
|
+
// worktree lives on a different volume and central validation is preferred);
|
|
358
|
+
// explicit options.sharedPaths are still honored.
|
|
359
|
+
const linkDepsDisabled = process.env.BRAINCLAW_NO_LINK_DEPS === '1';
|
|
360
|
+
const detected = linkDepsDisabled
|
|
361
|
+
? []
|
|
362
|
+
: [...detectStackSharedPaths(mainWorktreePath), ...detectWorkspaceNodeModules(mainWorktreePath)];
|
|
260
363
|
const extra = options.sharedPaths ?? [];
|
|
261
364
|
const excluded = new Set(options.excludeShared ?? []);
|
|
262
365
|
const sharedPaths = [...new Set([...detected, ...extra])].filter((p) => !excluded.has(p));
|
|
@@ -282,6 +385,10 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
|
|
|
282
385
|
base_ref: baseRef,
|
|
283
386
|
reset_existing_branch: options.resetExistingBranch === true,
|
|
284
387
|
git_advice: 'git add ONLY specific files, NEVER git add -A.',
|
|
388
|
+
// pln#523: surface any shared-path link failures (e.g. node_modules junction
|
|
389
|
+
// that could not be created) so the worker / supervisor can see why a build
|
|
390
|
+
// might fail, instead of an invisible degradation.
|
|
391
|
+
...(symlinkWarnings.length > 0 ? { symlink_warnings: symlinkWarnings } : {}),
|
|
285
392
|
};
|
|
286
393
|
fs.writeFileSync(path.join(targetPath, '.brainclaw-worktree.json'), JSON.stringify(meta, null, 2));
|
|
287
394
|
return targetPath;
|
|
@@ -510,6 +617,30 @@ export function removeWorktree(mainWorktreePath, worktreePath, options = {}) {
|
|
|
510
617
|
export function pruneWorktrees(mainWorktreePath) {
|
|
511
618
|
runGit(['worktree', 'prune'], mainWorktreePath);
|
|
512
619
|
}
|
|
620
|
+
/**
|
|
621
|
+
* Files brainclaw itself writes into a worktree AT BIRTH — they are never user
|
|
622
|
+
* work and must not count as "uncommitted changes" when deciding whether a
|
|
623
|
+
* merged worktree can be GC'd:
|
|
624
|
+
* - `.gitignore` — copied from the main repo by createWorktree; on
|
|
625
|
+
* Windows autocrlf flags it as ` M .gitignore`,
|
|
626
|
+
* which previously made EVERY brainclaw worktree
|
|
627
|
+
* look dirty and skipped the clean forever.
|
|
628
|
+
* - `.brainclaw-worktree.json` — the sidecar metadata createWorktree writes.
|
|
629
|
+
* Combined with isSystemDirtyPath (.brainclaw/, .git/, agent config dirs).
|
|
630
|
+
*/
|
|
631
|
+
const WORKTREE_BIRTH_NOISE = new Set(['.gitignore', '.brainclaw-worktree.json']);
|
|
632
|
+
/**
|
|
633
|
+
* True when a worktree's `git status --porcelain=v1 -z` output contains ONLY
|
|
634
|
+
* brainclaw birth artifacts / coordination-store noise — i.e. no real user work
|
|
635
|
+
* would be lost by removing it. Empty output (fully clean) also returns true.
|
|
636
|
+
*/
|
|
637
|
+
export function worktreeHasOnlyBirthNoise(statusZStdout) {
|
|
638
|
+
const paths = parsePorcelainZ(statusZStdout);
|
|
639
|
+
return paths.every((p) => {
|
|
640
|
+
const norm = p.replace(/\\/g, '/');
|
|
641
|
+
return WORKTREE_BIRTH_NOISE.has(norm) || isSystemDirtyPath(norm);
|
|
642
|
+
});
|
|
643
|
+
}
|
|
513
644
|
/**
|
|
514
645
|
* Removes worktrees whose branch has been fully merged into the current branch
|
|
515
646
|
* (typically master/main after a merge). Also removes brainclaw-managed
|
|
@@ -539,10 +670,13 @@ export function cleanMergedWorktrees(mainWorktreePath, options = {}) {
|
|
|
539
670
|
if (!isMerged) {
|
|
540
671
|
continue;
|
|
541
672
|
}
|
|
542
|
-
// Check for uncommitted changes
|
|
673
|
+
// Check for uncommitted changes — but ignore brainclaw birth-noise
|
|
674
|
+
// (.gitignore autocrlf, the sidecar, coordination store). Without this,
|
|
675
|
+
// every merged brainclaw worktree looked dirty and was skipped forever,
|
|
676
|
+
// so `worktree clean` removed nothing and worktrees accumulated (pln#525).
|
|
543
677
|
if (!options.force) {
|
|
544
|
-
const status = runGit(['status', '--porcelain'], wt.path);
|
|
545
|
-
if (status.ok && status.stdout
|
|
678
|
+
const status = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=normal'], wt.path);
|
|
679
|
+
if (status.ok && !worktreeHasOnlyBirthNoise(status.stdout)) {
|
|
546
680
|
result.skipped.push({ path: wt.path, reason: 'uncommitted changes' });
|
|
547
681
|
continue;
|
|
548
682
|
}
|
|
@@ -552,7 +686,12 @@ export function cleanMergedWorktrees(mainWorktreePath, options = {}) {
|
|
|
552
686
|
continue;
|
|
553
687
|
}
|
|
554
688
|
try {
|
|
555
|
-
|
|
689
|
+
// Reaching here means EITHER options.force OR the birth-noise gate above
|
|
690
|
+
// passed (no real user work). In both cases git's own `worktree remove`
|
|
691
|
+
// must be forced: otherwise it refuses on the untracked sidecar /
|
|
692
|
+
// autocrlf .gitignore that we already classified as discardable noise
|
|
693
|
+
// (pln#525 — the refusal that left every merged worktree un-GC-able).
|
|
694
|
+
removeWorktree(mainWorktreePath, wt.path, { force: true });
|
|
556
695
|
result.removed.push(wt.path);
|
|
557
696
|
}
|
|
558
697
|
catch {
|
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.7.
|
|
2
|
+
// Source: brainclaw v1.7.3 on 2026-06-05T21:43:11.193Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.7.
|
|
5
|
-
"generated_at": "2026-06-
|
|
4
|
+
"version": "1.7.3",
|
|
5
|
+
"generated_at": "2026-06-05T21:43:11.193Z",
|
|
6
6
|
"tools": {
|
|
7
7
|
"count": 62,
|
|
8
8
|
"published_count": 61,
|
package/dist/facts.json
CHANGED
package/docs/cli.md
CHANGED
|
@@ -1933,7 +1933,7 @@ The default catalog is intentionally small and centred on the canonical grammar.
|
|
|
1933
1933
|
| `bclaw_work(intent)` | Start a session, load context, and (for `intent="execute"`) claim a scope in one call. Returns a compact payload by default (pass `compact: false` for the full context). |
|
|
1934
1934
|
| `bclaw_context(kind)` | Unified context read: `kind` is one of `memory`, `execution`, `board`, `board_summary`, or `delta`. |
|
|
1935
1935
|
|
|
1936
|
-
**Canonical grammar** — your main tool for working with memory entities (plan, decision, constraint, trap, handoff, runtime_note, candidate, claim, action, assignment, agent_run):
|
|
1936
|
+
**Canonical grammar** — your main tool for working with memory entities (plan, decision, constraint, trap, handoff, runtime_note, candidate, sequence, claim, action, assignment, agent_run):
|
|
1937
1937
|
|
|
1938
1938
|
| Tool | Purpose |
|
|
1939
1939
|
|---|---|
|
|
@@ -1944,13 +1944,17 @@ The default catalog is intentionally small and centred on the canonical grammar.
|
|
|
1944
1944
|
| `bclaw_remove(entity, id, purge?)` | Soft-delete (or purge) an entity. |
|
|
1945
1945
|
| `bclaw_transition(entity, id, to)` | Change an entity's status (e.g. plan `todo` → `in_progress` → `done`, candidate `pending` → `accepted`). |
|
|
1946
1946
|
|
|
1947
|
-
**Multi-agent coordination** (escalation path when delegating to other agents):
|
|
1947
|
+
**Multi-agent coordination** (escalation path when delegating to other agents):
|
|
1948
1948
|
|
|
1949
1949
|
| Tool | Purpose |
|
|
1950
1950
|
|---|---|
|
|
1951
1951
|
| `bclaw_coordinate(intent)` | Assign, consult, review, reroute, or summarize across agents. Pass `open_loop: true` on `intent="review"` to also dispatch the reviewer turn. |
|
|
1952
1952
|
| `bclaw_dispatch(intent)` | Parallelize execute across a sequence's lanes (analysis / execute / review). |
|
|
1953
|
-
| `bclaw_loop(intent)` | Drive a turn in an existing multi-turn loop (`turn`, `complete_turn`, `advance`, `close`). Do not call `bclaw_loop(intent="open")` directly without dispatch — use `bclaw_coordinate(intent="review", open_loop: true)` instead. |
|
|
1953
|
+
| `bclaw_loop(intent)` | Drive a turn in an existing multi-turn loop (`turn`, `complete_turn`, `advance`, `close`). Do not call `bclaw_loop(intent="open")` directly without dispatch — use `bclaw_coordinate(intent="review", open_loop: true)` instead. |
|
|
1954
|
+
|
|
1955
|
+
**Sequences**:
|
|
1956
|
+
|
|
1957
|
+
`bclaw_list_sequences`, `bclaw_create_sequence`, `bclaw_update_sequence`, `bclaw_delete_sequence`. Sequence items use `{ planId, stepId?, rank, hard_after?, soft_after?, lane?, scope_hint?, rationale? }`. Create/update a sequence to `active`, inspect readiness with `bclaw_dispatch(intent="analysis")`, then dispatch ready lanes with `bclaw_dispatch(intent="execute")`.
|
|
1954
1958
|
|
|
1955
1959
|
**Session, claims, plans, handoffs**:
|
|
1956
1960
|
|
|
@@ -1960,7 +1964,7 @@ The default catalog is intentionally small and centred on the canonical grammar.
|
|
|
1960
1964
|
|
|
1961
1965
|
`bclaw_write_note`, `bclaw_quick_capture`, `bclaw_search`, `bclaw_setup`, `bclaw_bootstrap`, `bclaw_switch`, `bclaw_release_notes`, `bclaw_doctor`.
|
|
1962
1966
|
|
|
1963
|
-
**Legacy per-entity tools** (`bclaw_list_plans` / `bclaw_list_claims` / `bclaw_list_candidates
|
|
1967
|
+
**Legacy per-entity tools** (`bclaw_list_plans` / `bclaw_list_claims` / `bclaw_list_candidates`, `bclaw_get_context` / `bclaw_get_execution_context` / `bclaw_get_agent_board`, `bclaw_create_plan` / `bclaw_create_candidate`, `bclaw_update_plan` / `bclaw_update_handoff` / `bclaw_update_memory`, `bclaw_read_handoff` / `bclaw_delete_memory`, `bclaw_accept` / `bclaw_reject`, `bclaw_dispatch_analysis` / `bclaw_dispatch_review`, and others) were removed from the discoverable catalog at v1.0. Direct calls still succeed as a migration escape hatch but emit a redirect warning pointing at the canonical grammar. The full removal list and replacement map lives in [`docs/mcp-schema-changelog.md`](mcp-schema-changelog.md) under the v1.0 "Removed" section. Raw MCP clients can request the full advanced catalog with `tools/list` params `{ catalog: "all" }`.
|
|
1964
1968
|
|
|
1965
1969
|
---
|
|
1966
1970
|
|
package/docs/integrations/mcp.md
CHANGED
|
@@ -43,10 +43,10 @@ All 59 published MCP tools are discoverable via `tools/list`. Each tool carries
|
|
|
43
43
|
Every tool has one of three tiers in its `annotations.tier` field:
|
|
44
44
|
|
|
45
45
|
- **facade** — High-level entry points for agents that don't need granular access. Start here.
|
|
46
|
-
- **standard** — Day-to-day coordination tools: plans, claims, messaging, dispatch, review, memory. Returned by default alongside facades.
|
|
47
|
-
- **advanced** — Specialized governance, audit, registry,
|
|
48
|
-
|
|
49
|
-
By default, `tools/list` returns **facade + standard** tools (
|
|
46
|
+
- **standard** — Day-to-day coordination tools: plans, claims, messaging, sequences, dispatch, review, memory. Returned by default alongside facades.
|
|
47
|
+
- **advanced** — Specialized governance, audit, registry, and power tools.
|
|
48
|
+
|
|
49
|
+
By default, `tools/list` returns **facade + standard** tools (38 tools). To get all tools including advanced, pass `{ "catalog": "all" }`, `{ "include": "all" }`, or `{ "advanced": true }`. To filter by a single tier, pass `{ "tier": "facade" }`, `{ "tier": "standard" }`, or `{ "tier": "advanced" }`.
|
|
50
50
|
|
|
51
51
|
Published tools remain callable regardless of catalog filtering — the tier only affects discovery via `tools/list`.
|
|
52
52
|
|
|
@@ -90,9 +90,13 @@ Each tool also has an `annotations.category` field: `session`, `context`, `memor
|
|
|
90
90
|
| `bclaw_session_end` | session | End session, optionally auto-reflect notes or handoffs |
|
|
91
91
|
| `bclaw_add_step` | coordination | Add a sub-step to a plan item |
|
|
92
92
|
| `bclaw_complete_step` | coordination | Mark a plan sub-step as done |
|
|
93
|
-
| `bclaw_update_step` | coordination | Update a plan sub-step's status, text, or assignee |
|
|
94
|
-
| `bclaw_delete_step` | coordination | Remove a sub-step from a plan |
|
|
95
|
-
| `
|
|
93
|
+
| `bclaw_update_step` | coordination | Update a plan sub-step's status, text, or assignee |
|
|
94
|
+
| `bclaw_delete_step` | coordination | Remove a sub-step from a plan |
|
|
95
|
+
| `bclaw_list_sequences` | coordination | Coordination sequence listing |
|
|
96
|
+
| `bclaw_create_sequence` | coordination | Create a coordination sequence |
|
|
97
|
+
| `bclaw_update_sequence` | coordination | Update a sequence's status, metadata, or items |
|
|
98
|
+
| `bclaw_delete_sequence` | coordination | Delete a sequence by ID |
|
|
99
|
+
| `bclaw_correct_handoff` | coordination | Write an immutable correction handoff that supersedes an earlier one |
|
|
96
100
|
| `bclaw_assignment_update` | coordination | Report assignment lifecycle status |
|
|
97
101
|
| `bclaw_assignment_action` | coordination | Resolve or reject a pending ActionRequired item |
|
|
98
102
|
| `bclaw_harvest_candidates` | coordination | Harvest sandboxed worktree candidate files into the main project store |
|
|
@@ -123,13 +127,9 @@ Each tool also has an `annotations.category` field: `session`, `context`, `memor
|
|
|
123
127
|
| `bclaw_who` | discovery | List active agent sessions on workspace |
|
|
124
128
|
| `bclaw_add_capability` | discovery | Register a project capability |
|
|
125
129
|
| `bclaw_add_tool` | discovery | Register a project tool |
|
|
126
|
-
| `
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
129
|
-
| `bclaw_get_thread` | coordination | Get all messages in a thread across inboxes |
|
|
130
|
-
| `bclaw_delete_plan` | coordination | Delete a plan item by ID |
|
|
131
|
-
| `bclaw_delete_sequence` | coordination | Delete a sequence by ID |
|
|
132
|
-
| `bclaw_delete_memory` | memory | Delete a memory item by ID |
|
|
130
|
+
| `bclaw_get_thread` | coordination | Get all messages in a thread across inboxes |
|
|
131
|
+
| `bclaw_delete_plan` | coordination | Delete a plan item by ID |
|
|
132
|
+
| `bclaw_delete_memory` | memory | Delete a memory item by ID |
|
|
133
133
|
| `bclaw_update_memory` | memory | Update a memory item's text or metadata |
|
|
134
134
|
| `bclaw_compact` | memory | LLM-driven semantic memory compaction (two-phase) |
|
|
135
135
|
|
|
@@ -157,11 +157,44 @@ for the full 1.0.0 changelog.
|
|
|
157
157
|
| `bclaw_transition(entity, id, to, reason?)` | State machine transition with side-effect tags | `bclaw_accept`, `bclaw_reject`, status-update flows |
|
|
158
158
|
|
|
159
159
|
Supported entities: plan, decision, constraint, trap, handoff,
|
|
160
|
-
runtime_note, candidate, claim, action, assignment, agent_run
|
|
160
|
+
runtime_note, candidate, sequence, claim, action, assignment, agent_run
|
|
161
161
|
(with assignment lifecycle now writable through `bclaw_transition` and
|
|
162
162
|
`bclaw_remove`; `agent_run` remains read-only). Declarative transition matrix +
|
|
163
163
|
updatable field list live in [src/core/entity-registry.ts](../../src/core/entity-registry.ts).
|
|
164
164
|
|
|
165
|
+
#### Sequence workflow
|
|
166
|
+
|
|
167
|
+
Sequences are in the default catalog because they are the normal path
|
|
168
|
+
for parallel agent work. Use the specialized sequence tools when
|
|
169
|
+
building lanes; the canonical grammar can still read or patch the same
|
|
170
|
+
entity when that is more convenient.
|
|
171
|
+
|
|
172
|
+
Sequence items use this shape:
|
|
173
|
+
|
|
174
|
+
```json
|
|
175
|
+
{
|
|
176
|
+
"planId": "pln_xxx",
|
|
177
|
+
"stepId": "stp_xxx",
|
|
178
|
+
"rank": 1,
|
|
179
|
+
"hard_after": [],
|
|
180
|
+
"soft_after": [],
|
|
181
|
+
"lane": "api",
|
|
182
|
+
"scope_hint": "src/api/**",
|
|
183
|
+
"rationale": "Independent API lane"
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`planId` and `rank` are required. `stepId` is optional and narrows the
|
|
188
|
+
sequence item to a specific plan step. `hard_after` blocks readiness
|
|
189
|
+
until predecessor items complete; `soft_after` is advisory.
|
|
190
|
+
|
|
191
|
+
Typical MCP flow:
|
|
192
|
+
|
|
193
|
+
1. `bclaw_create_sequence({ name, status: "draft", items })`
|
|
194
|
+
2. `bclaw_update_sequence({ id, status: "active" })`
|
|
195
|
+
3. `bclaw_dispatch({ intent: "analysis" })`
|
|
196
|
+
4. `bclaw_dispatch({ intent: "execute", agents: [...] })`
|
|
197
|
+
|
|
165
198
|
For assignments specifically:
|
|
166
199
|
- `bclaw_transition(entity="assignment", id=..., to="cancelled", reason=...)` is the canonical supervisor/admin cancel path.
|
|
167
200
|
- `bclaw_remove(entity="assignment", id=...)` archives by cancelling the assignment.
|