brainclaw 1.17.0 → 1.19.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 (97) hide show
  1. package/README.md +5 -5
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/commands/code-map.js +4 -1
  4. package/dist/commands/codev.js +61 -30
  5. package/dist/commands/doctor.js +14 -1
  6. package/dist/commands/harvest.js +223 -43
  7. package/dist/commands/inbox.js +10 -4
  8. package/dist/commands/install-hooks.js +184 -27
  9. package/dist/commands/loop.js +2 -2
  10. package/dist/commands/loops-handlers.js +82 -1
  11. package/dist/commands/mcp-catalog.js +12 -4
  12. package/dist/commands/mcp-read-handlers.js +90 -7
  13. package/dist/commands/mcp-schemas.generated.js +3 -0
  14. package/dist/commands/mcp-write-claims.js +57 -0
  15. package/dist/commands/mcp-write-coordination.js +216 -57
  16. package/dist/commands/mcp-write-entities.js +11 -0
  17. package/dist/commands/mcp.js +29 -2
  18. package/dist/commands/session-end.js +15 -0
  19. package/dist/commands/session-start.js +19 -0
  20. package/dist/core/agentrun-reconciler.js +171 -7
  21. package/dist/core/agentruns.js +6 -1
  22. package/dist/core/claim-conformity.js +193 -0
  23. package/dist/core/claim-scope.js +155 -0
  24. package/dist/core/claims.js +127 -2
  25. package/dist/core/code-map/aggregate.js +473 -0
  26. package/dist/core/code-map/backend.js +36 -10
  27. package/dist/core/code-map/freshness.js +36 -1
  28. package/dist/core/code-map/lang/c/imports.scm +12 -0
  29. package/dist/core/code-map/lang/c/index.js +150 -0
  30. package/dist/core/code-map/lang/c/tags.scm +68 -0
  31. package/dist/core/code-map/lang/cpp/imports.scm +14 -0
  32. package/dist/core/code-map/lang/cpp/index.js +149 -0
  33. package/dist/core/code-map/lang/cpp/tags.scm +87 -0
  34. package/dist/core/code-map/lang/csharp/imports.scm +20 -0
  35. package/dist/core/code-map/lang/csharp/index.js +224 -0
  36. package/dist/core/code-map/lang/csharp/tags.scm +63 -0
  37. package/dist/core/code-map/lang/go/imports.scm +13 -0
  38. package/dist/core/code-map/lang/go/index.js +139 -0
  39. package/dist/core/code-map/lang/go/tags.scm +36 -0
  40. package/dist/core/code-map/lang/providers.js +12 -1
  41. package/dist/core/code-map/lang/ruby/imports.scm +24 -0
  42. package/dist/core/code-map/lang/ruby/index.js +198 -0
  43. package/dist/core/code-map/lang/ruby/tags.scm +49 -0
  44. package/dist/core/code-map/lang/rust/imports.scm +44 -0
  45. package/dist/core/code-map/lang/rust/index.js +136 -0
  46. package/dist/core/code-map/lang/rust/tags.scm +47 -0
  47. package/dist/core/code-map/query.js +229 -80
  48. package/dist/core/code-map/types.js +18 -0
  49. package/dist/core/code-map/work-section.js +8 -7
  50. package/dist/core/codev-responses.js +16 -0
  51. package/dist/core/dispatcher.js +176 -22
  52. package/dist/core/execution-adapters.js +29 -3
  53. package/dist/core/facade-schema.js +32 -0
  54. package/dist/core/guidance-telemetry.js +197 -0
  55. package/dist/core/ideation-loop-close.js +152 -0
  56. package/dist/core/instruction-templates.js +11 -3
  57. package/dist/core/loops/artifact-resolver.js +197 -0
  58. package/dist/core/loops/attempt-reservation.js +576 -0
  59. package/dist/core/loops/commit-intent.js +494 -0
  60. package/dist/core/loops/facade-schema.js +48 -0
  61. package/dist/core/loops/impl-bind.js +144 -0
  62. package/dist/core/loops/index.js +1 -1
  63. package/dist/core/loops/iteration-engine.js +29 -0
  64. package/dist/core/loops/lock.js +14 -0
  65. package/dist/core/loops/project-resolution.js +157 -0
  66. package/dist/core/loops/reconcile-turn.js +369 -0
  67. package/dist/core/loops/result-reducers.js +88 -0
  68. package/dist/core/loops/store.js +46 -7
  69. package/dist/core/loops/types.js +139 -11
  70. package/dist/core/loops/verbs.js +49 -4
  71. package/dist/core/loops/verify-command.js +209 -0
  72. package/dist/core/messaging.js +58 -5
  73. package/dist/core/next-actions.js +157 -0
  74. package/dist/core/review-loop-close.js +27 -6
  75. package/dist/core/review-loop-turn-dispatch.js +290 -28
  76. package/dist/core/runtime-signals.js +68 -0
  77. package/dist/core/schema.js +64 -0
  78. package/dist/core/surface-freshness.js +150 -0
  79. package/dist/core/warnings.js +98 -0
  80. package/dist/core/worktree.js +24 -0
  81. package/dist/facts.js +9 -9
  82. package/dist/facts.json +8 -8
  83. package/dist/wasm/tree-sitter-c.wasm +0 -0
  84. package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
  85. package/dist/wasm/tree-sitter-cpp.wasm +0 -0
  86. package/dist/wasm/tree-sitter-go.wasm +0 -0
  87. package/dist/wasm/tree-sitter-ruby.wasm +0 -0
  88. package/dist/wasm/tree-sitter-rust.wasm +0 -0
  89. package/docs/cli.md +1 -1
  90. package/docs/code-map.md +22 -6
  91. package/docs/concepts/loop-engine.md +24 -0
  92. package/docs/concepts/observer-protocol.md +22 -0
  93. package/docs/concepts/plans-and-claims.md +57 -0
  94. package/docs/integrations/claude-code.md +53 -0
  95. package/docs/integrations/mcp.md +45 -0
  96. package/docs/mcp-schema-changelog.md +118 -2
  97. package/package.json +1 -1
@@ -36,48 +36,205 @@ export function runInstallHooks(options = {}) {
36
36
  if (!fs.existsSync(claudeHookPath) || options.force) {
37
37
  fs.writeFileSync(claudeHookPath, generateClaudePreToolScript(), { encoding: 'utf-8', mode: 0o755 });
38
38
  console.log(`✔ Claude Code preToolUse hook generated at ${claudeHookPath}`);
39
- console.log(' To activate, add to .claude/settings.json hooks: { "PreToolUse": ".git/hooks/claude-pre-tool.sh" }');
40
39
  }
40
+ // pln#636 C1 second half (review F2) — GENERATION IS NOT ACTIVATION. This step
41
+ // used to only print instructions, which is why the hook was dead even for
42
+ // operators who ran the command: a repaired script nobody wires up is still
43
+ // dead. The Codex writer has owned `.codex/hooks.json` since v1.17.0; this
44
+ // brings the Claude surface to the same standard.
45
+ const activation = activateClaudePreToolHook(gitRoot, claudeHookPath);
46
+ switch (activation.status) {
47
+ case 'activated':
48
+ console.log(`✔ PreToolUse hook activated in ${activation.settingsPath}`);
49
+ console.log(' Advisory-only (never blocks): it adds context, it cannot deny a write.');
50
+ break;
51
+ case 'already_active':
52
+ console.log(`✔ PreToolUse hook already active in ${activation.settingsPath}`);
53
+ break;
54
+ case 'failed':
55
+ console.log(`⚠ Could not activate the PreToolUse hook automatically: ${activation.reason}`);
56
+ console.log(' Add this to .claude/settings.json by hand:');
57
+ console.log(' { "hooks": { "PreToolUse": [ { "matcher": "Edit|Write|MultiEdit|NotebookEdit",');
58
+ console.log(` "hooks": [ { "type": "command", "command": "${toPosixPath(claudeHookPath)}" } ] } ] } }`);
59
+ break;
60
+ }
61
+ }
62
+ /** Hook scripts are invoked through a shell, so the command is always POSIX-style. */
63
+ function toPosixPath(p) {
64
+ return p.split('\\').join('/');
41
65
  }
66
+ /**
67
+ * The tools whose `tool_input` exposes a concrete file path.
68
+ *
69
+ * `Bash` is deliberately absent: a shell command's file footprint is not
70
+ * statically knowable, so it is `unverifiable`, never a guess. The pre-repair
71
+ * matcher included it, which was one source of the noise that made the hook
72
+ * worth ignoring.
73
+ */
74
+ const CLAUDE_PRE_TOOL_MATCHER = 'Edit|Write|MultiEdit|NotebookEdit';
75
+ /**
76
+ * Merge the PreToolUse entry into `.claude/settings.json`, additively.
77
+ *
78
+ * NON-DESTRUCTIVE BY CONSTRUCTION, which matters more here than anywhere else in
79
+ * this file: that file holds the operator's own permission allow-list, and
80
+ * clobbering it would be a far worse outcome than an unactivated advisory. So
81
+ * every unknown key is preserved, a pre-existing PreToolUse array is appended
82
+ * to rather than replaced, and anything unparseable is left strictly untouched
83
+ * with a manual instruction printed instead (trp_5f342186: a hook mechanism may
84
+ * never be the thing that destroys work).
85
+ *
86
+ * Idempotent: re-running finds the existing command and reports `already_active`.
87
+ */
88
+ export function activateClaudePreToolHook(gitRoot, hookPath) {
89
+ const settingsPath = path.join(gitRoot, '.claude', 'settings.json');
90
+ const command = toPosixPath(hookPath);
91
+ let settings = {};
92
+ if (fs.existsSync(settingsPath)) {
93
+ try {
94
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
95
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
96
+ return { status: 'failed', reason: 'settings.json is not a JSON object', settingsPath };
97
+ }
98
+ settings = parsed;
99
+ }
100
+ catch (err) {
101
+ // Refusing to touch a file we cannot parse is the whole point: rewriting it
102
+ // would silently drop the operator's permission list.
103
+ return {
104
+ status: 'failed',
105
+ reason: `settings.json is not valid JSON (${err instanceof Error ? err.message : String(err)})`,
106
+ settingsPath,
107
+ };
108
+ }
109
+ }
110
+ const hooksSection = (typeof settings.hooks === 'object' && settings.hooks !== null && !Array.isArray(settings.hooks))
111
+ ? settings.hooks
112
+ : {};
113
+ const preToolUse = Array.isArray(hooksSection.PreToolUse)
114
+ ? hooksSection.PreToolUse
115
+ : [];
116
+ const alreadyActive = preToolUse.some((entry) => entry?.hooks?.some((h) => typeof h?.command === 'string' && toPosixPath(h.command) === command));
117
+ if (alreadyActive)
118
+ return { status: 'already_active', settingsPath };
119
+ const next = {
120
+ ...settings,
121
+ hooks: {
122
+ ...hooksSection,
123
+ PreToolUse: [
124
+ ...preToolUse,
125
+ { matcher: CLAUDE_PRE_TOOL_MATCHER, hooks: [{ type: 'command', command }] },
126
+ ],
127
+ },
128
+ };
129
+ try {
130
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
131
+ fs.writeFileSync(settingsPath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8');
132
+ }
133
+ catch (err) {
134
+ return {
135
+ status: 'failed',
136
+ reason: err instanceof Error ? err.message : String(err),
137
+ settingsPath,
138
+ };
139
+ }
140
+ return { status: 'activated', settingsPath };
141
+ }
142
+ /**
143
+ * Claude Code PreToolUse advisory hook (pln#636 C1, mechanism per cst_38effd52).
144
+ *
145
+ * THE MECHANISM MATTERS, AND THE PREVIOUS VERSION HAD IT WRONG THREE WAYS:
146
+ *
147
+ * 1. It read the tool name from `CLAUDE_TOOL_NAME` in the environment. Claude
148
+ * Code delivers a JSON payload on **stdin**; that env var does not exist, so
149
+ * the hook exited before doing anything — dead on arrival.
150
+ * 2. It wrote its advisory to **stderr with exit 0**. Per the documented host
151
+ * contract, stderr at exit 0 is NOT surfaced to the model (only exit 2 feeds
152
+ * stderr to Claude, and exit 2 BLOCKS the tool — unacceptable for an
153
+ * advisory). So even a hook fixed for (1) would have spoken into the void.
154
+ * The only non-blocking channel to the model is
155
+ * `hookSpecificOutput.additionalContext` on **stdout** with exit 0.
156
+ * 3. It shelled out to the CLI (`execSync brainclaw claim list`) on every write,
157
+ * and deduped through one project-global marker, so one agent's warning
158
+ * silenced every other agent.
159
+ *
160
+ * Advisory-only is non-negotiable (trp_5f342186 — a hook cascade destroyed
161
+ * work): `permissionDecision` is always `allow`, the exit code is always 0.
162
+ *
163
+ * SCOPE LIMIT, STATED HONESTLY: this version answers "do you hold ANY active
164
+ * claim of your own?", not "are you writing outside your claim's scope". Real
165
+ * scope awareness needs the scope grammar (pln#636 C0-a) — 42.4% of real claim
166
+ * scopes are not path-matchable (cst_22ebb103), so a path comparison written
167
+ * today would false-accuse on nearly half of them.
168
+ *
169
+ * Guarded by tests/unit/guidance-engine-consistency.test.ts.
170
+ */
42
171
  function generateClaudePreToolScript() {
43
172
  return `#!/bin/sh
44
- # brainclaw Claude Code preToolUse hook
173
+ # brainclaw Claude Code PreToolUse hook (advisory-only)
45
174
  # Generated by: brainclaw install-hooks
175
+ # Contract: reads a JSON payload on stdin, replies with JSON on stdout, and
176
+ # ALWAYS exits 0. stderr is deliberately unused: Claude Code does not surface it
177
+ # to the model at exit 0 (cst_38effd52).
46
178
  exec node -e "
47
179
  const fs = require('fs');
48
180
  const path = require('path');
49
- const { execSync } = require('child_process');
50
181
 
51
- const toolName = process.env.CLAUDE_TOOL_NAME || process.argv[2] || process.env.TOOL_NAME || '';
52
- const isWrite = /edit|write|replace|bash|str_replace/i.test(toolName);
182
+ let raw = '';
183
+ try { raw = fs.readFileSync(0, 'utf8'); } catch (e) { process.exit(0); }
53
184
 
54
- if (!isWrite || !toolName) process.exit(0);
185
+ let payload;
186
+ try { payload = JSON.parse(raw); } catch (e) { process.exit(0); }
55
187
 
188
+ // Only STRUCTURED writes expose a concrete file path. A shell command's file
189
+ // footprint is not statically knowable, so it stays unverifiable — never guessed.
190
+ const toolName = (payload && payload.tool_name) || '';
191
+ if (['Edit', 'Write', 'NotebookEdit', 'MultiEdit'].indexOf(toolName) === -1) process.exit(0);
192
+
193
+ // Read the store directly; spawning the CLI per edit was the third defect.
194
+ var active = [];
56
195
  try {
57
- const BCLAW_CMD = fs.existsSync(path.join(process.cwd(), 'node_modules', '.bin', 'brainclaw'))
58
- ? 'npx brainclaw' : 'brainclaw';
59
- const out = execSync(BCLAW_CMD + ' claim list --json', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] });
60
- const claims = JSON.parse(out);
61
- if (claims && claims.length > 0) process.exit(0);
62
- } catch (e) {
63
- // ignore if brainclaw not found or fails
64
- process.exit(0);
65
- }
196
+ var claimsDir = path.join(process.cwd(), '.brainclaw', 'coordination', 'claims');
197
+ var walk = function (dir) {
198
+ var entries = fs.readdirSync(dir, { withFileTypes: true });
199
+ for (var i = 0; i < entries.length; i++) {
200
+ var full = path.join(dir, entries[i].name);
201
+ if (entries[i].isDirectory()) { walk(full); continue; }
202
+ if (entries[i].name.slice(-5) !== '.json') continue;
203
+ try {
204
+ var claim = JSON.parse(fs.readFileSync(full, 'utf8'));
205
+ if (claim && claim.status === 'active') active.push(claim);
206
+ } catch (e) { /* skip an unreadable claim */ }
207
+ }
208
+ };
209
+ walk(claimsDir);
210
+ } catch (e) { process.exit(0); }
66
211
 
67
- const sessionMark = path.join(process.cwd(), '.brainclaw', 'tmp', 'claude_warned');
68
- if (!fs.existsSync(path.dirname(sessionMark))) fs.mkdirSync(path.dirname(sessionMark), { recursive: true });
212
+ // Identity-aware: only THIS agent's claims count. Another agent holding a claim
213
+ // says nothing about whether you hold one.
214
+ var me = (process.env.BRAINCLAW_AGENT_ID || process.env.BRAINCLAW_AGENT_NAME || process.env.BRAINCLAW_AGENT || '').trim();
215
+ var mine = me ? active.filter(function (c) { return c.agent_id === me || c.agent === me; }) : active;
216
+ if (mine.length > 0) process.exit(0);
69
217
 
70
- if (fs.existsSync(sessionMark)) {
71
- const mtime = fs.statSync(sessionMark).mtimeMs;
72
- if (Date.now() - mtime < 2 * 60 * 60 * 1000) {
73
- process.exit(0); // already warned this session
74
- }
75
- }
76
- fs.writeFileSync(sessionMark, Date.now().toString());
218
+ // Dedup PER AGENT, not once per project.
219
+ try {
220
+ var runtimeDir = path.join(process.cwd(), '.brainclaw', 'coordination', 'runtime');
221
+ var slug = (me || 'unknown').replace(/[^A-Za-z0-9_.-]/g, '_');
222
+ var mark = path.join(runtimeDir, 'claim-advisory-' + slug + '.mark');
223
+ fs.mkdirSync(runtimeDir, { recursive: true });
224
+ if (fs.existsSync(mark) && Date.now() - fs.statSync(mark).mtimeMs < 2 * 60 * 60 * 1000) process.exit(0);
225
+ fs.writeFileSync(mark, String(Date.now()));
226
+ } catch (e) { /* dedup is best-effort: speaking twice beats crashing */ }
77
227
 
78
- process.stderr.write('\\n[Brainclaw] ⚠️ WARNING: You are about to use an Edit/Write tool (' + toolName + ') but you have NO active claim.\\n');
79
- process.stderr.write('[Brainclaw] Consider running \\\`brainclaw claim create <scope>\\\` to lock your work and avoid conflicts.\\n\\n');
80
- " 2>&1 || exit 0
228
+ // The ONLY non-blocking channel to the model. permissionDecision stays 'allow'.
229
+ process.stdout.write(JSON.stringify({
230
+ hookSpecificOutput: {
231
+ hookEventName: 'PreToolUse',
232
+ permissionDecision: 'allow',
233
+ additionalContext: '[brainclaw] Editing without an active claim of your own. Claim the scope so parallel agents do not collide. Advisory only: this write is proceeding.',
234
+ },
235
+ }));
236
+ process.exit(0);
237
+ "
81
238
  `;
82
239
  }
83
240
  function generatePostMergeScript() {
@@ -122,14 +122,14 @@ function buildRequest(subcommand, loopId, opts) {
122
122
  };
123
123
  }
124
124
  }
125
- export function runLoopCommand(subcommand, args, options = {}, cwd) {
125
+ export async function runLoopCommand(subcommand, args, options = {}, cwd) {
126
126
  if (!memoryExists(cwd)) {
127
127
  console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
128
128
  process.exit(1);
129
129
  }
130
130
  const loopId = requireLoopId(args, options);
131
131
  const request = buildRequest(subcommand, loopId, options);
132
- const handled = handleBclawLoop({ args: request, cwd });
132
+ const handled = await handleBclawLoop({ args: request, cwd });
133
133
  if (handled.response.status !== 'ok') {
134
134
  const message = handled.response.error ?? handled.summary;
135
135
  fail(`bclaw_loop.${String(request.intent)} rejected the call: ${message}`, 2, options);
@@ -1,6 +1,9 @@
1
1
  import { ZodError } from 'zod';
2
2
  import { listAgentRuns } from '../core/agentruns.js';
3
3
  import { reconcileAgentRun } from '../core/agentrun-reconciler.js';
4
+ import { findReservationByRunId } from '../core/loops/attempt-reservation.js';
5
+ import { runVerify } from '../core/loops/verify-command.js';
6
+ import { runImplBind } from '../core/loops/impl-bind.js';
4
7
  import { add_artifact, advance, AwaitingFileApplyApprovalError, closeLoop, complete_turn, computeNextExpected, getLoop, IdempotencyKeyReusedError, IdempotencyOwnerMismatchError, listLoopEvents, listLoops, LockLostError, LockTimeoutError, openLoop, pause, provideInput, requestInput, resume, sweepPauseTimeouts, turn, VersionConflictError, withLoopLock, } from '../core/loops/index.js';
5
8
  import { BclawLoopRequestSchema, BCLAW_LOOP_INTENTS, } from '../core/loops/facade-schema.js';
6
9
  // NextExpectedHint type now lives in src/core/loops/next-expected.ts
@@ -179,7 +182,7 @@ function trySweepLoopTimeouts(loop_id, cwd) {
179
182
  }
180
183
  catch { /* best-effort: never block facade on sweep errors */ }
181
184
  }
182
- export function handleBclawLoop(options) {
185
+ export async function handleBclawLoop(options) {
183
186
  const startMs = Date.now();
184
187
  const defaultActor = options.defaultActor ?? 'bclaw_loop';
185
188
  const inferredIntent = inferIntent(options.args);
@@ -218,6 +221,7 @@ export function handleBclawLoop(options) {
218
221
  linked: req.linked,
219
222
  stop_condition: req.stop_condition,
220
223
  mode: req.mode,
224
+ verify: req.verify,
221
225
  created_by: agentId,
222
226
  }, options.cwd);
223
227
  const newEvents = findNewLoopEvents(loop.id, undefined, options.cwd);
@@ -257,7 +261,20 @@ export function handleBclawLoop(options) {
257
261
  continue;
258
262
  if (slotStatus === 'done' || slotStatus === 'failed' || slotStatus === 'cancelled')
259
263
  continue;
264
+ // pln#630 PR2b-c (§13 R6): GET is strictly observational for
265
+ // TURN-OWNED slots. A slot carrying current_turn_id reconciles only
266
+ // via the dedicated mutating reconcile path (never on a read), so a
267
+ // stale/racing read can't phantom-complete its run. Legacy slots
268
+ // (no current_turn_id) keep the intentional lazy reconcile
269
+ // (trp_fdf3e590) that converges silent-completion on access.
270
+ if (slot.current_turn_id)
271
+ continue;
260
272
  for (const run of listAgentRuns(options.cwd, { assignment_id: assignmentId })) {
273
+ // Belt-and-braces (review PR2b-c #D): skip by ACTUAL ownership too,
274
+ // not just the slot pointer — if a run is turn-owned but its slot
275
+ // wasn't stamped (write-ordering), GET must still not mutate it.
276
+ if (findReservationByRunId(run.id, options.cwd))
277
+ continue;
261
278
  reconcileAgentRun(run.id, options.cwd);
262
279
  }
263
280
  }
@@ -426,6 +443,70 @@ export function handleBclawLoop(options) {
426
443
  return successResponse('close', { loop, next_expected: null }, [loopArtifactEntry(loop.id), ...loopEventArtifacts(newEvents)], [sideEffectUpdate('loop', loop.id), ...loopEventSideEffects(newEvents)], [], Date.now() - startMs, summarizeLoop(loop));
427
444
  });
428
445
  }
446
+ case 'verify': {
447
+ // pln#632 — run the loop's opener-configured verify command + record a
448
+ // deterministic verify_report. runVerify manages its OWN two lock scopes (the
449
+ // spawn runs OUT of the lock), so it is NOT wrapped in withLockedLoopMutation.
450
+ const existing = getLoop(req.loop_id, options.cwd);
451
+ if (!existing) {
452
+ return errorResponse('verify', 'not_found', `unknown loop_id ${req.loop_id}`, Date.now() - startMs);
453
+ }
454
+ const beforeEvents = snapshotLoopEvents(req.loop_id, options.cwd);
455
+ const result = runVerify({ loop_id: req.loop_id, actor }, options.cwd);
456
+ const newEvents = findNewLoopEvents(result.thread.id, beforeEvents, options.cwd);
457
+ const summary = result.unconfigured
458
+ ? `verify: loop has no protocol.verify — falling back to an agent-narrated verify_report`
459
+ : result.deduped
460
+ ? `verify: a verify_report already exists for iteration ${result.thread.iteration_count} (idempotent)`
461
+ : `${result.report?.passed ? '✔ verify green' : result.report?.timed_out ? '✖ verify RED (timeout)' : '✖ verify red'}: ${result.report?.command ?? ''}`;
462
+ return successResponse('verify', {
463
+ loop: result.thread,
464
+ verify_report: result.report ?? null,
465
+ deduped: result.deduped,
466
+ unconfigured: result.unconfigured ?? false,
467
+ next_expected: computeNextExpected(result.thread),
468
+ }, [loopArtifactEntry(result.thread.id), ...loopEventArtifacts(newEvents)], [sideEffectUpdate('loop', result.thread.id), ...loopEventSideEffects(newEvents)], [], Date.now() - startMs, summary);
469
+ }
470
+ case 'bind': {
471
+ // pln#632 impl-loop bind — dispatch the loop's linked sequence + advance
472
+ // bind→execute. runImplBind awaits the async spawn (the advance takes its own
473
+ // lock via the verb), so it is NOT wrapped in withLockedLoopMutation — it mirrors
474
+ // coordinate(open_loop)'s async-handler-spawns pattern, not a synchronous verb.
475
+ const existing = getLoop(req.loop_id, options.cwd);
476
+ if (!existing) {
477
+ return errorResponse('bind', 'not_found', `unknown loop_id ${req.loop_id}`, Date.now() - startMs);
478
+ }
479
+ if (existing.kind !== 'implementation') {
480
+ return errorResponse('bind', 'validation_error', `bind is only valid for implementation loops (loop ${req.loop_id} is kind='${existing.kind}'); review/ideation loops dispatch via bclaw_coordinate`, Date.now() - startMs);
481
+ }
482
+ const beforeEvents = snapshotLoopEvents(req.loop_id, options.cwd);
483
+ const bind = await runImplBind({
484
+ loop_id: req.loop_id,
485
+ dispatcherAgent: actor,
486
+ dispatcherAgentId: agentId,
487
+ sessionId: options.sessionId,
488
+ dryRun: req.dry_run,
489
+ lanes: req.lanes,
490
+ autoExecute: req.auto_execute,
491
+ model: req.model,
492
+ maxAssignments: req.max_assignments,
493
+ }, options.cwd);
494
+ const loop = getLoop(req.loop_id, options.cwd);
495
+ const newEvents = findNewLoopEvents(loop.id, beforeEvents, options.cwd);
496
+ const sideEffects = bind.action === 'bound'
497
+ ? [sideEffectUpdate('loop', loop.id), ...loopEventSideEffects(newEvents)]
498
+ : [...loopEventSideEffects(newEvents)];
499
+ return successResponse('bind', {
500
+ loop,
501
+ sequence_id: bind.sequence_id,
502
+ action: bind.action,
503
+ advanced_to: bind.advanced_to ?? null,
504
+ auto_closed: bind.auto_closed ?? false,
505
+ dispatched: bind.messages_sent,
506
+ dispatch: bind.dispatch,
507
+ next_expected: computeNextExpected(loop),
508
+ }, [loopArtifactEntry(loop.id), ...loopEventArtifacts(newEvents)], sideEffects, bind.dispatch?.warnings ?? [], Date.now() - startMs, bind.reason);
509
+ }
429
510
  }
430
511
  }
431
512
  catch (err) {
@@ -360,19 +360,22 @@ export const MCP_READ_TOOLS = [
360
360
  },
361
361
  {
362
362
  name: 'bclaw_read_inbox',
363
- description: 'Read messages from an agent inbox. Returns pending messages by default. Use markAsRead to auto-mark pending messages as read. Supports filtering by status, type, and thread_id.',
363
+ description: 'Read messages from an agent inbox, newest-first. By default returns only ACTIONABLE messages (pending + read) and hides acknowledged/archived — pass includeAll=true, or a specific status, to widen. Message bodies are previewed (~500 chars) unless full=true; the page is size-bounded by budget_tokens so a read can never blow the token budget. Use markAsRead to auto-mark pending messages as read.',
364
364
  annotations: { tier: 'standard', category: 'coordination', headlessApproval: 'auto' },
365
365
  inputSchema: {
366
366
  type: 'object',
367
367
  properties: {
368
368
  agent: { type: 'string', description: 'Agent name whose inbox to read. Defaults to calling agent.' },
369
369
  agentId: { type: 'string', description: 'Registered agent id.' },
370
- status: { type: 'string', description: 'Filter by status: pending, read, acknowledged, archived.' },
370
+ status: { type: 'string', description: 'Filter by an exact status: pending, read, acknowledged, archived. Overrides the actionable default.' },
371
+ includeAll: { type: 'boolean', description: 'Return every status (including acknowledged + archived), disabling the actionable default. Default: false.' },
371
372
  type: { type: 'string', description: 'Filter by message type: assign, review, rfc, info, reply.' },
372
373
  thread_id: { type: 'string', description: 'Filter by thread ID to see a conversation.' },
374
+ full: { type: 'boolean', description: 'Return complete message bodies instead of ~500-char previews. Each previewed message reports text_length + truncated:true. Default: false.' },
373
375
  markAsRead: { type: 'boolean', description: 'Mark pending messages as read. Default: false.' },
374
376
  limit: { type: 'number', description: 'Maximum messages to return (default: 20).' },
375
377
  offset: { type: 'number', description: 'Skip N messages for pagination.' },
378
+ budget_tokens: { type: 'number', description: 'Cap the returned page size (~4 chars/token). Messages are trimmed until the payload fits; has_more/next_offset let you page the rest.' },
376
379
  },
377
380
  },
378
381
  },
@@ -1028,8 +1031,8 @@ const MCP_WRITE_TOOLS = [
1028
1031
  // created a loop structure without dispatching the first turn, so
1029
1032
  // nothing ever ran. Loops are opened via
1030
1033
  // bclaw_coordinate(intent='review', open_loop=true) or intent='ideate'.
1031
- enum: ['get', 'list', 'turn', 'complete_turn', 'advance', 'add_artifact', 'pause', 'resume', 'close'],
1032
- description: 'Loop lifecycle intent for driving turns inside a loop that was already opened via the coordinate facade. To START a loop, use `bclaw_coordinate(intent="review", open_loop=true, targetAgents=[…])` or `intent="ideate"` — that opens the loop AND dispatches the first turn. See docs/concepts/loop-engine.md.',
1034
+ enum: ['get', 'list', 'turn', 'complete_turn', 'advance', 'add_artifact', 'pause', 'resume', 'close', 'bind'],
1035
+ description: 'Loop lifecycle intent for driving turns inside a loop that was already opened via the coordinate facade. To START a loop, use `bclaw_coordinate(intent="review", open_loop=true, targetAgents=[…])` or `intent="ideate"` — that opens the loop AND dispatches the first turn. `bind` (implementation loops only) dispatches the loop\'s linked sequence and advances bind→execute — the engine action for the `bind` phase. See docs/concepts/loop-engine.md.',
1033
1036
  },
1034
1037
  loop_id: { type: 'string', description: 'Target loop id (lop_…). Required for every intent except open and list.' },
1035
1038
  kind: { type: 'string', enum: ['review', 'ideation', 'implementation', 'research', 'debug'], description: 'Loop kind for open / list filter.' },
@@ -1052,6 +1055,11 @@ const MCP_WRITE_TOOLS = [
1052
1055
  outcome: { type: 'string', enum: ['done', 'failed', 'cancelled'], description: 'complete_turn outcome (default done).' },
1053
1056
  failure_reason: { type: 'string', description: 'complete_turn: optional failure/cancel reason.' },
1054
1057
  artifact: { type: 'object', description: 'complete_turn / add_artifact payload: { phase, type, body?, produced_by?, ref? }.' },
1058
+ dry_run: { type: 'boolean', description: 'bind: analyze + report what would dispatch; no spawn, no advance.' },
1059
+ lanes: { type: 'array', items: { type: 'string' }, description: 'bind: restrict the dispatch to specific sequence lanes.' },
1060
+ auto_execute: { type: 'boolean', description: 'bind: deliver briefs without spawning (→ manual launch commands).' },
1061
+ model: { type: 'string', description: 'bind: model override for the dispatched agents.' },
1062
+ max_assignments: { type: 'number', description: 'bind: cap assignments made in this bind.' },
1055
1063
  to_phase: { type: 'string', description: 'advance: explicit target phase (otherwise the next phase).' },
1056
1064
  force: { type: 'boolean', description: 'advance: allow going backwards (increments iteration_count).' },
1057
1065
  reason: { type: 'string', description: 'advance / pause / close: optional reason string.' },
@@ -149,6 +149,7 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
149
149
  let agentNameCache;
150
150
  return dispatchReadTool(name, args, {
151
151
  cwd,
152
+ sourceCwd: effective.cwd,
152
153
  activeSource,
153
154
  resolvedProject,
154
155
  projectRoutingApplied,
@@ -1803,6 +1804,8 @@ function dispatchReadTool(name, args, ctx) {
1803
1804
  if (name === 'bclaw_read_inbox') {
1804
1805
  const agentName = args.agent ?? ctx.getAgentName();
1805
1806
  const markAsRead = args.markAsRead === true; // default: false — reading doesn't imply processing
1807
+ const includeAll = args.includeAll === true; // pln#627 Phase A — widen past the actionable default
1808
+ const full = args.full === true; // pln#627 Phase A — return whole bodies, not previews
1806
1809
  const result = readInbox({
1807
1810
  agent: agentName,
1808
1811
  status: args.status,
@@ -1810,21 +1813,77 @@ function dispatchReadTool(name, args, ctx) {
1810
1813
  thread_id: args.thread_id,
1811
1814
  limit: args.limit,
1812
1815
  offset: args.offset,
1816
+ includeAll,
1813
1817
  markAsRead,
1814
1818
  }, cwd);
1815
- const lines = [`Inbox for ${agentName} ${result.total} message(s):`];
1816
- for (const msg of result.messages) {
1819
+ // pln#627 Phase A bound the payload so a single inbox read can never blow
1820
+ // the MCP token budget (root cause: persona/CoDev dumps persisted as inbox
1821
+ // messages, one at 960 KB). Two independent guards:
1822
+ // 1. per-message: preview each body to INBOX_PREVIEW_CHARS unless full=true;
1823
+ // the whole body stays available via full=true (bclaw_get(inbox_message)
1824
+ // is per-agent-scoped and cannot serve one message by id).
1825
+ // 2. whole-page: boundListResult trims messages until the JSON fits the
1826
+ // char budget, the same way bclaw_find / bclaw_search do (~4 chars/token).
1827
+ const INBOX_PREVIEW_CHARS = 500;
1828
+ const projected = result.messages.map((msg) => {
1829
+ const textLength = msg.text.length;
1830
+ const truncated = !full && textLength > INBOX_PREVIEW_CHARS;
1831
+ return {
1832
+ ...msg,
1833
+ text: truncated ? msg.text.slice(0, INBOX_PREVIEW_CHARS) : msg.text,
1834
+ text_length: textLength,
1835
+ truncated,
1836
+ };
1837
+ });
1838
+ const budgetTokens = typeof args.budget_tokens === 'number' && args.budget_tokens > 0 ? args.budget_tokens : undefined;
1839
+ const charBudget = budgetTokens ? Math.min(budgetTokens * 4, DEFAULT_FIND_CHAR_BUDGET) : DEFAULT_FIND_CHAR_BUDGET;
1840
+ const bounded = boundListResult({ entity: 'inbox_message', total: result.total, items: projected }, result.offset, charBudget);
1841
+ const scopeNote = includeAll || args.status ? '' : ' actionable (pending+read); pass includeAll=true for acknowledged/archived';
1842
+ const lines = [`Inbox for ${agentName} — ${result.total} message(s)${scopeNote}:`];
1843
+ for (const msg of bounded.items) {
1817
1844
  const ack = msg.requires_ack ? ' [ACK required]' : '';
1818
1845
  const thread = msg.thread_id ? ` thread:${msg.thread_id}` : '';
1819
1846
  lines.push(` [${msg.short_label ?? msg.id}] ${msg.type} from ${msg.from} (${msg.status})${ack}${thread}`);
1820
- lines.push(` ${msg.text.slice(0, 200)}${msg.text.length > 200 ? '...' : ''}`);
1821
- }
1822
- if (result.messages.length === 0) {
1847
+ const preview = msg.text.slice(0, 200);
1848
+ const more = msg.truncated || msg.text_length > 200;
1849
+ const moreNote = more
1850
+ ? `… (${msg.text_length} chars${msg.truncated ? '; pass full=true for the whole body' : ''})`
1851
+ : '';
1852
+ lines.push(` ${preview}${moreNote}`);
1853
+ }
1854
+ if (bounded.items.length === 0) {
1823
1855
  lines.push(' (no messages)');
1824
1856
  }
1857
+ if (bounded.hint)
1858
+ lines.push('', bounded.hint);
1859
+ const nextActions = bounded.has_more
1860
+ ? [{
1861
+ tool: 'bclaw_read_inbox',
1862
+ args: {
1863
+ ...(args.agent ? { agent: args.agent } : {}),
1864
+ offset: bounded.next_offset,
1865
+ ...(args.limit ? { limit: args.limit } : {}),
1866
+ ...(args.status ? { status: args.status } : {}),
1867
+ ...(includeAll ? { includeAll: true } : {}),
1868
+ },
1869
+ when: 'to fetch the next page',
1870
+ }]
1871
+ : [];
1825
1872
  return {
1826
1873
  content: [{ type: 'text', text: lines.join('\n') }],
1827
- structuredContent: { ...result, schema_version: SCHEMA_VERSION },
1874
+ structuredContent: {
1875
+ total: result.total,
1876
+ offset: result.offset,
1877
+ limit: result.limit,
1878
+ messages: bounded.items,
1879
+ returned: bounded.returned,
1880
+ has_more: bounded.has_more,
1881
+ ...(bounded.next_offset !== undefined ? { next_offset: bounded.next_offset } : {}),
1882
+ ...(bounded.omitted_for_size ? { omitted_for_size: bounded.omitted_for_size } : {}),
1883
+ ...(bounded.hint ? { hint: bounded.hint } : {}),
1884
+ ...(nextActions.length ? { next_actions: nextActions } : {}),
1885
+ schema_version: SCHEMA_VERSION,
1886
+ },
1828
1887
  };
1829
1888
  }
1830
1889
  if (name === 'bclaw_context') {
@@ -1915,9 +1974,33 @@ function dispatchReadTool(name, args, ctx) {
1915
1974
  ` stderr: ${status.runtime.log_files.stderr?.exists ? `${status.runtime.log_files.stderr.size_bytes}B` : 'absent'}`,
1916
1975
  ` git: commits_ahead=${status.runtime.commits_ahead ?? 'n/a'} dirty_tracked=${status.runtime.dirty_tracked ?? 'n/a'}`,
1917
1976
  ];
1977
+ // pln#521 P1 (B4) — routing echo. Operators debugging a dispatch need to see
1978
+ // which project this status was read from, and WHY that project won, without
1979
+ // reverse-engineering cwd + store state. The decision (project_name/
1980
+ // project_cwd) is a first-class field; the reasoning is the `_resolution_trace`
1981
+ // sibling, which by design ships here and nowhere else. Deliberately cheap:
1982
+ // no candidate/nested-store scan on a hot read path. source_cwd is the
1983
+ // PRE-routing cwd (ctx.sourceCwd) — using the routed cwd would make the two
1984
+ // ends identical exactly when a `project` arg hopped stores, i.e. the one
1985
+ // case the trace exists to show.
1986
+ const projectCwd = resolvedProject?.path ?? cwd;
1987
+ const projectName = resolvedProject?.name;
1988
+ const resolutionTrace = {
1989
+ source_cwd: ctx.sourceCwd,
1990
+ effective_cwd: projectCwd,
1991
+ active_source: activeSource,
1992
+ ...(projectRoutingApplied && typeof args.project === 'string' ? { project_arg: args.project } : {}),
1993
+ };
1994
+ lines.push('', `Project: ${projectName ?? '(unnamed)'} — ${projectCwd} (via ${activeSource})`);
1918
1995
  return {
1919
1996
  content: [{ type: 'text', text: lines.join('\n') }],
1920
- structuredContent: { ...status, schema_version: SCHEMA_VERSION },
1997
+ structuredContent: {
1998
+ ...status,
1999
+ project_cwd: projectCwd,
2000
+ ...(projectName ? { project_name: projectName } : {}),
2001
+ _resolution_trace: resolutionTrace,
2002
+ schema_version: SCHEMA_VERSION,
2003
+ },
1921
2004
  };
1922
2005
  }
1923
2006
  throw new Error(`Unknown read tool: ${name}`);
@@ -291,6 +291,9 @@ export const generatedSchemas = {
291
291
  "failed",
292
292
  "cancelled"
293
293
  ]
294
+ },
295
+ "current_turn_id": {
296
+ "type": "string"
294
297
  }
295
298
  },
296
299
  "required": [