brainclaw 1.14.0 → 1.16.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 (63) hide show
  1. package/README.md +16 -263
  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 -2015
  13. package/dist/commands/dispatch-watch.js +25 -2
  14. package/dist/commands/harvest.js +31 -6
  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 -5570
  27. package/dist/commands/update-handoff.js +28 -42
  28. package/dist/core/agent-capability.js +31 -14
  29. package/dist/core/agent-files.js +1 -1
  30. package/dist/core/agent-registry.js +51 -3
  31. package/dist/core/claims.js +18 -0
  32. package/dist/core/coordination.js +5 -2
  33. package/dist/core/cross-project.js +35 -1
  34. package/dist/core/dispatcher.js +34 -20
  35. package/dist/core/entity-operations.js +335 -12
  36. package/dist/core/entity-registry.js +72 -9
  37. package/dist/core/execution.js +28 -4
  38. package/dist/core/facade-schema.js +30 -4
  39. package/dist/core/federation-cloud.js +142 -11
  40. package/dist/core/federation-outbox.js +292 -0
  41. package/dist/core/federation-signing.js +115 -0
  42. package/dist/core/handoff-review.js +35 -0
  43. package/dist/core/io.js +6 -0
  44. package/dist/core/protocol-tool-policy.js +113 -0
  45. package/dist/core/review-loop-close.js +115 -0
  46. package/dist/core/schema.js +25 -2
  47. package/dist/core/security-detectors.js +35 -6
  48. package/dist/core/security.js +32 -12
  49. package/dist/core/worktree.js +98 -9
  50. package/dist/facts.js +13 -11
  51. package/dist/facts.json +12 -10
  52. package/docs/PROTOCOL.md +7 -3
  53. package/docs/concepts/coordinator-runbook.md +3 -0
  54. package/docs/concepts/dispatch-lifecycle.md +4 -4
  55. package/docs/concepts/loop-engine.md +3 -1
  56. package/docs/concepts/troubleshooting.md +1 -1
  57. package/docs/integrations/codex.md +3 -3
  58. package/docs/integrations/overview.md +1 -1
  59. package/docs/mcp-schema-changelog.md +153 -2
  60. package/docs/playbooks/orchestration.md +1 -1
  61. package/docs/product/entity-model-audit.md +3 -2
  62. package/docs/security.md +22 -1
  63. 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,10 +147,16 @@ 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
162
  hasMcp: true, hasHooks: false, hasAutoApprove: false, hasSkills: true, hasRules: true,
@@ -693,25 +699,36 @@ export function resolveBriefMode(agentName) {
693
699
  * pln#528 — capability matrix DERIVED from the spawn template, so it stays in
694
700
  * sync with how each agent is actually invoked (no per-profile duplication).
695
701
  *
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.
702
+ * pln#628 Focus 4A CORRECTION (dec#133, empirical probe codex 0.144.4): the
703
+ * original pln#528 belief — that a `--sandbox` spawn "does NOT wire the brainclaw
704
+ * MCP server" — was a FALSE premise that was never re-verified. In reality the MCP
705
+ * server is a separate out-of-sandbox process and `approval_policy=never`
706
+ * auto-approves every tool call, so MCP is reachable from a sandboxed run. The
707
+ * ONE residual constraint the sandbox actually imposes is `git commit` (.git sits
708
+ * outside the writable root). So the two capabilities are now decoupled: sandbox
709
+ * ⇏ no-MCP, sandbox ⇒ no-commit.
703
710
  */
704
711
  export function isSandboxedSpawn(profile) {
705
712
  return /--sandbox\b/.test(profile.invoke_template ?? '');
706
713
  }
707
- /** Whether the agent, AS SPAWNED by the dispatcher, can reach brainclaw MCP. */
714
+ /**
715
+ * Whether the agent, AS SPAWNED by the dispatcher, can reach brainclaw MCP.
716
+ *
717
+ * pln#628 Focus 4A: this is NO LONGER gated by isSandboxedSpawn. dec#133 proved
718
+ * empirically that a sandboxed codex run reaches MCP (both whitelisted and
719
+ * non-whitelisted tools fired) — the sandbox does not sever MCP, it only makes
720
+ * `.git` read-only. MCP reachability therefore tracks `runtime.mcp_direct` alone;
721
+ * the commit constraint is expressed separately by dispatchCanCommit.
722
+ */
708
723
  export function dispatchHasMcp(profile) {
709
- return profile.runtime.mcp_direct && !isSandboxedSpawn(profile);
724
+ return profile.runtime.mcp_direct;
710
725
  }
711
726
  /**
712
727
  * Whether the spawned worker can `git commit`. A sandbox whose root excludes
713
728
  * `.git` cannot — the coordinator must integrate the worker's output instead of
714
- * relying on a self-commit handoff.
729
+ * relying on a self-commit handoff. NOTE (dec#133): commit-from-sandbox was NOT
730
+ * verified to work even on Windows, so this stays conservative (sandbox ⇒ no
731
+ * commit); do not relax it to a platform check without an empirical probe.
715
732
  */
716
733
  export function dispatchCanCommit(profile) {
717
734
  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';
@@ -140,8 +140,17 @@ function ensureParentDir(filepath) {
140
140
  fs.mkdirSync(dir, { recursive: true });
141
141
  }
142
142
  }
143
- function fingerprintPublicKey(publicKey) {
144
- return crypto.createHash('sha256').update(publicKey).digest('hex');
143
+ /**
144
+ * Canonical Ed25519 public-key fingerprint (pln#101): sha256 over the PEM with
145
+ * carriage returns stripped and surrounding whitespace trimmed, so a trailing
146
+ * newline or CRLF (e.g. introduced by copy-paste through the cloud UI) does NOT
147
+ * change the fingerprint. The cloud computes the same canonical value — see
148
+ * brainclaw-cloud/src/handlers/agents.ts fingerprintPem — so a local↔remote
149
+ * match is a reliable proof of the same key regardless of PEM formatting.
150
+ */
151
+ export function fingerprintPublicKeyPem(publicKeyPem) {
152
+ const canonical = publicKeyPem.replace(/\r/g, '').trim();
153
+ return crypto.createHash('sha256').update(canonical).digest('hex');
145
154
  }
146
155
  function buildIdentityKey(agentId, env = process.env, forceRegenerate = false) {
147
156
  migrateLegacyAgentKey(agentId, env);
@@ -168,10 +177,49 @@ function buildIdentityKey(agentId, env = process.env, forceRegenerate = false) {
168
177
  return {
169
178
  algorithm: 'ed25519',
170
179
  public_key: publicKeyPem,
171
- fingerprint: fingerprintPublicKey(publicKeyPem),
180
+ fingerprint: fingerprintPublicKeyPem(publicKeyPem),
172
181
  created_at: createdAt,
173
182
  };
174
183
  }
184
+ /**
185
+ * Load an agent's Ed25519 signing material for cloud request signing (pln#100).
186
+ *
187
+ * Reads the private key from the neutral key store (~/.brainclaw/keys/),
188
+ * migrating from the legacy CODEX_HOME location if needed, and derives the SPKI
189
+ * public-key PEM plus its fingerprint — the same sha256(pem) the cloud stores as
190
+ * agents.key_fingerprint, so a local↔remote fingerprint match is a byte-for-byte
191
+ * proof of the same key. Returns undefined when no key has been generated yet:
192
+ * signing NEVER silently mints a key (the public key must be registered with the
193
+ * cloud first). Use registerAgentIdentity({ generateFingerprint: true }) to mint one.
194
+ */
195
+ export function loadAgentSigningKey(agentId, env = process.env) {
196
+ migrateLegacyAgentKey(agentId, env);
197
+ const filepath = agentKeyPath(agentId);
198
+ if (!fs.existsSync(filepath))
199
+ return undefined;
200
+ const privateKeyPem = fs.readFileSync(filepath, 'utf-8');
201
+ const privateKey = crypto.createPrivateKey(privateKeyPem);
202
+ // @types/node 26 dropped the KeyObject overload from createPublicKey's signature
203
+ // (see buildIdentityKey) — cast to a parameter type the .d.ts still accepts.
204
+ const publicKeyPem = crypto
205
+ .createPublicKey(privateKey)
206
+ .export({ type: 'spki', format: 'pem' })
207
+ .toString();
208
+ return { privateKeyPem, publicKeyPem, fingerprint: fingerprintPublicKeyPem(publicKeyPem) };
209
+ }
210
+ /**
211
+ * Ensure an agent has an Ed25519 signing key, WITHOUT rotating an existing one
212
+ * (pln#101). Generates the keypair on first call, returns the existing key on
213
+ * subsequent calls — so it is safe to run after the public key has been
214
+ * approved in the cloud (rotating would break the fingerprint match). Returns
215
+ * the SPKI public-key PEM + its sha256(pem) fingerprint.
216
+ */
217
+ export function ensureAgentSigningKey(agentId, env = process.env) {
218
+ const key = buildIdentityKey(agentId, env, false);
219
+ if (!key)
220
+ throw new Error(`Failed to derive signing key for agent ${agentId}`);
221
+ return { publicKeyPem: key.public_key, fingerprint: key.fingerprint };
222
+ }
175
223
  function withIdentityKey(agent, env = process.env, forceRegenerate = false) {
176
224
  return {
177
225
  ...agent,
@@ -14,6 +14,7 @@ import { loadSessionById } from './identity.js';
14
14
  import { loadState, persistState } from './state.js';
15
15
  import { createRuntimeEvent } from './events.js';
16
16
  import { emitRegistryPostImage, registryFaultPoint } from './events/registry-post-image.js';
17
+ import { maybeEnqueueClaimTransition, isFederationEnqueueActive } from './federation-outbox.js';
17
18
  /** Parse duration string like '4h', '30m' to ms. */
18
19
  function parseTtl(value) {
19
20
  const match = /^(\d+)([mhd])$/i.exec(value.trim());
@@ -69,9 +70,26 @@ function saveClaimUnlocked(claim, cwd, options) {
69
70
  // pln#568 (I2): journal the post-image BEFORE the projection write, so a
70
71
  // crash can only leave the journal ahead of the projection, never behind.
71
72
  const created = !store.exists(parsed.id);
73
+ // Federation (pln#101): capture the PREVIOUS status BEFORE the write so we can
74
+ // diff it after (create or active↔terminal transition ⇒ enqueue for cloud
75
+ // sync). Only pay the prev-load when federation is actually active; this whole
76
+ // block runs under the store mutation mutex, which serializes rev reservation.
77
+ const fedActive = isFederationEnqueueActive(cwd, options?.federation?.suppressEnqueue);
78
+ let fedPrevStatus;
79
+ if (fedActive) {
80
+ try {
81
+ fedPrevStatus = loadClaimFromAnyDir(parsed.id, cwd).status;
82
+ }
83
+ catch {
84
+ fedPrevStatus = undefined;
85
+ }
86
+ }
72
87
  emitRegistryPostImage('claim', parsed, { created, agent: parsed.agent, agent_id: parsed.agent_id, session_id: parsed.session_id, cwd });
73
88
  registryFaultPoint('after_registry_journal');
74
89
  store.save(parsed);
90
+ if (fedActive) {
91
+ maybeEnqueueClaimTransition(parsed, fedPrevStatus, fedPrevStatus === undefined, cwd, options?.federation?.suppressEnqueue);
92
+ }
75
93
  const writeDir = claimsDir(cwd, 'write');
76
94
  for (const dirPath of claimDirs(cwd)) {
77
95
  if (dirPath === writeDir)
@@ -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.
@@ -307,9 +307,11 @@ export function buildProtocolSection(options) {
307
307
  if (options.worktreePath) {
308
308
  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
309
  }
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}\`.`);
310
+ // pln#526: standard fallback channel — works even if bclaw_assignment_update
311
+ // fails in your environment. pln#628 Focus 4A: sandbox is NO LONGER a reason
312
+ // MCP is unavailable (dec#133), so this is framed as a generic fallback, not a
313
+ // sandbox instruction. The coordinator ingests it with `brainclaw harvest`.
314
+ 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
315
  }
314
316
  else if (options?.claimId) {
315
317
  parts.push('1. Call bclaw_session_start to register your session');
@@ -468,22 +470,25 @@ export function generateBrief(plan, item, cwd, briefMode, options) {
468
470
  if (mode === 'full') {
469
471
  parts.push(buildProtocolSection(options));
470
472
  }
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.)
473
+ // pln#628 Focus 4A — transport addendum, now keyed to the ACTUAL missing
474
+ // capability. Originally (pln#528) this fired for any sandboxed spawn and
475
+ // claimed "no MCP + no commit". dec#133 proved the "no MCP" half FALSE: a
476
+ // sandboxed codex reaches MCP (separate out-of-sandbox process +
477
+ // approval_policy=never). dispatchHasMcp now tracks runtime.mcp_direct alone,
478
+ // so this block only fires for genuinely MCP-less agents (nanoclaw/nemoclaw/
479
+ // zeroclaw). For them the Protocol section's MCP lifecycle does not apply and
480
+ // the file protocol is the sole channel. Sandboxed-but-MCP-capable agents
481
+ // (codex) no longer receive a self-contradictory "MCP NOT reachable / Do NOT
482
+ // call bclaw_*" note: their coherent message is carried by the Protocol section
483
+ // (MCP primary + LANE-RESULT.json fallback) and working-defaults (canCommit=
484
+ // false → the coordinator commits their worktree at harvest).
480
485
  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:');
486
+ parts.push('## ⚠ Transport: no MCP (file protocol only)');
487
+ 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
488
  const asgn = options?.assignmentId ?? '<assignment_id>';
484
489
  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
490
  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.');
491
+ parts.push('- Do NOT call bclaw_* tools — they are unavailable here. The coordinator harvests your result and integrates it.');
487
492
  parts.push('');
488
493
  }
489
494
  // Codex-specific constraints: focus and speed guidance for sandboxed runs.
@@ -525,14 +530,17 @@ export function generateDispatchBrief(options) {
525
530
  assignmentId: options.assignmentId,
526
531
  }));
527
532
  }
528
- // pln#528 — transport-aware addendum for sandboxed agents (see generateBrief).
533
+ // pln#628 Focus 4A — transport addendum keyed to the ACTUAL missing capability
534
+ // (see generateBrief for the full rationale + dec#133). Fires only for
535
+ // genuinely MCP-less agents; sandboxed-but-MCP-capable codex no longer gets a
536
+ // self-contradictory "no MCP / Do NOT call bclaw_*" note.
529
537
  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:');
538
+ parts.push('## ⚠ Transport: no MCP (file protocol only)');
539
+ 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
540
  const asgn = options.assignmentId ?? '<assignment_id>';
533
541
  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
542
  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.');
543
+ parts.push('- Do NOT call bclaw_* tools — they are unavailable here. The coordinator harvests your result and integrates it.');
536
544
  parts.push('');
537
545
  }
538
546
  // Codex-specific constraints: focus and speed guidance for sandboxed runs
@@ -902,13 +910,19 @@ export async function dispatch(options, cwd) {
902
910
  requireWorktree: true, // pln#531: never spawn a worker in the integration repo
903
911
  });
904
912
  entry.execution_status = execResult.execution_status;
913
+ // pln#626 Phase 1 — mirror the coordinate path: carry the reason so a
914
+ // command_ready_manual sequence item says WHY it didn't spawn.
915
+ if (execResult.execution_reason)
916
+ entry.execution_reason = execResult.execution_reason;
917
+ if (execResult.failure_kind)
918
+ entry.failure_kind = execResult.failure_kind;
905
919
  if (execResult.pid)
906
920
  entry.pid = execResult.pid;
907
921
  if (execResult.execution_status === 'delivered_and_started') {
908
922
  entry.channel = 'spawned_cli';
909
923
  }
910
924
  if (execResult.error)
911
- result.warnings.push(execResult.error);
925
+ result.warnings.push(`${entry.agent}: ${execResult.error}`);
912
926
  if (entry.assignment_id && entry.claim_id) {
913
927
  if (execResult.failure_kind === 'spawn_no_handshake') {
914
928
  try {