brainclaw 1.15.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +25 -4
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-capture.js +209 -0
  4. package/dist/cli/register-code-map.js +19 -0
  5. package/dist/cli/register-coordination.js +472 -0
  6. package/dist/cli/register-federation.js +258 -0
  7. package/dist/cli/register-lifecycle.js +436 -0
  8. package/dist/cli/register-memory-context.js +502 -0
  9. package/dist/cli/register-planning.js +167 -0
  10. package/dist/cli/register-review.js +149 -0
  11. package/dist/cli/shared.js +5 -0
  12. package/dist/cli.js +212 -2183
  13. package/dist/commands/dispatch-watch.js +25 -2
  14. package/dist/commands/harvest.js +107 -20
  15. package/dist/commands/mcp-catalog.js +1438 -0
  16. package/dist/commands/mcp-contract.js +33 -0
  17. package/dist/commands/mcp-presentation.js +27 -0
  18. package/dist/commands/mcp-read-handlers.js +72 -36
  19. package/dist/commands/mcp-write-admin.js +328 -0
  20. package/dist/commands/mcp-write-claims.js +864 -0
  21. package/dist/commands/mcp-write-coordination.js +1825 -0
  22. package/dist/commands/mcp-write-entities.js +620 -0
  23. package/dist/commands/mcp-write-memory.js +451 -0
  24. package/dist/commands/mcp-write-sequences.js +116 -0
  25. package/dist/commands/mcp-write-support.js +367 -0
  26. package/dist/commands/mcp.js +261 -5584
  27. package/dist/commands/update-handoff.js +28 -42
  28. package/dist/core/agent-capability.js +38 -16
  29. package/dist/core/agent-files.js +54 -3
  30. package/dist/core/agent-integrations.js +1 -0
  31. package/dist/core/coordination.js +5 -2
  32. package/dist/core/cross-project.js +35 -1
  33. package/dist/core/dispatcher.js +67 -27
  34. package/dist/core/entity-operations.js +335 -12
  35. package/dist/core/entity-registry.js +72 -9
  36. package/dist/core/execution.js +28 -4
  37. package/dist/core/facade-schema.js +18 -4
  38. package/dist/core/handoff-review.js +35 -0
  39. package/dist/core/protocol-tool-policy.js +113 -0
  40. package/dist/core/review-loop-close.js +184 -0
  41. package/dist/core/review-loop-turn-dispatch.js +183 -0
  42. package/dist/core/schema.js +24 -2
  43. package/dist/core/security-detectors.js +35 -6
  44. package/dist/core/security.js +32 -12
  45. package/dist/core/worktree.js +274 -12
  46. package/dist/facts.js +13 -11
  47. package/dist/facts.json +12 -10
  48. package/docs/PROTOCOL.md +7 -3
  49. package/docs/concepts/coordinator-runbook.md +3 -0
  50. package/docs/concepts/dispatch-lifecycle.md +4 -4
  51. package/docs/concepts/loop-engine.md +6 -2
  52. package/docs/concepts/troubleshooting.md +1 -1
  53. package/docs/integrations/codex.md +22 -6
  54. package/docs/integrations/overview.md +1 -1
  55. package/docs/mcp-schema-changelog.md +137 -2
  56. package/docs/playbooks/orchestration.md +1 -1
  57. package/docs/product/entity-model-audit.md +3 -2
  58. package/docs/security.md +22 -1
  59. package/package.json +3 -1
@@ -960,8 +960,9 @@ export const RuntimeEventTypeSchema = z.enum([
960
960
  /**
961
961
  * pln#526 — LANE-RESULT convention. A dispatched worker writes a single
962
962
  * `LANE-RESULT.json` at its worktree root as its final step (a fallback that
963
- * works even when bclaw_assignment_update / MCP is unavailable, e.g. sandboxed
964
- * agents). The coordinator ingests it with `brainclaw harvest <assignment_id>`.
963
+ * works even when bclaw_assignment_update / MCP is unavailable in the worker's
964
+ * environment, e.g. a genuinely MCP-less agent). The coordinator ingests it with
965
+ * `brainclaw harvest <assignment_id>`.
965
966
  */
966
967
  export const LaneResultSchema = z.object({
967
968
  assignment_id: z.string(),
@@ -973,6 +974,17 @@ export const LaneResultSchema = z.object({
973
974
  files_changed: z.array(z.string()).optional(),
974
975
  /** Free-form notes (blockers, follow-ups). */
975
976
  notes: z.string().optional(),
977
+ /**
978
+ * pln#628 Focus 4B — review-loop verdict. A worker running a review-loop turn
979
+ * sets this to signal whether the change is good to merge (`approve`) or needs
980
+ * fixes (`request_changes`). The coordinator's harvest maps it onto a loop
981
+ * `verdict` artifact so `reviewer_green` can fire and the loop auto-closes
982
+ * without a human driving complete_turn/advance by hand. Absent on
983
+ * non-review lanes — harvest simply skips the loop-close callback then.
984
+ */
985
+ review_verdict: z.enum(['approve', 'request_changes']).optional(),
986
+ /** One-line rationale accompanying review_verdict (shown in the verdict artifact). */
987
+ review_summary: z.string().optional(),
976
988
  });
977
989
  export const RuntimeEventSchema = z.object({
978
990
  id: z.string(),
@@ -1442,6 +1454,16 @@ export const ConfigSchema = z.object({
1442
1454
  worktree: z.object({
1443
1455
  shared_paths: z.array(z.string()).default([]),
1444
1456
  exclude_shared: z.array(z.string()).default([]),
1457
+ /**
1458
+ * How a dispatched worktree gets its JS dependencies (`node_modules`).
1459
+ * trp_37b05a15 — the default `link` (out-of-root junction) is rejected by
1460
+ * `next dev` / Turbopack. Set `install` (real per-worktree install, native
1461
+ * package manager) or `copy` (recursive copy from the main tree) for a
1462
+ * Turbopack-compatible in-root `node_modules`; `none` provisions no deps.
1463
+ * Env `BRAINCLAW_WORKTREE_DEPS_MODE` overrides this; `BRAINCLAW_NO_LINK_DEPS=1`
1464
+ * still forces `none`.
1465
+ */
1466
+ deps_mode: z.enum(['link', 'install', 'copy', 'none']).optional(),
1445
1467
  }).optional(),
1446
1468
  // Event-log store (pln#543). Absent ⇒ off — fresh and existing stores keep
1447
1469
  // today's behavior; the journal only activates when explicitly set here (or
@@ -52,7 +52,7 @@ export function runStructuralDetectors(text, disabled) {
52
52
  out.push({
53
53
  detectorId: d.id,
54
54
  label: d.label,
55
- excerpt: truncate(m[0]),
55
+ excerpt: maskSecret(m[0]),
56
56
  });
57
57
  }
58
58
  }
@@ -113,13 +113,42 @@ export function runEntropyDetector(text, options = {}) {
113
113
  const context = text.slice(start, end);
114
114
  if (!SECRET_KEYWORD_CONTEXT.test(context))
115
115
  continue;
116
- out.push({ excerpt: truncate(token), entropy: Math.round(entropy * 100) / 100 });
116
+ out.push({ excerpt: maskSecret(token), entropy: Math.round(entropy * 100) / 100 });
117
117
  }
118
118
  return out;
119
119
  }
120
- function truncate(s, maxLen = 48) {
121
- if (s.length <= maxLen)
122
- return s;
123
- return s.slice(0, Math.max(8, maxLen / 2)) + '…' + s.slice(-Math.max(4, maxLen / 4));
120
+ /**
121
+ * Irreversibly mask a matched secret for display.
122
+ *
123
+ * The previous behavior truncated the match to ~48 chars, which returned
124
+ * short secrets (GitHub PATs are 40 chars, AWS key IDs are 20) verbatim in
125
+ * warning messages and logs. Masking keeps just enough to identify the
126
+ * token family without ever exposing recoverable material. Splitting is
127
+ * done per Unicode code point, so surrogate pairs are never cut in half.
128
+ *
129
+ * - matches of 2 code points or fewer: `***` alone (exposing even the
130
+ * first code point would reveal most or all of the value);
131
+ * - matches of 3–8 code points: first code point + `***`;
132
+ * - longer matches: at most ⌊length/3⌋ code points are exposed, capped
133
+ * at 6, split prefix-heavy (up to 4 leading — enough to identify
134
+ * `ghp_`, `AKIA`, `sk_l` — the remainder trailing) around a fixed
135
+ * `…***…` marker.
136
+ *
137
+ * The exposure budget grows smoothly with the match length (no cliff at
138
+ * the short/long boundary) and never reveals more than a third of a
139
+ * match longer than 8 code points.
140
+ */
141
+ export function maskSecret(s) {
142
+ const cp = Array.from(s);
143
+ if (cp.length === 0)
144
+ return '';
145
+ if (cp.length <= 2)
146
+ return '***';
147
+ if (cp.length <= 8)
148
+ return cp[0] + '***';
149
+ const exposed = Math.min(6, Math.floor(cp.length / 3));
150
+ const lead = Math.min(4, exposed - 1);
151
+ const trail = exposed - lead;
152
+ return cp.slice(0, lead).join('') + '…***…' + cp.slice(cp.length - trail).join('');
124
153
  }
125
154
  //# sourceMappingURL=security-detectors.js.map
@@ -1,14 +1,27 @@
1
- import { runEntropyDetector, runStructuralDetectors } from './security-detectors.js';
1
+ import { maskSecret, runEntropyDetector, runStructuralDetectors } from './security-detectors.js';
2
2
  /**
3
- * Scan a text string for sensitive content. Three signal layers run:
4
- * 1. User-configured regex patterns from `config.redaction.patterns`
5
- * (the legacy MVP behavior).
6
- * 2. Structural detectors — exact token shapes for GitHub PATs, AWS
7
- * access keys, JWTs, etc. High precision; on by default.
8
- * 3. Entropy detector — flags high-entropy token-like substrings near
9
- * a sensitive keyword. Tunable, on by default.
3
+ * Scan a text string for sensitive content. Four independent signal layers
4
+ * run, each with its own enable-gate (S4 semantics, pln#623):
10
5
  *
11
- * In strict mode all signals escalate to `block`; otherwise `warn`.
6
+ * 1. Redaction patterns — user-configured regexes from
7
+ * `config.redaction.patterns`. Gate: `config.redaction.enabled` (whole
8
+ * scan short-circuits off when false). The legacy MVP behavior.
9
+ * 2. Structural detectors — exact token shapes for GitHub PATs, AWS access
10
+ * keys, JWTs, etc. High precision. Gate: `security.token_detection.enabled`
11
+ * (default on); individual detectors via `token_detection.detectors[id]`.
12
+ * 3. Entropy detector — high-Shannon-entropy token-like substrings near a
13
+ * secret keyword. Gate: `security.token_detection.entropy.enabled` (nested
14
+ * under the token_detection gate; default on).
15
+ * 4. Sensitive paths — literal mentions of `config.sensitive_paths` entries
16
+ * (`.env`, `secrets/`, …). Gate: `security.block_sensitive_paths` (default
17
+ * on).
18
+ *
19
+ * LEVEL (uniform across ALL four layers): a match surfaces as `warn`, and
20
+ * escalates to `block` when `security.strict_redaction` is true (mode: strict).
21
+ * Strict mode blocks every signal uniformly — there is no per-layer level
22
+ * override. Detected/redacted excerpts in messages are always irreversibly
23
+ * masked (see maskSecret); the redaction pattern itself is referenced by index
24
+ * and masked, never echoed.
12
25
  */
13
26
  export function scanText(text, config) {
14
27
  const warnings = [];
@@ -16,7 +29,7 @@ export function scanText(text, config) {
16
29
  return warnings;
17
30
  const isStrict = config.security?.strict_redaction ?? false;
18
31
  const level = isStrict ? 'block' : 'warn';
19
- for (const pattern of config.redaction.patterns) {
32
+ for (const [i, pattern] of config.redaction.patterns.entries()) {
20
33
  try {
21
34
  // Strip Python-style inline flags (?i) etc. since we always use 'i' flag
22
35
  const cleanPattern = pattern.replace(/^\(\?[gimsuy]+\)/g, '');
@@ -24,7 +37,9 @@ export function scanText(text, config) {
24
37
  if (re.test(text)) {
25
38
  warnings.push({
26
39
  level,
27
- message: `Possible sensitive content matching pattern '${pattern}' found in text`,
40
+ // The configured pattern may itself be a literal secret value, so
41
+ // it is referenced by index and masked, never echoed verbatim.
42
+ message: `Possible sensitive content matching redaction pattern #${i} ('${maskSecret(pattern)}') found in text`,
28
43
  });
29
44
  }
30
45
  }
@@ -61,7 +76,12 @@ export function scanText(text, config) {
61
76
  for (const sp of config.sensitive_paths) {
62
77
  if (text.includes(sp)) {
63
78
  warnings.push({
64
- level: 'warn',
79
+ // S3 (pln#623): the level is config-derived, not hardcoded. Like the
80
+ // three detector layers above, a sensitive-path match surfaces as a
81
+ // `warn` normally and escalates to `block` under strict_redaction —
82
+ // strict mode blocks EVERY signal, uniformly. `block_sensitive_paths`
83
+ // remains the enable-gate for this layer (default on).
84
+ level,
65
85
  message: `Sensitive path '${sp}' mentioned in text`,
66
86
  });
67
87
  }
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import yaml from 'yaml';
7
7
  import { logger } from './logger.js';
8
+ import { loadConfig } from './config.js';
8
9
  import { parsePorcelainZ, isSystemDirtyPath } from './dirty-scope.js';
9
10
  /** Normalizes a path for use in git CLI arguments (forward slashes on Windows). */
10
11
  function gitPath(p) {
@@ -93,6 +94,67 @@ export function detectStackSharedPaths(projectRoot) {
93
94
  }
94
95
  return [...result];
95
96
  }
97
+ const WORKTREE_DEPS_MODES = ['link', 'install', 'copy', 'none'];
98
+ /**
99
+ * Resolves the JS dependency provisioning mode for a worktree.
100
+ *
101
+ * Precedence (first match wins):
102
+ * 1. env `BRAINCLAW_WORKTREE_DEPS_MODE` (link|install|copy|none)
103
+ * 2. env `BRAINCLAW_NO_LINK_DEPS=1` → `none` (backward compat)
104
+ * 3. config `worktree.deps_mode` in `.brainclaw/config.yaml`
105
+ * 4. `link` (default — unchanged behavior)
106
+ *
107
+ * An unrecognized env value is ignored (falls through) with a warning, so a
108
+ * typo never silently changes provisioning.
109
+ */
110
+ export function resolveWorktreeDepsMode(projectRoot) {
111
+ const envMode = process.env.BRAINCLAW_WORKTREE_DEPS_MODE?.trim().toLowerCase();
112
+ if (envMode) {
113
+ if (WORKTREE_DEPS_MODES.includes(envMode)) {
114
+ return envMode;
115
+ }
116
+ logger.warn(`[worktree] Ignoring invalid BRAINCLAW_WORKTREE_DEPS_MODE='${envMode}' `
117
+ + `(expected one of ${WORKTREE_DEPS_MODES.join('|')}).`);
118
+ }
119
+ if (process.env.BRAINCLAW_NO_LINK_DEPS === '1')
120
+ return 'none';
121
+ try {
122
+ const configured = loadConfig(projectRoot).worktree?.deps_mode;
123
+ if (configured && WORKTREE_DEPS_MODES.includes(configured)) {
124
+ return configured;
125
+ }
126
+ }
127
+ catch { /* no / invalid config — fall through to default */ }
128
+ return 'link';
129
+ }
130
+ /**
131
+ * Detects the JS package manager for a project from its lockfile, falling back
132
+ * to the `packageManager` field of package.json, then to `npm`. Lockfile wins
133
+ * because it reflects what actually produced the main tree's `node_modules`.
134
+ */
135
+ export function detectPackageManager(projectRoot) {
136
+ const lockfiles = [
137
+ ['pnpm-lock.yaml', 'pnpm'],
138
+ ['yarn.lock', 'yarn'],
139
+ ['bun.lockb', 'bun'],
140
+ ['bun.lock', 'bun'],
141
+ ['package-lock.json', 'npm'],
142
+ ['npm-shrinkwrap.json', 'npm'],
143
+ ];
144
+ for (const [file, pm] of lockfiles) {
145
+ if (fs.existsSync(path.join(projectRoot, file)))
146
+ return pm;
147
+ }
148
+ try {
149
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'));
150
+ const declared = pkg.packageManager?.split('@')[0]?.trim();
151
+ if (declared === 'pnpm' || declared === 'yarn' || declared === 'bun' || declared === 'npm') {
152
+ return declared;
153
+ }
154
+ }
155
+ catch { /* no / invalid package.json — default below */ }
156
+ return 'npm';
157
+ }
96
158
  /**
97
159
  * pln#523 — read declared monorepo workspace globs from npm/yarn/bun
98
160
  * `workspaces` (package.json) and pnpm-workspace.yaml. Returns the raw
@@ -369,14 +431,42 @@ export function commitWorktreeOnBehalf(worktreePath, message, options = {}) {
369
431
  return { committed: false, files_changed: [], reason: 'worktree clean — nothing to commit' };
370
432
  }
371
433
  // Stage everything, then UNSTAGE the transient files that must never land on
372
- // the lane branch: the worker's own `LANE-RESULT.json` report and any
373
- // `.brainclaw/` coordination state. Committing those would pollute the branch
374
- // (and master, on merge) with non-deliverable artefacts.
434
+ // the lane branch: the worker's own `LANE-RESULT.json` report, any
435
+ // `.brainclaw/` coordination state, and the `.brainclaw-worktree.json` marker.
436
+ // Committing those would pollute the branch (and master, on merge) with
437
+ // non-deliverable artefacts — a field report (Codex on macOS) caught them
438
+ // landing in a lane commit (trp_01a2ba2a). `.brainclaw-worktree.json` sits at
439
+ // the worktree ROOT (NOT inside `.brainclaw/`), so the `.brainclaw` pathspec
440
+ // does not cover it — it needs its own entry. These are ALWAYS transient, so
441
+ // the unstage is unconditional.
375
442
  const add = runGit(['add', '-A'], worktreePath);
376
443
  if (!add.ok) {
377
444
  return { committed: false, files_changed: [], reason: `git add failed: ${add.stderr.trim()}` };
378
445
  }
379
- runGit(['reset', '-q', '--', 'LANE-RESULT.json', '.brainclaw', '.brainclaw-heartbeat-*'], worktreePath);
446
+ runGit([
447
+ 'reset', '-q', '--',
448
+ 'LANE-RESULT.json',
449
+ '.brainclaw',
450
+ '.brainclaw-worktree.json',
451
+ '.brainclaw-heartbeat-*',
452
+ ], worktreePath);
453
+ // node_modules needs a TRACKED-AWARE exclusion (Codex review of #88, BLOCKING).
454
+ // Unstage the links/dirs brainclaw provisions — but a project that VENDORS
455
+ // node_modules tracks those files, and a worker's change to a TRACKED
456
+ // node_modules file is a REAL deliverable; dropping it would silently omit
457
+ // work. Strategy: unstage every node_modules path, then RE-ADD only the ones
458
+ // already tracked at HEAD and modified/deleted (never the fresh provisioned
459
+ // link/dir, which is `A` vs HEAD). The component-bounded pathspecs never match
460
+ // a similarly-named deliverable such as `src/node_modules_helper.ts` — the
461
+ // plain `node_modules` is root-leading-dir only, the `:(glob)` forms match the
462
+ // `node_modules` path component exactly (nested link entry + nested contents).
463
+ const NODE_MODULES_SPECS = ['node_modules', ':(glob)**/node_modules', ':(glob)**/node_modules/**'];
464
+ runGit(['reset', '-q', '--', ...NODE_MODULES_SPECS], worktreePath);
465
+ const trackedNm = runGit(['diff', '--name-only', '--diff-filter=MD', 'HEAD', '--', ...NODE_MODULES_SPECS], worktreePath);
466
+ const keepNm = trackedNm.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
467
+ if (keepNm.length > 0) {
468
+ runGit(['add', '--', ...keepNm], worktreePath);
469
+ }
380
470
  // The files actually staged for this commit (post-exclusion) — also the
381
471
  // truthful files_changed report.
382
472
  const staged = runGit(['diff', '--cached', '--name-only'], worktreePath);
@@ -385,7 +475,7 @@ export function commitWorktreeOnBehalf(worktreePath, message, options = {}) {
385
475
  // Only transient files changed — nothing deliverable to commit. Restore the
386
476
  // index so the worktree is left exactly as the worker left it.
387
477
  runGit(['reset', '-q'], worktreePath);
388
- return { committed: false, files_changed: [], reason: 'no committable changes (only transient LANE-RESULT.json / .brainclaw)' };
478
+ return { committed: false, files_changed: [], reason: 'no committable changes (only transient LANE-RESULT.json / .brainclaw / node_modules links)' };
389
479
  }
390
480
  const authorName = options.authorName ?? 'brainclaw (on behalf)';
391
481
  const authorEmail = options.authorEmail ?? 'brainclaw@on-behalf.local';
@@ -503,6 +593,130 @@ export function findWorktreePathForBranch(worktrees, branchName) {
503
593
  *
504
594
  * Returns the absolute path to the newly created worktree.
505
595
  */
596
+ /**
597
+ * Whether the project looks like a Next.js app — a `next` dependency in
598
+ * package.json or a `next.config.*` at the root. Used to warn that the
599
+ * out-of-root `node_modules` symlink brainclaw provisions is rejected by
600
+ * `next dev` / Turbopack (trp_37b05a15), even though tsc / vitest / build accept
601
+ * it. Best-effort + defensive: any read/parse error → false (never blocks
602
+ * worktree creation over a heuristic).
603
+ */
604
+ export function projectUsesNextjs(projectRoot) {
605
+ try {
606
+ for (const cfg of ['next.config.js', 'next.config.mjs', 'next.config.ts', 'next.config.cjs']) {
607
+ if (fs.existsSync(path.join(projectRoot, cfg)))
608
+ return true;
609
+ }
610
+ const pkgPath = path.join(projectRoot, 'package.json');
611
+ if (!fs.existsSync(pkgPath))
612
+ return false;
613
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
614
+ return Boolean(pkg.dependencies?.next ?? pkg.devDependencies?.next);
615
+ }
616
+ catch {
617
+ return false;
618
+ }
619
+ }
620
+ /**
621
+ * Timeout for a per-worktree package-manager install (`deps_mode=install`).
622
+ * Defaults to 10 minutes; override with BRAINCLAW_WORKTREE_INSTALL_TIMEOUT_MS.
623
+ */
624
+ export function resolveWorktreeInstallTimeoutMs() {
625
+ const raw = process.env.BRAINCLAW_WORKTREE_INSTALL_TIMEOUT_MS;
626
+ const n = raw ? Number.parseInt(raw, 10) : NaN;
627
+ return Number.isFinite(n) && n > 0 ? n : 600_000;
628
+ }
629
+ /**
630
+ * Provisions a worktree's JS dependencies for `install` / `copy` deps modes,
631
+ * yielding a real in-root `node_modules` that `next dev` / Turbopack accepts
632
+ * (unlike the out-of-root junction of `link` mode; trp_37b05a15).
633
+ *
634
+ * Best-effort: any failure is recorded as a warning (the worker can still
635
+ * install by hand) and NEVER thrown — worktree creation must not fail over
636
+ * dependency provisioning. Returns human-readable warnings (empty on success).
637
+ *
638
+ * - `install` runs ONE package-manager install at the worktree root, which
639
+ * natively populates monorepo workspace `node_modules` too — so it ignores
640
+ * `nodeModulesRelPaths`. No-op when the project has no `package.json`.
641
+ * - `copy` recursively mirrors each existing `node_modules` dir from the main
642
+ * tree (symlinks copied verbatim so pnpm's relative link farm stays valid).
643
+ */
644
+ export function provisionWorktreeDeps(mode, mainWorktreePath, targetPath, nodeModulesRelPaths) {
645
+ const warnings = [];
646
+ if (mode === 'install') {
647
+ if (!fs.existsSync(path.join(targetPath, 'package.json')))
648
+ return warnings;
649
+ const pm = detectPackageManager(mainWorktreePath);
650
+ const timeoutMs = resolveWorktreeInstallTimeoutMs();
651
+ // Windows: npm/pnpm/yarn/bun are `.cmd` shims, only found via the shell — so
652
+ // pass ONE static command string (pm is validated; 'install' is literal → no
653
+ // injection) and NO args array (avoids DEP0190). Unix: the binaries are on
654
+ // PATH, so spawn directly with an args array and no shell.
655
+ const result = process.platform === 'win32'
656
+ ? spawnSync(`${pm} install`, { cwd: targetPath, encoding: 'utf-8', timeout: timeoutMs, shell: true })
657
+ : spawnSync(pm, ['install'], { cwd: targetPath, encoding: 'utf-8', timeout: timeoutMs });
658
+ if (result.error?.code === 'ETIMEDOUT') {
659
+ const msg = `deps_mode=install: '${pm} install' timed out after ${timeoutMs}ms and was killed `
660
+ + `(raise BRAINCLAW_WORKTREE_INSTALL_TIMEOUT_MS). Run '${pm} install' in the worktree manually.`;
661
+ warnings.push(msg);
662
+ logger.warn(`[worktree] ${msg}`);
663
+ }
664
+ else if (result.error) {
665
+ const msg = `deps_mode=install: could not run '${pm} install' (${result.error.message}). `
666
+ + `Is ${pm} on PATH? Run '${pm} install' in the worktree manually.`;
667
+ warnings.push(msg);
668
+ logger.warn(`[worktree] ${msg}`);
669
+ }
670
+ else if (result.status !== 0) {
671
+ const tail = (result.stderr || result.stdout || '').trim().split(/\r?\n/).filter(Boolean).slice(-3).join(' | ');
672
+ const msg = `deps_mode=install: '${pm} install' exited ${result.status ?? '?'}${tail ? ` — ${tail}` : ''}. `
673
+ + `Run '${pm} install' in the worktree manually.`;
674
+ warnings.push(msg);
675
+ logger.warn(`[worktree] ${msg}`);
676
+ }
677
+ return warnings;
678
+ }
679
+ // copy
680
+ const copyable = nodeModulesRelPaths.filter((rel) => fs.existsSync(path.join(mainWorktreePath, rel)));
681
+ if (copyable.length === 0) {
682
+ if (fs.existsSync(path.join(targetPath, 'package.json'))) {
683
+ const pm = detectPackageManager(mainWorktreePath);
684
+ const msg = `deps_mode=copy: no node_modules found in the main tree to copy — `
685
+ + `run '${pm} install' in the worktree.`;
686
+ warnings.push(msg);
687
+ logger.warn(`[worktree] ${msg}`);
688
+ }
689
+ return warnings;
690
+ }
691
+ for (const rel of copyable) {
692
+ const src = path.join(mainWorktreePath, rel);
693
+ const dest = path.join(targetPath, rel);
694
+ if (fs.existsSync(dest))
695
+ continue;
696
+ try {
697
+ const parentDir = path.dirname(dest);
698
+ if (parentDir !== targetPath)
699
+ fs.mkdirSync(parentDir, { recursive: true });
700
+ // Codex review P1: if the SOURCE node_modules is itself a symlink/junction
701
+ // (e.g. a main tree that is itself a linked worktree, or a user-linked
702
+ // node_modules), a verbatim copy would reproduce that out-of-root link and
703
+ // Turbopack would still reject it — defeating copy mode. Dereference the
704
+ // TOP-LEVEL entry to its real directory before copying, then copy with
705
+ // verbatimSymlinks so the tree's INTERNAL relative links (pnpm's farm)
706
+ // stay intact. A real dir source copies straight through.
707
+ const srcReal = fs.lstatSync(src).isSymbolicLink() ? fs.realpathSync(src) : src;
708
+ fs.cpSync(srcReal, dest, { recursive: true, verbatimSymlinks: true });
709
+ }
710
+ catch (err) {
711
+ const reason = err instanceof Error ? err.message : String(err);
712
+ const msg = `deps_mode=copy: failed to copy '${rel}' into worktree (${reason}). `
713
+ + `Run the package manager's install in the worktree manually.`;
714
+ warnings.push(msg);
715
+ logger.warn(`[worktree] ${msg}`);
716
+ }
717
+ }
718
+ return warnings;
719
+ }
506
720
  export function createWorktree(mainWorktreePath, branchName, options = {}) {
507
721
  // pln#614: resolve the true git toplevel first, so an in-tree project (project
508
722
  // dir ≠ git root) creates its worktree from the real repo root — `git worktree
@@ -617,20 +831,60 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
617
831
  // `dist` intentionally excluded — build outputs must be per-worktree
618
832
  // (EBUSY during clean:dist when MCP/extension holds a handle on junction target).
619
833
  // pln#523: also link per-package node_modules for JS/TS monorepos so workers
620
- // can build/typecheck sub-packages, not just the root. Set
621
- // BRAINCLAW_NO_LINK_DEPS=1 to disable auto dependency linking (e.g. when the
622
- // worktree lives on a different volume and central validation is preferred);
623
- // explicit options.sharedPaths are still honored.
624
- const linkDepsDisabled = process.env.BRAINCLAW_NO_LINK_DEPS === '1';
625
- const detected = linkDepsDisabled
834
+ // can build/typecheck sub-packages, not just the root.
835
+ //
836
+ // trp_37b05a15: the JS dependency provisioning mode (link | install | copy |
837
+ // none) is opt-in via BRAINCLAW_WORKTREE_DEPS_MODE / config worktree.deps_mode
838
+ // (BRAINCLAW_NO_LINK_DEPS=1 still maps to `none`). `link` (default) junctions
839
+ // node_modules from the main tree — an out-of-root symlink `next dev` rejects;
840
+ // `install`/`copy` provision a real in-root node_modules (Turbopack-ok);
841
+ // `none` provisions no deps (central validation). Explicit options.sharedPaths
842
+ // are always honored.
843
+ const isNodeModulesPath = (p) => p === 'node_modules' || p.endsWith('/node_modules');
844
+ const depsMode = resolveWorktreeDepsMode(mainWorktreePath);
845
+ const detected = depsMode === 'none'
626
846
  ? []
627
847
  : [...detectStackSharedPaths(mainWorktreePath), ...detectWorkspaceNodeModules(mainWorktreePath)];
628
848
  const extra = options.sharedPaths ?? [];
629
849
  const excluded = new Set(options.excludeShared ?? []);
630
- const sharedPaths = [...new Set([...detected, ...extra])].filter((p) => !excluded.has(p));
850
+ const requested = [...new Set([...detected, ...extra])].filter((p) => !excluded.has(p));
851
+ // In install/copy mode, node_modules becomes a REAL in-root directory instead
852
+ // of an out-of-root junction — so it is excluded from the symlink pass and
853
+ // provisioned separately. Other stack dirs (venv, vendor, …) still link.
854
+ const provisionDeps = depsMode === 'install' || depsMode === 'copy';
855
+ const nodeModulesPaths = requested.filter(isNodeModulesPath);
856
+ const sharedPaths = provisionDeps ? requested.filter((p) => !isNodeModulesPath(p)) : requested;
631
857
  for (const entry of sharedPaths) {
632
858
  trySymlinkSharedPath(entry);
633
859
  }
860
+ // Codex review P1: track whether in-root provisioning actually succeeded, so
861
+ // the dispatch brief can tell the worker the truth. A failed install/copy is
862
+ // best-effort (non-fatal) but the worker must then install itself — the brief
863
+ // must NOT claim "node_modules is real, do not reinstall" over a failure.
864
+ let depsProvisioned;
865
+ if (provisionDeps) {
866
+ const provisionWarnings = provisionWorktreeDeps(depsMode, mainWorktreePath, targetPath, nodeModulesPaths);
867
+ symlinkWarnings.push(...provisionWarnings);
868
+ depsProvisioned = provisionWarnings.length === 0;
869
+ }
870
+ else if (depsMode === 'link') {
871
+ // trp_37b05a15 (field report, Next.js 16 / Turbopack) — the node_modules link
872
+ // brainclaw provisions is an out-of-worktree-root symlink to the main repo.
873
+ // tsc / vitest / build follow it fine, but `next dev` (Turbopack) PANICS on a
874
+ // node_modules link that points outside the worktree root. Surface a warning
875
+ // (not a failure — the link is still correct for build/typecheck) so a worker
876
+ // or operator doing dev-server work knows the workaround up front.
877
+ const linkedNodeModules = sharedPaths.some(isNodeModulesPath);
878
+ if (linkedNodeModules && projectUsesNextjs(mainWorktreePath)) {
879
+ const msg = 'Next.js detected: node_modules is linked as an out-of-worktree-root symlink, which '
880
+ + '`next dev` / Turbopack rejects (it requires node_modules under the worktree root). '
881
+ + 'tsc / vitest / build are unaffected. For dev-server work, set deps_mode=install '
882
+ + '(config worktree.deps_mode or BRAINCLAW_WORKTREE_DEPS_MODE=install), run `npm install` '
883
+ + 'here, or smoke-test on the merged branch.';
884
+ symlinkWarnings.push(msg);
885
+ logger.warn(`[worktree] ${msg}`);
886
+ }
887
+ }
634
888
  // NOTE: .brainclaw/ is intentionally NOT symlinked.
635
889
  // Symlinking .brainclaw/ causes hooks and session_start to trigger on the
636
890
  // shared store, creating session conflicts and potentially blocking agents
@@ -669,6 +923,14 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
669
923
  ...(baseRefSha ? { base_ref_sha: baseRefSha } : {}),
670
924
  reset_existing_branch: options.resetExistingBranch === true,
671
925
  git_advice: 'git add ONLY specific files, NEVER git add -A.',
926
+ // trp_37b05a15: how JS deps were provisioned (link junction / real install /
927
+ // copy / none) — non-default modes are recorded so a worker/supervisor knows
928
+ // whether node_modules is an out-of-root link (dev-server caveat) or in-root.
929
+ // `deps_provisioned` (install/copy only) records whether the in-root
930
+ // provisioning actually succeeded — false means best-effort failed and the
931
+ // worker must install itself (Codex review P1).
932
+ ...(depsMode !== 'link' ? { deps_mode: depsMode } : {}),
933
+ ...(depsProvisioned !== undefined ? { deps_provisioned: depsProvisioned } : {}),
672
934
  // pln#523: surface any shared-path link failures (e.g. node_modules junction
673
935
  // that could not be created) so the worker / supervisor can see why a build
674
936
  // might fail, instead of an invisible degradation.
package/dist/facts.js CHANGED
@@ -1,11 +1,11 @@
1
1
  // Generated by scripts/emit-site-facts.mjs at build time. Do not edit manually.
2
- // Source: brainclaw v1.15.0 on 2026-07-15T15:24:46.074Z
2
+ // Source: brainclaw v1.17.0 on 2026-07-19T20:01:41.172Z
3
3
  export const FACTS = {
4
- "version": "1.15.0",
5
- "generated_at": "2026-07-15T15:24:46.074Z",
4
+ "version": "1.17.0",
5
+ "generated_at": "2026-07-19T20:01:41.172Z",
6
6
  "tools": {
7
7
  "count": 67,
8
- "published_count": 66,
8
+ "published_count": 65,
9
9
  "names": [
10
10
  "bclaw_bootstrap",
11
11
  "bclaw_release_notes",
@@ -77,7 +77,7 @@ export const FACTS = {
77
77
  ]
78
78
  },
79
79
  "entities": {
80
- "count": 17,
80
+ "count": 18,
81
81
  "names": [
82
82
  "plan",
83
83
  "step",
@@ -95,6 +95,7 @@ export const FACTS = {
95
95
  "assignment",
96
96
  "agent_run",
97
97
  "action",
98
+ "agent",
98
99
  "cross_project_link"
99
100
  ],
100
101
  "short_label_prefixes": {
@@ -114,6 +115,7 @@ export const FACTS = {
114
115
  "assignment": "asgn",
115
116
  "agent_run": "run",
116
117
  "action": "act",
118
+ "agent": "agt",
117
119
  "cross_project_link": "xpl"
118
120
  }
119
121
  },
@@ -219,7 +221,7 @@ export const FACTS = {
219
221
  "workflow_model": "task-based",
220
222
  "tier": "A",
221
223
  "has_mcp": true,
222
- "has_hooks": false,
224
+ "has_hooks": true,
223
225
  "has_skills": true,
224
226
  "has_rules": true,
225
227
  "instruction_file": "AGENTS.md",
@@ -472,7 +474,7 @@ export const FACTS = {
472
474
  },
473
475
  "bench": {
474
476
  "schema": "brainclaw.bench.v1",
475
- "generated_at": "2026-07-15T15:24:43.956Z",
477
+ "generated_at": "2026-07-19T20:01:39.038Z",
476
478
  "node_version": "v24.18.0",
477
479
  "platform": "linux-x64",
478
480
  "repeats": 3,
@@ -482,14 +484,14 @@ export const FACTS = {
482
484
  "volume": "empty",
483
485
  "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
484
486
  "duration_ms_median": 76,
485
- "payload_chars_median": 1650,
486
- "payload_tokens_est_median": 413
487
+ "payload_chars_median": 1640,
488
+ "payload_tokens_est_median": 410
487
489
  },
488
490
  {
489
491
  "name": "warm_work",
490
492
  "volume": "medium",
491
493
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
492
- "duration_ms_median": 126,
494
+ "duration_ms_median": 135,
493
495
  "payload_chars_median": 2626,
494
496
  "payload_tokens_est_median": 657
495
497
  },
@@ -497,7 +499,7 @@ export const FACTS = {
497
499
  "name": "first_edit",
498
500
  "volume": "medium",
499
501
  "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
500
- "duration_ms_median": 8,
502
+ "duration_ms_median": 7,
501
503
  "payload_chars_median": 442,
502
504
  "payload_tokens_est_median": 111
503
505
  }