brainclaw 1.7.2 → 1.7.4

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.
@@ -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(),
@@ -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));
@@ -267,6 +370,14 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
267
370
  // Symlinking .brainclaw/ causes hooks and session_start to trigger on the
268
371
  // shared store, creating session conflicts and potentially blocking agents
269
372
  // (especially Claude CLI which auto-detects .brainclaw/ presence).
373
+ // pln#479: opt-in per-worktree typecheck gate. Off by default — on large
374
+ // monorepos `tsc` is slow and a per-commit gate would be punishing — enable
375
+ // with BRAINCLAW_WORKTREE_TYPECHECK_GATE=1. Isolated to this worktree, so the
376
+ // main repo's commits are never affected.
377
+ let typecheckGate;
378
+ if (process.env.BRAINCLAW_WORKTREE_TYPECHECK_GATE === '1') {
379
+ typecheckGate = installWorktreeTypecheckGate(mainWorktreePath, targetPath);
380
+ }
270
381
  const mainGitignorePath = path.join(mainWorktreePath, '.gitignore');
271
382
  const targetGitignorePath = path.join(targetPath, '.gitignore');
272
383
  if (fs.existsSync(mainGitignorePath)) {
@@ -282,10 +393,87 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
282
393
  base_ref: baseRef,
283
394
  reset_existing_branch: options.resetExistingBranch === true,
284
395
  git_advice: 'git add ONLY specific files, NEVER git add -A.',
396
+ // pln#523: surface any shared-path link failures (e.g. node_modules junction
397
+ // that could not be created) so the worker / supervisor can see why a build
398
+ // might fail, instead of an invisible degradation.
399
+ ...(symlinkWarnings.length > 0 ? { symlink_warnings: symlinkWarnings } : {}),
400
+ // pln#479: record whether the per-worktree typecheck gate is active.
401
+ ...(typecheckGate?.installed ? { typecheck_gate: true } : {}),
285
402
  };
286
403
  fs.writeFileSync(path.join(targetPath, '.brainclaw-worktree.json'), JSON.stringify(meta, null, 2));
287
404
  return targetPath;
288
405
  }
406
+ /** Directory (relative to a worktree root) holding the per-worktree git hooks. */
407
+ export const WORKTREE_HOOKS_DIRNAME = '.brainclaw-hooks';
408
+ /**
409
+ * The pre-commit gate body (pln#479). Runs via `node -e` — same SIGPIPE-avoiding
410
+ * pattern as install-hooks.ts. Git runs hooks with cwd = worktree root, so the
411
+ * relative paths resolve there. `node` and the tsc entry point are invoked with
412
+ * forward-slash relative paths to stay cross-platform (no quoting/backslash
413
+ * pitfalls). If typescript is absent the gate degrades to a warning rather than
414
+ * blocking — a tooling gap must not trap a worker.
415
+ */
416
+ export function buildTypecheckPreCommitScript() {
417
+ return `#!/bin/sh
418
+ # brainclaw worktree typecheck gate (pln#479) — do not edit manually.
419
+ # Blocks the commit when 'tsc --noEmit' fails. Bypass: git commit --no-verify.
420
+ exec node -e "
421
+ const fs = require('fs');
422
+ const { execSync } = require('child_process');
423
+ if (!fs.existsSync('tsconfig.json')) process.exit(0);
424
+ if (!fs.existsSync('node_modules/typescript/bin/tsc')) {
425
+ process.stderr.write('\\\\n[brainclaw] typecheck gate: typescript not found in worktree node_modules — skipping (commit allowed).\\\\n');
426
+ process.exit(0);
427
+ }
428
+ try {
429
+ execSync('node node_modules/typescript/bin/tsc --noEmit', { stdio: 'inherit' });
430
+ } catch (e) {
431
+ process.stderr.write('\\\\n[brainclaw] commit blocked: tsc --noEmit reported type errors (above). Fix them, or bypass with: git commit --no-verify\\\\n\\\\n');
432
+ process.exit(1);
433
+ }
434
+ " 2>&1 || exit $?
435
+ `;
436
+ }
437
+ /**
438
+ * pln#479 — install an ISOLATED pre-commit gate in a dispatched worktree that
439
+ * blocks a commit when `tsc --noEmit` fails, so a worker cannot land code that
440
+ * breaks the type-check (observed: workers committing strict-mode-broken TS that
441
+ * only blew up at merge/build time, pln#466).
442
+ *
443
+ * Isolation is the crux: git hooks are shared across all worktrees of a repo by
444
+ * default, so we must NOT write into the common hooks dir — that would impose
445
+ * tsc on the human's main-repo commits too. Instead we point THIS worktree's
446
+ * `core.hooksPath` at a worktree-local dir via the `--worktree` config scope
447
+ * (enabling `extensions.worktreeConfig`), which leaves the main repo's hook
448
+ * setup completely untouched and is torn down with the worktree.
449
+ *
450
+ * No-ops when the worktree has no `tsconfig.json`. Depends on pln#523 having
451
+ * linked `node_modules` so `tsc` resolves.
452
+ */
453
+ export function installWorktreeTypecheckGate(mainWorktreePath, worktreePath) {
454
+ if (!fs.existsSync(path.join(worktreePath, 'tsconfig.json'))) {
455
+ return { installed: false, reason: 'no tsconfig.json — not a TypeScript worktree' };
456
+ }
457
+ try {
458
+ const hooksDir = path.join(worktreePath, WORKTREE_HOOKS_DIRNAME);
459
+ fs.mkdirSync(hooksDir, { recursive: true });
460
+ fs.writeFileSync(path.join(hooksDir, 'pre-commit'), buildTypecheckPreCommitScript(), {
461
+ encoding: 'utf-8',
462
+ mode: 0o755,
463
+ });
464
+ // Enable per-worktree config on the repo (idempotent, additive) so the
465
+ // hooksPath override stays scoped to THIS worktree only.
466
+ runGit(['config', 'extensions.worktreeConfig', 'true'], mainWorktreePath);
467
+ const set = runGit(['config', '--worktree', 'core.hooksPath', gitPath(hooksDir)], worktreePath);
468
+ if (!set.ok) {
469
+ return { installed: false, reason: `git config --worktree core.hooksPath failed: ${set.stderr.trim()}` };
470
+ }
471
+ return { installed: true };
472
+ }
473
+ catch (err) {
474
+ return { installed: false, reason: err instanceof Error ? err.message : String(err) };
475
+ }
476
+ }
289
477
  /**
290
478
  * Lists all git worktrees for the given repo and enriches them with
291
479
  * brainclaw metadata if available.
@@ -510,6 +698,30 @@ export function removeWorktree(mainWorktreePath, worktreePath, options = {}) {
510
698
  export function pruneWorktrees(mainWorktreePath) {
511
699
  runGit(['worktree', 'prune'], mainWorktreePath);
512
700
  }
701
+ /**
702
+ * Files brainclaw itself writes into a worktree AT BIRTH — they are never user
703
+ * work and must not count as "uncommitted changes" when deciding whether a
704
+ * merged worktree can be GC'd:
705
+ * - `.gitignore` — copied from the main repo by createWorktree; on
706
+ * Windows autocrlf flags it as ` M .gitignore`,
707
+ * which previously made EVERY brainclaw worktree
708
+ * look dirty and skipped the clean forever.
709
+ * - `.brainclaw-worktree.json` — the sidecar metadata createWorktree writes.
710
+ * Combined with isSystemDirtyPath (.brainclaw/, .git/, agent config dirs).
711
+ */
712
+ const WORKTREE_BIRTH_NOISE = new Set(['.gitignore', '.brainclaw-worktree.json']);
713
+ /**
714
+ * True when a worktree's `git status --porcelain=v1 -z` output contains ONLY
715
+ * brainclaw birth artifacts / coordination-store noise — i.e. no real user work
716
+ * would be lost by removing it. Empty output (fully clean) also returns true.
717
+ */
718
+ export function worktreeHasOnlyBirthNoise(statusZStdout) {
719
+ const paths = parsePorcelainZ(statusZStdout);
720
+ return paths.every((p) => {
721
+ const norm = p.replace(/\\/g, '/');
722
+ return WORKTREE_BIRTH_NOISE.has(norm) || isSystemDirtyPath(norm);
723
+ });
724
+ }
513
725
  /**
514
726
  * Removes worktrees whose branch has been fully merged into the current branch
515
727
  * (typically master/main after a merge). Also removes brainclaw-managed
@@ -539,10 +751,13 @@ export function cleanMergedWorktrees(mainWorktreePath, options = {}) {
539
751
  if (!isMerged) {
540
752
  continue;
541
753
  }
542
- // Check for uncommitted changes
754
+ // Check for uncommitted changes — but ignore brainclaw birth-noise
755
+ // (.gitignore autocrlf, the sidecar, coordination store). Without this,
756
+ // every merged brainclaw worktree looked dirty and was skipped forever,
757
+ // so `worktree clean` removed nothing and worktrees accumulated (pln#525).
543
758
  if (!options.force) {
544
- const status = runGit(['status', '--porcelain'], wt.path);
545
- if (status.ok && status.stdout.trim().length > 0) {
759
+ const status = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=normal'], wt.path);
760
+ if (status.ok && !worktreeHasOnlyBirthNoise(status.stdout)) {
546
761
  result.skipped.push({ path: wt.path, reason: 'uncommitted changes' });
547
762
  continue;
548
763
  }
@@ -552,7 +767,12 @@ export function cleanMergedWorktrees(mainWorktreePath, options = {}) {
552
767
  continue;
553
768
  }
554
769
  try {
555
- removeWorktree(mainWorktreePath, wt.path, { force: options.force });
770
+ // Reaching here means EITHER options.force OR the birth-noise gate above
771
+ // passed (no real user work). In both cases git's own `worktree remove`
772
+ // must be forced: otherwise it refuses on the untracked sidecar /
773
+ // autocrlf .gitignore that we already classified as discardable noise
774
+ // (pln#525 — the refusal that left every merged worktree un-GC-able).
775
+ removeWorktree(mainWorktreePath, wt.path, { force: true });
556
776
  result.removed.push(wt.path);
557
777
  }
558
778
  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 on 2026-06-04T21:46:11.832Z
2
+ // Source: brainclaw v1.7.4 on 2026-06-08T21:59:54.110Z
3
3
  export const FACTS = {
4
- "version": "1.7.2",
5
- "generated_at": "2026-06-04T21:46:11.832Z",
4
+ "version": "1.7.4",
5
+ "generated_at": "2026-06-08T21:59:54.110Z",
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.2",
3
- "generated_at": "2026-06-04T21:46:11.832Z",
2
+ "version": "1.7.4",
3
+ "generated_at": "2026-06-08T21:59:54.110Z",
4
4
  "tools": {
5
5
  "count": 62,
6
6
  "published_count": 61,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainclaw",
3
- "version": "1.7.2",
3
+ "version": "1.7.4",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "bin": {