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.
@@ -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
@@ -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
@@ -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
- // Non-fatal - shared paths are an optimization for agent worktrees
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
- const detected = detectStackSharedPaths(mainWorktreePath);
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.trim().length > 0) {
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
- removeWorktree(mainWorktreePath, wt.path, { force: options.force });
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.1 on 2026-06-02T10:24:49.702Z
2
+ // Source: brainclaw v1.7.3 on 2026-06-05T21:43:11.193Z
3
3
  export const FACTS = {
4
- "version": "1.7.1",
5
- "generated_at": "2026-06-02T10:24:49.702Z",
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
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.7.1",
3
- "generated_at": "2026-06-02T10:24:49.702Z",
2
+ "version": "1.7.3",
3
+ "generated_at": "2026-06-05T21:43:11.193Z",
4
4
  "tools": {
5
5
  "count": 62,
6
6
  "published_count": 61,
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` / `bclaw_list_sequences`, `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" }`.
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
 
@@ -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, sequences, and power tools.
48
-
49
- By default, `tools/list` returns **facade + standard** tools (34 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" }`.
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
- | `bclaw_correct_handoff` | coordination | Write an immutable correction handoff that supersedes an earlier one |
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
- | `bclaw_list_sequences` | coordination | Coordination sequence listing |
127
- | `bclaw_create_sequence` | coordination | Create a coordination sequence |
128
- | `bclaw_update_sequence` | coordination | Update a sequence's status, metadata, or items |
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.