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.
- package/README.md +25 -4
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-capture.js +209 -0
- package/dist/cli/register-code-map.js +19 -0
- package/dist/cli/register-coordination.js +472 -0
- package/dist/cli/register-federation.js +258 -0
- package/dist/cli/register-lifecycle.js +436 -0
- package/dist/cli/register-memory-context.js +502 -0
- package/dist/cli/register-planning.js +167 -0
- package/dist/cli/register-review.js +149 -0
- package/dist/cli/shared.js +5 -0
- package/dist/cli.js +212 -2183
- package/dist/commands/dispatch-watch.js +25 -2
- package/dist/commands/harvest.js +107 -20
- package/dist/commands/mcp-catalog.js +1438 -0
- package/dist/commands/mcp-contract.js +33 -0
- package/dist/commands/mcp-presentation.js +27 -0
- package/dist/commands/mcp-read-handlers.js +72 -36
- package/dist/commands/mcp-write-admin.js +328 -0
- package/dist/commands/mcp-write-claims.js +864 -0
- package/dist/commands/mcp-write-coordination.js +1825 -0
- package/dist/commands/mcp-write-entities.js +620 -0
- package/dist/commands/mcp-write-memory.js +451 -0
- package/dist/commands/mcp-write-sequences.js +116 -0
- package/dist/commands/mcp-write-support.js +367 -0
- package/dist/commands/mcp.js +261 -5584
- package/dist/commands/update-handoff.js +28 -42
- package/dist/core/agent-capability.js +38 -16
- package/dist/core/agent-files.js +54 -3
- package/dist/core/agent-integrations.js +1 -0
- package/dist/core/coordination.js +5 -2
- package/dist/core/cross-project.js +35 -1
- package/dist/core/dispatcher.js +67 -27
- package/dist/core/entity-operations.js +335 -12
- package/dist/core/entity-registry.js +72 -9
- package/dist/core/execution.js +28 -4
- package/dist/core/facade-schema.js +18 -4
- package/dist/core/handoff-review.js +35 -0
- package/dist/core/protocol-tool-policy.js +113 -0
- package/dist/core/review-loop-close.js +184 -0
- package/dist/core/review-loop-turn-dispatch.js +183 -0
- package/dist/core/schema.js +24 -2
- package/dist/core/security-detectors.js +35 -6
- package/dist/core/security.js +32 -12
- package/dist/core/worktree.js +274 -12
- package/dist/facts.js +13 -11
- package/dist/facts.json +12 -10
- package/docs/PROTOCOL.md +7 -3
- package/docs/concepts/coordinator-runbook.md +3 -0
- package/docs/concepts/dispatch-lifecycle.md +4 -4
- package/docs/concepts/loop-engine.md +6 -2
- package/docs/concepts/troubleshooting.md +1 -1
- package/docs/integrations/codex.md +22 -6
- package/docs/integrations/overview.md +1 -1
- package/docs/mcp-schema-changelog.md +137 -2
- package/docs/playbooks/orchestration.md +1 -1
- package/docs/product/entity-model-audit.md +3 -2
- package/docs/security.md +22 -1
- package/package.json +3 -1
package/dist/core/schema.js
CHANGED
|
@@ -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
|
|
964
|
-
*
|
|
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:
|
|
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:
|
|
116
|
+
out.push({ excerpt: maskSecret(token), entropy: Math.round(entropy * 100) / 100 });
|
|
117
117
|
}
|
|
118
118
|
return out;
|
|
119
119
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
package/dist/core/security.js
CHANGED
|
@@ -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.
|
|
4
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/core/worktree.js
CHANGED
|
@@ -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
|
|
373
|
-
// `.brainclaw/` coordination state
|
|
374
|
-
// (and master, on merge) with
|
|
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([
|
|
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.
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
|
|
625
|
-
|
|
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
|
|
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.
|
|
2
|
+
// Source: brainclaw v1.17.0 on 2026-07-19T20:01:41.172Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.
|
|
5
|
-
"generated_at": "2026-07-
|
|
4
|
+
"version": "1.17.0",
|
|
5
|
+
"generated_at": "2026-07-19T20:01:41.172Z",
|
|
6
6
|
"tools": {
|
|
7
7
|
"count": 67,
|
|
8
|
-
"published_count":
|
|
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":
|
|
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":
|
|
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-
|
|
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":
|
|
486
|
-
"payload_tokens_est_median":
|
|
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":
|
|
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":
|
|
502
|
+
"duration_ms_median": 7,
|
|
501
503
|
"payload_chars_median": 442,
|
|
502
504
|
"payload_tokens_est_median": 111
|
|
503
505
|
}
|