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
@@ -1,6 +1,6 @@
1
1
  import { loadState, persistState } from '../core/state.js';
2
2
  import { memoryExists } from '../core/io.js';
3
- import { nowISO } from '../core/ids.js';
3
+ import { mergeHandoffReview } from '../core/handoff-review.js';
4
4
  export function applyHandoffUpdates(handoff, options = {}) {
5
5
  if (options.status)
6
6
  handoff.status = options.status;
@@ -18,47 +18,33 @@ export function applyHandoffUpdates(handoff, options = {}) {
18
18
  if (Object.keys(contractUpdates).length > 0) {
19
19
  handoff.contract = { ...handoff.contract, ...contractUpdates };
20
20
  }
21
- const hasReviewUpdate = options.reviewer !== undefined ||
22
- options.requester !== undefined ||
23
- options.requested_at !== undefined ||
24
- options.review_thread_id !== undefined ||
25
- options.review_message_id !== undefined ||
26
- options.review_verdict !== undefined ||
27
- options.reviewed_by !== undefined ||
28
- options.review_summary !== undefined ||
29
- options.blocking_issues !== undefined ||
30
- options.suggestions !== undefined;
31
- if (hasReviewUpdate) {
32
- const review = { ...(handoff.review ?? {}) };
33
- if (options.reviewer !== undefined)
34
- review.reviewer = options.reviewer;
35
- if (options.requester !== undefined)
36
- review.requester = options.requester;
37
- if (options.requested_at !== undefined)
38
- review.requested_at = options.requested_at;
39
- if (options.review_thread_id !== undefined)
40
- review.thread_id = options.review_thread_id;
41
- if (options.review_message_id !== undefined)
42
- review.message_id = options.review_message_id;
43
- if (options.review_verdict !== undefined)
44
- review.verdict = options.review_verdict;
45
- if (options.reviewed_by !== undefined)
46
- review.reviewed_by = options.reviewed_by;
47
- if (options.review_summary !== undefined)
48
- review.summary = options.review_summary;
49
- if (options.blocking_issues !== undefined)
50
- review.blocking_issues = options.blocking_issues;
51
- if (options.suggestions !== undefined)
52
- review.suggestions = options.suggestions;
53
- const reviewCompleted = options.review_verdict !== undefined ||
54
- options.reviewed_by !== undefined ||
55
- options.review_summary !== undefined ||
56
- options.blocking_issues !== undefined ||
57
- options.suggestions !== undefined;
58
- if (reviewCompleted) {
59
- review.reviewed_at = nowISO();
60
- }
61
- handoff.review = review;
21
+ // Translate the flat options into a nested review patch and delegate the
22
+ // merge + reviewed_at stamping to the shared core helper — the SINGLE source
23
+ // of truth shared with updateEntity(handoff) so the two write paths cannot
24
+ // drift (pln#625 Phase 3, Codex review of #84).
25
+ const reviewPatch = {};
26
+ if (options.reviewer !== undefined)
27
+ reviewPatch.reviewer = options.reviewer;
28
+ if (options.requester !== undefined)
29
+ reviewPatch.requester = options.requester;
30
+ if (options.requested_at !== undefined)
31
+ reviewPatch.requested_at = options.requested_at;
32
+ if (options.review_thread_id !== undefined)
33
+ reviewPatch.thread_id = options.review_thread_id;
34
+ if (options.review_message_id !== undefined)
35
+ reviewPatch.message_id = options.review_message_id;
36
+ if (options.review_verdict !== undefined)
37
+ reviewPatch.verdict = options.review_verdict;
38
+ if (options.reviewed_by !== undefined)
39
+ reviewPatch.reviewed_by = options.reviewed_by;
40
+ if (options.review_summary !== undefined)
41
+ reviewPatch.summary = options.review_summary;
42
+ if (options.blocking_issues !== undefined)
43
+ reviewPatch.blocking_issues = options.blocking_issues;
44
+ if (options.suggestions !== undefined)
45
+ reviewPatch.suggestions = options.suggestions;
46
+ if (Object.keys(reviewPatch).length > 0) {
47
+ handoff.review = mergeHandoffReview(handoff.review, reviewPatch);
62
48
  }
63
49
  return handoff;
64
50
  }
@@ -147,16 +147,27 @@ const PROFILES = {
147
147
  invoke_binary: 'opencode',
148
148
  invoke_review_template: 'opencode "{prompt}"',
149
149
  },
150
- // Sandbox note: when running under --sandbox workspace-write, Codex cannot reach
151
- // the main project store via MCP. Use filesystem-direct writes instead:
152
- // write candidates to .brainclaw/coordination/inbox/cnd_<id>.json in the active
153
- // worktree. The coordinator then syncs them via bclaw_harvest_candidates.
150
+ // Sandbox note (CORRECTED — dec#133, empirical probe codex 0.144.4, 2026-07-18):
151
+ // the earlier belief that `--sandbox workspace-write` blocks brainclaw MCP was
152
+ // FALSE and never re-verified. The MCP server runs as a SEPARATE process outside
153
+ // the sandbox, and `approval_policy=never` (baked into the invoke template below)
154
+ // auto-approves every tool call in headless mode — so MCP reads/writes are
155
+ // reachable from a sandboxed codex run. The REAL residual constraint is `git
156
+ // commit`: the sandbox root excludes `.git`, so the coordinator commits the
157
+ // worktree diff at harvest time (see dispatchCanCommit / harvest.ts). Candidates
158
+ // can still be dropped as filesystem JSON as a fallback, but MCP is not the
159
+ // blocker.
154
160
  codex: {
155
161
  name: 'codex', category: 'code-agent', workflowModel: 'task-based',
156
- hasMcp: true, hasHooks: false, hasAutoApprove: false, hasSkills: true, hasRules: true,
162
+ // hooks: Codex gained a native lifecycle hook surface (SessionStart /
163
+ // UserPromptSubmit / Stop / PreToolUse / … via .codex/hooks.json or [hooks]
164
+ // in config.toml; developers.openai.com/codex/hooks, verified 2026-07 —
165
+ // trp_fe75dafc). brainclaw writes .codex/hooks.json (ensureCodexHooks),
166
+ // giving Codex the same session-lifecycle wiring as Claude Code.
167
+ hasMcp: true, hasHooks: true, hasAutoApprove: false, hasSkills: true, hasRules: true,
157
168
  instructionFile: 'AGENTS.md', sharedInstructionFile: true, mcpConfigScope: 'machine', templateTier: 'A',
158
169
  role_capabilities: ['execute', 'review'],
159
- runtime: { mcp_direct: true, hooks: false, canBeSpawnedCli: true, canSpawnOtherCli: false, inbox: true },
170
+ runtime: { mcp_direct: true, hooks: true, canBeSpawnedCli: true, canSpawnOtherCli: false, inbox: true },
160
171
  max_concurrent_tasks: 5,
161
172
  // pln#475: prefer stdin_pipe to avoid Windows cmd.exe arg-parsing breaking
162
173
  // long prompts. codex.cmd resolves through cmd shell, where embedded
@@ -693,25 +704,36 @@ export function resolveBriefMode(agentName) {
693
704
  * pln#528 — capability matrix DERIVED from the spawn template, so it stays in
694
705
  * sync with how each agent is actually invoked (no per-profile duplication).
695
706
  *
696
- * The motivating reality (a cross-project field debrief): codex is spawned with
697
- * `--sandbox workspace-write`, which (a) does NOT wire the brainclaw MCP server
698
- * and (b) puts `.git` outside the sandbox root — so a sandboxed worker can
699
- * neither call `bclaw_*` nor `git commit`, regardless of the profile's nominal
700
- * `runtime.mcp_direct` flag. These helpers expose that so the brief / handoff /
701
- * harvest logic can adapt to the transport instead of issuing instructions the
702
- * worker cannot follow.
707
+ * pln#628 Focus 4A CORRECTION (dec#133, empirical probe codex 0.144.4): the
708
+ * original pln#528 belief — that a `--sandbox` spawn "does NOT wire the brainclaw
709
+ * MCP server" — was a FALSE premise that was never re-verified. In reality the MCP
710
+ * server is a separate out-of-sandbox process and `approval_policy=never`
711
+ * auto-approves every tool call, so MCP is reachable from a sandboxed run. The
712
+ * ONE residual constraint the sandbox actually imposes is `git commit` (.git sits
713
+ * outside the writable root). So the two capabilities are now decoupled: sandbox
714
+ * ⇏ no-MCP, sandbox ⇒ no-commit.
703
715
  */
704
716
  export function isSandboxedSpawn(profile) {
705
717
  return /--sandbox\b/.test(profile.invoke_template ?? '');
706
718
  }
707
- /** Whether the agent, AS SPAWNED by the dispatcher, can reach brainclaw MCP. */
719
+ /**
720
+ * Whether the agent, AS SPAWNED by the dispatcher, can reach brainclaw MCP.
721
+ *
722
+ * pln#628 Focus 4A: this is NO LONGER gated by isSandboxedSpawn. dec#133 proved
723
+ * empirically that a sandboxed codex run reaches MCP (both whitelisted and
724
+ * non-whitelisted tools fired) — the sandbox does not sever MCP, it only makes
725
+ * `.git` read-only. MCP reachability therefore tracks `runtime.mcp_direct` alone;
726
+ * the commit constraint is expressed separately by dispatchCanCommit.
727
+ */
708
728
  export function dispatchHasMcp(profile) {
709
- return profile.runtime.mcp_direct && !isSandboxedSpawn(profile);
729
+ return profile.runtime.mcp_direct;
710
730
  }
711
731
  /**
712
732
  * Whether the spawned worker can `git commit`. A sandbox whose root excludes
713
733
  * `.git` cannot — the coordinator must integrate the worker's output instead of
714
- * relying on a self-commit handoff.
734
+ * relying on a self-commit handoff. NOTE (dec#133): commit-from-sandbox was NOT
735
+ * verified to work even on Windows, so this stays conservative (sandbox ⇒ no
736
+ * commit); do not relax it to a platform check without an empirical probe.
715
737
  */
716
738
  export function dispatchCanCommit(profile) {
717
739
  return !isSandboxedSpawn(profile);
@@ -3,7 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import yaml from 'yaml';
6
- import { MCP_HEADLESS_AUTO_TOOL_NAMES, MCP_CANONICAL_GRAMMAR_TOOL_NAMES, REMOVED_IN_V1_TOOLS } from '../commands/mcp.js';
6
+ import { MCP_HEADLESS_AUTO_TOOL_NAMES, MCP_CANONICAL_GRAMMAR_TOOL_NAMES, REMOVED_IN_V1_TOOLS } from './protocol-tool-policy.js';
7
7
  import { renderToml, tomlArrayTableHasEntry } from './toml-writer.js';
8
8
  import { PROTOCOL_SKILLS, renderProtocolSkill } from './protocol-skills.js';
9
9
  import { getInstalledBrainclawVersion } from './brainclaw-version.js';
@@ -314,6 +314,7 @@ const ANTIGRAVITY_MCP_RELATIVE_PATH = '.gemini/antigravity/mcp_config.json';
314
314
  const ANTIGRAVITY_HOOKS_RELATIVE_PATH = '.gemini/antigravity/hooks.json';
315
315
  const CURSOR_HOOKS_RELATIVE_PATH = '.cursor/hooks.json';
316
316
  const COPILOT_HOOKS_RELATIVE_PATH = '.github/copilot/hooks.json';
317
+ const CODEX_HOOKS_RELATIVE_PATH = '.codex/hooks.json';
317
318
  const OPENCLAW_MCP_RELATIVE_PATH = '.openclaw/mcp.json';
318
319
  const VSCODE_EXTENSIONS_RELATIVE_PATH = '.vscode/extensions.json';
319
320
  const UNIVERSAL_SKILL_RELATIVE_PATH = '.agents/skills/brainclaw/SKILL.md';
@@ -1686,7 +1687,10 @@ export function ensureCodexMcpConfig(homeDir, env = process.env) {
1686
1687
  '\n[mcp_servers.brainclaw]',
1687
1688
  `command = "${normalizedCommand}"`,
1688
1689
  `args = [${normalizedArgs.map(a => `"${a}"`).join(', ')}]`,
1689
- 'startup_timeout_ms = 20000',
1690
+ // Codex renamed this field to `_sec` (developers.openai.com/codex/extend/mcp;
1691
+ // the docs note "uses _sec, not _ms"). The old `startup_timeout_ms` is an
1692
+ // unrecognized key → Codex silently falls back to its default startup timeout.
1693
+ 'startup_timeout_sec = 20',
1690
1694
  '',
1691
1695
  '[mcp_servers.brainclaw.env]',
1692
1696
  'BRAINCLAW_AGENT = "codex"',
@@ -2115,6 +2119,53 @@ export function ensureAntigravityHooks(homeDir) {
2115
2119
  relativePath: ANTIGRAVITY_HOOKS_RELATIVE_PATH,
2116
2120
  };
2117
2121
  }
2122
+ /**
2123
+ * Writes `.codex/hooks.json` — Codex CLI's native lifecycle hooks config
2124
+ * (project scope). Codex gained a full hook surface (developers.openai.com/codex/hooks,
2125
+ * verified 2026-07 — trp_fe75dafc); this wires brainclaw's session lifecycle to it,
2126
+ * mirroring the Claude Code / Antigravity hook writers.
2127
+ *
2128
+ * Events (PascalCase, per the Codex schema): `SessionStart` loads shared context,
2129
+ * `UserPromptSubmit` surfaces the context diff, `Stop` runs session-end cleanup.
2130
+ * File shape: `{ "hooks": { "<Event>": [ { "matcher": "", "hooks": [ { "type": "command", "command": "…" } ] } ] } }`
2131
+ * — the top-level `hooks` wrapper + per-entry `hooks` array (`matcher: ""` = match all).
2132
+ *
2133
+ * brainclaw OWNS these three event arrays (overwrite, not merge) — the same
2134
+ * contract as the Cursor / Antigravity `hooks.json` writers. This is
2135
+ * unconditionally idempotent regardless of how the CLI path resolves, with no
2136
+ * cross-upgrade pile-up. It deliberately does NOT use command-recognition to
2137
+ * preserve user entries within these events: recognizing brainclaw's own hook
2138
+ * path-independently requires matching bare CLI subcommands, which over-matches
2139
+ * legitimate user hooks that merely pass `session-start`/etc. as an argument
2140
+ * (Codex review of #94, round 2). Other events the user defines are untouched
2141
+ * (only these three keys are set).
2142
+ */
2143
+ export function ensureCodexHooks(cwd) {
2144
+ const filePath = path.join(cwd, CODEX_HOOKS_RELATIVE_PATH);
2145
+ const existing = readJsonObject(filePath);
2146
+ if (existing === undefined) {
2147
+ return skippedAutoConfigResult('rule', 'Codex session hooks', filePath, CODEX_HOOKS_RELATIVE_PATH);
2148
+ }
2149
+ const hooks = isJsonObject(existing.hooks) ? { ...existing.hooks } : {};
2150
+ const sessionStartCmd = buildHookCommand(['session-start', '--include-context']);
2151
+ const contextDiffCmd = buildHookCommand(['context-diff']);
2152
+ const sessionEndCmd = buildHookCommand(['session-end', '--auto-release', '--reflect', '--reflect-handoff', '--dispatch-review']);
2153
+ hooks.SessionStart = [buildCommandHookEntry(sessionStartCmd)];
2154
+ hooks.UserPromptSubmit = [buildCommandHookEntry(contextDiffCmd)];
2155
+ hooks.Stop = [buildCommandHookEntry(sessionEndCmd)];
2156
+ const { created, updated } = writeJsonFileIfChanged(filePath, {
2157
+ ...existing,
2158
+ hooks,
2159
+ });
2160
+ return {
2161
+ kind: 'rule',
2162
+ label: 'Codex session hooks',
2163
+ created,
2164
+ updated,
2165
+ filePath,
2166
+ relativePath: CODEX_HOOKS_RELATIVE_PATH,
2167
+ };
2168
+ }
2118
2169
  /**
2119
2170
  * Writes `.github/copilot/hooks.json` — GitHub Copilot's native hooks config.
2120
2171
  * Events: sessionStart, userPromptSubmitted, sessionEnd (camelCase).
@@ -2284,7 +2335,7 @@ export const AGENT_WIRING_REGISTRY = {
2284
2335
  writeProtocolSkills,
2285
2336
  ],
2286
2337
  userWriters: [(ctx) => ensureCodexMcpConfig(ctx.homeDir, ctx.env)],
2287
- hookWriters: [],
2338
+ hookWriters: [(ctx) => ensureCodexHooks(ctx.cwd)],
2288
2339
  },
2289
2340
  continue: {
2290
2341
  workspaceWriters: [(ctx) => ensureContinueMcpConfig(ctx.cwd)],
@@ -61,6 +61,7 @@ const DEFAULT_SURFACES = {
61
61
  'codex': [
62
62
  { kind: 'instructions', location: 'workspace', path: 'AGENTS.md' },
63
63
  { kind: 'mcp', location: 'machine', path: '.codex/config.toml' },
64
+ { kind: 'hook', location: 'workspace', path: '.codex/hooks.json' },
64
65
  { kind: 'skill', location: 'workspace', path: '.agents/skills/brainclaw/SKILL.md' },
65
66
  ],
66
67
  'opencode': [
@@ -288,8 +288,11 @@ function buildIncomingSignalsSummary(cwd) {
288
288
  return {
289
289
  id: signal.id,
290
290
  entity_type: signal.entity_type,
291
- from_project: signal.from_project.name,
292
- from_agent: signal.from_agent.name,
291
+ // Defence-in-depth: listIncomingCrossProjectSignals already filters
292
+ // wrong-shape envelopes, but never let a single missing field crash the
293
+ // whole board render (trp_e90b3198).
294
+ from_project: signal.from_project?.name ?? '?',
295
+ from_agent: signal.from_agent?.name ?? '?',
293
296
  created_at: signal.created_at,
294
297
  preview: text.length > 120 ? text.slice(0, 117) + '...' : text,
295
298
  };
@@ -128,6 +128,33 @@ export function writeCrossProjectSignal(target, entityType, payload, sourceCwd)
128
128
  fs.writeFileSync(filepath, JSON.stringify(signal, null, 2) + '\n', 'utf-8');
129
129
  return signal;
130
130
  }
131
+ /**
132
+ * Runtime shape guard for a cross-project signal envelope. A second signaling
133
+ * subsystem can drop schema-incompatible (but valid-JSON) files into the same
134
+ * directory; without this guard a consumer that reads envelope.from_project.name
135
+ * / from_agent.name / created_at crashes with a TypeError on every read
136
+ * (e.g. bclaw_context board — reachable purely locally). Guarding here — the
137
+ * single source of these envelopes — keeps every consumer safe.
138
+ */
139
+ const CROSS_PROJECT_SIGNAL_ENTITIES = new Set(['candidate', 'handoff', 'runtime_note']);
140
+ function isCrossProjectSignalEnvelope(value) {
141
+ if (!value || typeof value !== 'object')
142
+ return false;
143
+ const v = value;
144
+ const fromProject = v.from_project;
145
+ const fromAgent = v.from_agent;
146
+ return typeof v.id === 'string'
147
+ && typeof v.entity_type === 'string'
148
+ // entity_type must be one of ours — a foreign subsystem's value would flow
149
+ // downstream as a bogus type.
150
+ && CROSS_PROJECT_SIGNAL_ENTITIES.has(v.entity_type)
151
+ && typeof v.created_at === 'string'
152
+ && typeof fromProject?.name === 'string'
153
+ && typeof fromAgent?.name === 'string'
154
+ // payload MUST be a non-null object: the consumer does `'text' in payload`,
155
+ // which throws a TypeError on a primitive/null payload (Codex review of #85).
156
+ && typeof v.payload === 'object' && v.payload !== null;
157
+ }
131
158
  /**
132
159
  * Lists cross-project signals materialized in the local inbox.
133
160
  */
@@ -142,7 +169,14 @@ export function listIncomingCrossProjectSignals(cwd) {
142
169
  continue;
143
170
  const filepath = path.join(dir, entry);
144
171
  try {
145
- signals.push(JSON.parse(fs.readFileSync(filepath, 'utf-8')));
172
+ const parsed = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
173
+ // Skip files that are valid JSON but not our envelope shape (schema drift
174
+ // from another signaling subsystem sharing this directory) — matching the
175
+ // existing "ignore malformed" intent, but for wrong-shape as well as
176
+ // wrong-syntax.
177
+ if (isCrossProjectSignalEnvelope(parsed)) {
178
+ signals.push(parsed);
179
+ }
146
180
  }
147
181
  catch {
148
182
  // Ignore malformed signal files.
@@ -279,13 +279,39 @@ export function buildProtocolSection(options) {
279
279
  }
280
280
  if (options?.worktreePath) {
281
281
  parts.push(`Worktree: ${options.worktreePath}`);
282
- // pln#523: tell the worker how dependencies are provisioned so it does not
283
- // stall trying to install them. node_modules (and per-package node_modules in
284
- // monorepos) are junction-linked from the main repo — run builds/typecheck
285
- // directly. If they are missing, do NOT `npm install` in the worktree: check
286
- // `.brainclaw-worktree.json` → `symlink_warnings` (a link may have failed,
287
- // e.g. cross-volume) and validate the build centrally with the coordinator.
288
- parts.push('Dependencies: node_modules is linked from the main repo (incl. monorepo per-package). Build/typecheck directly; if deps are missing, do NOT npm install here — see .brainclaw-worktree.json symlink_warnings and validate centrally.');
282
+ // pln#523 / trp_37b05a15: tell the worker how dependencies are provisioned so
283
+ // it does not stall trying to (re)install them. The authoritative record is
284
+ // the worktree's `.brainclaw-worktree.json` → `deps_mode` (absent ⇒ `link`).
285
+ // - link (default): node_modules (incl. monorepo per-package) is
286
+ // junction-linked from the main repo — build/typecheck directly; do NOT
287
+ // `npm install`. An out-of-root symlink, so `next dev`/Turbopack rejects
288
+ // it (build/tsc/vitest are fine).
289
+ // - install/copy: node_modules is a REAL in-root directory — everything,
290
+ // including a dev server, works directly; no reinstall needed.
291
+ // - none: no deps provisioned — run the project's install first.
292
+ let depsMode = 'link';
293
+ let depsProvisioned;
294
+ try {
295
+ const sidecar = JSON.parse(fs.readFileSync(path.join(options.worktreePath, '.brainclaw-worktree.json'), 'utf-8'));
296
+ if (sidecar.deps_mode)
297
+ depsMode = sidecar.deps_mode;
298
+ depsProvisioned = sidecar.deps_provisioned;
299
+ }
300
+ catch { /* sidecar absent/unreadable — assume the default `link` */ }
301
+ if ((depsMode === 'install' || depsMode === 'copy') && depsProvisioned === false) {
302
+ // Codex review P1: provisioning was ATTEMPTED but FAILED (best-effort, non-fatal).
303
+ // Do not claim node_modules is usable — tell the worker to install it.
304
+ parts.push(`Dependencies: in-root provisioning was attempted (deps_mode=${depsMode}) but FAILED — node_modules may be missing or incomplete. Run the project's install (npm/pnpm/yarn/bun) in the worktree before building; see .brainclaw-worktree.json symlink_warnings for the failure.`);
305
+ }
306
+ else if (depsMode === 'install' || depsMode === 'copy') {
307
+ parts.push(`Dependencies: node_modules is a real in-root directory (deps_mode=${depsMode}) — build, typecheck, and dev server all work directly; do NOT reinstall. If anything is missing, see .brainclaw-worktree.json symlink_warnings.`);
308
+ }
309
+ else if (depsMode === 'none') {
310
+ parts.push('Dependencies: none were provisioned (deps_mode=none) — run the project\'s install (npm/pnpm/yarn/bun) in the worktree before building.');
311
+ }
312
+ else {
313
+ parts.push('Dependencies: node_modules is linked from the main repo (incl. monorepo per-package). Build/typecheck directly; if deps are missing, do NOT npm install here — see .brainclaw-worktree.json symlink_warnings and validate centrally. (Out-of-root symlink: next dev/Turbopack needs deps_mode=install.)');
314
+ }
289
315
  }
290
316
  parts.push('');
291
317
  // Assignment lifecycle protocol (Agent SDK)
@@ -307,9 +333,11 @@ export function buildProtocolSection(options) {
307
333
  if (options.worktreePath) {
308
334
  parts.push('**Compile check**: before every commit, `tsc --noEmit` (or the project build) must pass — a per-worktree pre-commit gate may enforce this and reject the commit otherwise. Do not bypass with --no-verify unless you intend to hand off a known-broken state.');
309
335
  }
310
- // pln#526: standard fallback channel — works even when MCP is unreachable
311
- // (sandboxed agents). The coordinator ingests it with `brainclaw harvest`.
312
- parts.push(`Final fallback (if bclaw_assignment_update / MCP is unavailable, e.g. a sandboxed agent): write LANE-RESULT.json at the worktree root — {"assignment_id":"${options.assignmentId}","status":"completed|blocked|failed","summary":"<what you did>","files_changed":["..."],"artifacts":["..."]}. The coordinator harvests it via \`brainclaw harvest ${options.assignmentId}\`.`);
336
+ // pln#526: standard fallback channel — works even if bclaw_assignment_update
337
+ // fails in your environment. pln#628 Focus 4A: sandbox is NO LONGER a reason
338
+ // MCP is unavailable (dec#133), so this is framed as a generic fallback, not a
339
+ // sandbox instruction. The coordinator ingests it with `brainclaw harvest`.
340
+ parts.push(`Final fallback (if bclaw_assignment_update / MCP is unavailable in your environment): write LANE-RESULT.json at the worktree root — {"assignment_id":"${options.assignmentId}","status":"completed|blocked|failed","summary":"<what you did>","files_changed":["..."],"artifacts":["..."]}. The coordinator harvests it via \`brainclaw harvest ${options.assignmentId}\`.`);
313
341
  }
314
342
  else if (options?.claimId) {
315
343
  parts.push('1. Call bclaw_session_start to register your session');
@@ -468,22 +496,25 @@ export function generateBrief(plan, item, cwd, briefMode, options) {
468
496
  if (mode === 'full') {
469
497
  parts.push(buildProtocolSection(options));
470
498
  }
471
- // pln#528 — transport-aware addendum (field debrief P1#2). When the agent is
472
- // spawned sandboxed (no MCP + no git commit — e.g. codex --sandbox
473
- // workspace-write), the MCP lifecycle lines in the Protocol section do NOT
474
- // apply. Say so explicitly and make the FILE protocol authoritative, so the
475
- // worker never receives instructions it cannot follow nor has to guess the
476
- // fallback. (Note: resolveBriefMode still returns 'full' for codex per pln#496
477
- // so the reconciler-independent path is preserved; this addendum disambiguates
478
- // the transport rather than stripping the section — the full compact reversal
479
- // is a separate human-owned call on the May-vs-June MCP-availability conflict.)
499
+ // pln#628 Focus 4A — transport addendum, now keyed to the ACTUAL missing
500
+ // capability. Originally (pln#528) this fired for any sandboxed spawn and
501
+ // claimed "no MCP + no commit". dec#133 proved the "no MCP" half FALSE: a
502
+ // sandboxed codex reaches MCP (separate out-of-sandbox process +
503
+ // approval_policy=never). dispatchHasMcp now tracks runtime.mcp_direct alone,
504
+ // so this block only fires for genuinely MCP-less agents (nanoclaw/nemoclaw/
505
+ // zeroclaw). For them the Protocol section's MCP lifecycle does not apply and
506
+ // the file protocol is the sole channel. Sandboxed-but-MCP-capable agents
507
+ // (codex) no longer receive a self-contradictory "MCP NOT reachable / Do NOT
508
+ // call bclaw_*" note: their coherent message is carried by the Protocol section
509
+ // (MCP primary + LANE-RESULT.json fallback) and working-defaults (canCommit=
510
+ // false → the coordinator commits their worktree at harvest).
480
511
  if (briefProfile && !dispatchHasMcp(briefProfile)) {
481
- parts.push('## ⚠ Transport: sandboxed run (no MCP, no commit)');
482
- parts.push('Your runtime is sandboxed — the brainclaw MCP server is NOT reachable and `git commit` is unavailable (.git is outside the sandbox root). Any `bclaw_*` MCP instruction above does NOT apply to you. Report your outcome via the FILE protocol only — it is authoritative for this run:');
512
+ parts.push('## ⚠ Transport: no MCP (file protocol only)');
513
+ parts.push('Your runtime has no brainclaw MCP access — any `bclaw_*` instruction above does NOT apply to you. Report your outcome via the FILE protocol only; it is authoritative for this run:');
483
514
  const asgn = options?.assignmentId ?? '<assignment_id>';
484
515
  parts.push(`- When done, write LANE-RESULT.json at the worktree root: {"assignment_id":"${asgn}","status":"completed|blocked|failed","summary":"<what you did>","files_changed":["..."]}.`);
485
516
  parts.push('- Capture decisions/traps as candidate JSON under .brainclaw/coordination/inbox/ (the coordinator harvests them).');
486
- parts.push('- Do NOT call bclaw_* tools — they are unavailable here. The coordinator harvests your result and integrates/commits it.');
517
+ parts.push('- Do NOT call bclaw_* tools — they are unavailable here. The coordinator harvests your result and integrates it.');
487
518
  parts.push('');
488
519
  }
489
520
  // Codex-specific constraints: focus and speed guidance for sandboxed runs.
@@ -525,14 +556,17 @@ export function generateDispatchBrief(options) {
525
556
  assignmentId: options.assignmentId,
526
557
  }));
527
558
  }
528
- // pln#528 — transport-aware addendum for sandboxed agents (see generateBrief).
559
+ // pln#628 Focus 4A — transport addendum keyed to the ACTUAL missing capability
560
+ // (see generateBrief for the full rationale + dec#133). Fires only for
561
+ // genuinely MCP-less agents; sandboxed-but-MCP-capable codex no longer gets a
562
+ // self-contradictory "no MCP / Do NOT call bclaw_*" note.
529
563
  if (taskBriefProfile && !dispatchHasMcp(taskBriefProfile)) {
530
- parts.push('## ⚠ Transport: sandboxed run (no MCP, no commit)');
531
- parts.push('Your runtime is sandboxed — the brainclaw MCP server is NOT reachable and `git commit` is unavailable (.git is outside the sandbox root). Any `bclaw_*` MCP instruction above does NOT apply to you. Report your outcome via the FILE protocol only — it is authoritative for this run:');
564
+ parts.push('## ⚠ Transport: no MCP (file protocol only)');
565
+ parts.push('Your runtime has no brainclaw MCP access — any `bclaw_*` instruction above does NOT apply to you. Report your outcome via the FILE protocol only; it is authoritative for this run:');
532
566
  const asgn = options.assignmentId ?? '<assignment_id>';
533
567
  parts.push(`- When done, write LANE-RESULT.json at the worktree root: {"assignment_id":"${asgn}","status":"completed|blocked|failed","summary":"<what you did>","files_changed":["..."]}.`);
534
568
  parts.push('- Capture decisions/traps as candidate JSON under .brainclaw/coordination/inbox/ (the coordinator harvests them).');
535
- parts.push('- Do NOT call bclaw_* tools — they are unavailable here. The coordinator harvests your result and integrates/commits it.');
569
+ parts.push('- Do NOT call bclaw_* tools — they are unavailable here. The coordinator harvests your result and integrates it.');
536
570
  parts.push('');
537
571
  }
538
572
  // Codex-specific constraints: focus and speed guidance for sandboxed runs
@@ -902,13 +936,19 @@ export async function dispatch(options, cwd) {
902
936
  requireWorktree: true, // pln#531: never spawn a worker in the integration repo
903
937
  });
904
938
  entry.execution_status = execResult.execution_status;
939
+ // pln#626 Phase 1 — mirror the coordinate path: carry the reason so a
940
+ // command_ready_manual sequence item says WHY it didn't spawn.
941
+ if (execResult.execution_reason)
942
+ entry.execution_reason = execResult.execution_reason;
943
+ if (execResult.failure_kind)
944
+ entry.failure_kind = execResult.failure_kind;
905
945
  if (execResult.pid)
906
946
  entry.pid = execResult.pid;
907
947
  if (execResult.execution_status === 'delivered_and_started') {
908
948
  entry.channel = 'spawned_cli';
909
949
  }
910
950
  if (execResult.error)
911
- result.warnings.push(execResult.error);
951
+ result.warnings.push(`${entry.agent}: ${execResult.error}`);
912
952
  if (entry.assignment_id && entry.claim_id) {
913
953
  if (execResult.failure_kind === 'spawn_no_handshake') {
914
954
  try {