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.
- package/README.md +5 -5
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/code-map.js +4 -1
- package/dist/commands/codev.js +61 -30
- package/dist/commands/doctor.js +14 -1
- package/dist/commands/harvest.js +223 -43
- package/dist/commands/inbox.js +10 -4
- package/dist/commands/install-hooks.js +184 -27
- package/dist/commands/loop.js +2 -2
- package/dist/commands/loops-handlers.js +82 -1
- package/dist/commands/mcp-catalog.js +12 -4
- package/dist/commands/mcp-read-handlers.js +90 -7
- package/dist/commands/mcp-schemas.generated.js +3 -0
- package/dist/commands/mcp-write-claims.js +57 -0
- package/dist/commands/mcp-write-coordination.js +216 -57
- package/dist/commands/mcp-write-entities.js +11 -0
- package/dist/commands/mcp.js +29 -2
- package/dist/commands/session-end.js +15 -0
- package/dist/commands/session-start.js +19 -0
- package/dist/core/agentrun-reconciler.js +171 -7
- package/dist/core/agentruns.js +6 -1
- package/dist/core/claim-conformity.js +193 -0
- package/dist/core/claim-scope.js +155 -0
- package/dist/core/claims.js +127 -2
- package/dist/core/code-map/aggregate.js +473 -0
- package/dist/core/code-map/backend.js +36 -10
- package/dist/core/code-map/freshness.js +36 -1
- package/dist/core/code-map/lang/c/imports.scm +12 -0
- package/dist/core/code-map/lang/c/index.js +150 -0
- package/dist/core/code-map/lang/c/tags.scm +68 -0
- package/dist/core/code-map/lang/cpp/imports.scm +14 -0
- package/dist/core/code-map/lang/cpp/index.js +149 -0
- package/dist/core/code-map/lang/cpp/tags.scm +87 -0
- package/dist/core/code-map/lang/csharp/imports.scm +20 -0
- package/dist/core/code-map/lang/csharp/index.js +224 -0
- package/dist/core/code-map/lang/csharp/tags.scm +63 -0
- package/dist/core/code-map/lang/go/imports.scm +13 -0
- package/dist/core/code-map/lang/go/index.js +139 -0
- package/dist/core/code-map/lang/go/tags.scm +36 -0
- package/dist/core/code-map/lang/providers.js +12 -1
- package/dist/core/code-map/lang/ruby/imports.scm +24 -0
- package/dist/core/code-map/lang/ruby/index.js +198 -0
- package/dist/core/code-map/lang/ruby/tags.scm +49 -0
- package/dist/core/code-map/lang/rust/imports.scm +44 -0
- package/dist/core/code-map/lang/rust/index.js +136 -0
- package/dist/core/code-map/lang/rust/tags.scm +47 -0
- package/dist/core/code-map/query.js +229 -80
- package/dist/core/code-map/types.js +18 -0
- package/dist/core/code-map/work-section.js +8 -7
- package/dist/core/codev-responses.js +16 -0
- package/dist/core/dispatcher.js +176 -22
- package/dist/core/execution-adapters.js +29 -3
- package/dist/core/facade-schema.js +32 -0
- package/dist/core/guidance-telemetry.js +197 -0
- package/dist/core/ideation-loop-close.js +152 -0
- package/dist/core/instruction-templates.js +11 -3
- package/dist/core/loops/artifact-resolver.js +197 -0
- package/dist/core/loops/attempt-reservation.js +576 -0
- package/dist/core/loops/commit-intent.js +494 -0
- package/dist/core/loops/facade-schema.js +48 -0
- package/dist/core/loops/impl-bind.js +144 -0
- package/dist/core/loops/index.js +1 -1
- package/dist/core/loops/iteration-engine.js +29 -0
- package/dist/core/loops/lock.js +14 -0
- package/dist/core/loops/project-resolution.js +157 -0
- package/dist/core/loops/reconcile-turn.js +369 -0
- package/dist/core/loops/result-reducers.js +88 -0
- package/dist/core/loops/store.js +46 -7
- package/dist/core/loops/types.js +139 -11
- package/dist/core/loops/verbs.js +49 -4
- package/dist/core/loops/verify-command.js +209 -0
- package/dist/core/messaging.js +58 -5
- package/dist/core/next-actions.js +157 -0
- package/dist/core/review-loop-close.js +27 -6
- package/dist/core/review-loop-turn-dispatch.js +290 -28
- package/dist/core/runtime-signals.js +68 -0
- package/dist/core/schema.js +64 -0
- package/dist/core/surface-freshness.js +150 -0
- package/dist/core/warnings.js +98 -0
- package/dist/core/worktree.js +24 -0
- package/dist/facts.js +9 -9
- package/dist/facts.json +8 -8
- package/dist/wasm/tree-sitter-c.wasm +0 -0
- package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/dist/wasm/tree-sitter-cpp.wasm +0 -0
- package/dist/wasm/tree-sitter-go.wasm +0 -0
- package/dist/wasm/tree-sitter-ruby.wasm +0 -0
- package/dist/wasm/tree-sitter-rust.wasm +0 -0
- package/docs/cli.md +1 -1
- package/docs/code-map.md +22 -6
- package/docs/concepts/loop-engine.md +24 -0
- package/docs/concepts/observer-protocol.md +22 -0
- package/docs/concepts/plans-and-claims.md +57 -0
- package/docs/integrations/claude-code.md +53 -0
- package/docs/integrations/mcp.md +45 -0
- package/docs/mcp-schema-changelog.md +118 -2
- 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
|
|
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
|
-
|
|
52
|
-
|
|
182
|
+
let raw = '';
|
|
183
|
+
try { raw = fs.readFileSync(0, 'utf8'); } catch (e) { process.exit(0); }
|
|
53
184
|
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
fs.
|
|
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
|
-
|
|
79
|
-
process.
|
|
80
|
-
|
|
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() {
|
package/dist/commands/loop.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
1816
|
-
|
|
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
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
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: {
|
|
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: {
|
|
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}`);
|