brainclaw 1.18.0 → 1.19.1
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/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/claim.js +5 -1
- package/dist/commands/harvest.js +28 -2
- package/dist/commands/install-hooks.js +184 -27
- package/dist/commands/mcp-write-claims.js +63 -1
- package/dist/commands/mcp-write-coordination.js +57 -17
- package/dist/commands/mcp-write-entities.js +11 -0
- package/dist/commands/mcp.js +24 -1
- package/dist/commands/session-end.js +15 -0
- package/dist/commands/session-start.js +19 -0
- package/dist/core/claim-conformity.js +193 -0
- package/dist/core/claim-scope.js +155 -0
- package/dist/core/claims.js +160 -2
- package/dist/core/facade-schema.js +32 -0
- package/dist/core/guidance-telemetry.js +197 -0
- package/dist/core/ideation-loop-close.js +32 -4
- package/dist/core/instruction-templates.js +11 -3
- package/dist/core/loops/verbs.js +40 -1
- package/dist/core/next-actions.js +157 -0
- package/dist/core/review-loop-close.js +22 -4
- package/dist/core/schema.js +40 -0
- package/dist/core/surface-freshness.js +150 -0
- package/dist/core/warnings.js +98 -0
- package/dist/facts.js +5 -5
- package/dist/facts.json +4 -4
- 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 +75 -1
- package/package.json +1 -1
|
Binary file
|
package/dist/commands/claim.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildOperationalIdentity } from '../core/identity.js';
|
|
2
2
|
import { memoryExists } from '../core/io.js';
|
|
3
3
|
import { mutate } from '../core/mutation-pipeline.js';
|
|
4
|
-
import { saveClaim, generateClaimId, listClaims } from '../core/claims.js';
|
|
4
|
+
import { saveClaim, generateClaimId, listClaims, claimBaselineFields } from '../core/claims.js';
|
|
5
5
|
import { rebuildProjectMd } from '../core/markdown.js';
|
|
6
6
|
import { loadState, saveState } from '../core/state.js';
|
|
7
7
|
import { nowISO } from '../core/ids.js';
|
|
@@ -64,6 +64,10 @@ export function runClaim(description, options) {
|
|
|
64
64
|
status: 'active',
|
|
65
65
|
expires_at: options.ttl ? parseTtl(options.ttl) : undefined,
|
|
66
66
|
model: resolveCurrentModel(options.cwd),
|
|
67
|
+
// pln#636 C0-b / trp#1292 — resolved here, OUTSIDE the mutate() below, so the
|
|
68
|
+
// git subprocess never widens the critical section that serializes writes on
|
|
69
|
+
// the claims store.
|
|
70
|
+
...claimBaselineFields(options.cwd),
|
|
67
71
|
};
|
|
68
72
|
try {
|
|
69
73
|
mutate({ cwd: options.cwd }, () => {
|
package/dist/commands/harvest.js
CHANGED
|
@@ -28,6 +28,8 @@ import { dispatchReviewLoopTurn, turnOwnedReviewEnabled } from '../core/review-l
|
|
|
28
28
|
import { reconcileTurn } from '../core/loops/reconcile-turn.js';
|
|
29
29
|
import { findReservationByAssignmentId } from '../core/loops/attempt-reservation.js';
|
|
30
30
|
import { readCompletionSignals } from '../core/runtime-signals.js';
|
|
31
|
+
import { reconcileClaimConformity } from '../core/claim-conformity.js';
|
|
32
|
+
import { toWarningDetail } from '../core/warnings.js';
|
|
31
33
|
/**
|
|
32
34
|
* pln#630 PR3a — finalize a TURN-OWNED review lane via the exactly-once `reconcileTurn`
|
|
33
35
|
* instead of the legacy `closeReviewLoopFromLaneResult`. Returns `undefined` for a legacy
|
|
@@ -329,7 +331,7 @@ function laneHarvestedMarkerPath(cwd, assignmentId) {
|
|
|
329
331
|
export function harvestLaneResults(options = {}) {
|
|
330
332
|
const cwd = options.cwd ?? process.cwd();
|
|
331
333
|
const agent = options.agent ?? 'coordinator';
|
|
332
|
-
const result = { harvested: [], skipped: [], errors: [] };
|
|
334
|
+
const result = { harvested: [], skipped: [], errors: [], warnings: [] };
|
|
333
335
|
const worktreePaths = resolveLaneScanPaths(options, cwd);
|
|
334
336
|
for (const worktreePath of worktreePaths) {
|
|
335
337
|
const file = getLaneResultPath(worktreePath);
|
|
@@ -346,6 +348,7 @@ export function harvestLaneResults(options = {}) {
|
|
|
346
348
|
// Assignment filter (when harvesting a specific lane).
|
|
347
349
|
if (options.assignmentId && lane.assignment_id !== options.assignmentId)
|
|
348
350
|
continue;
|
|
351
|
+
let ideationLoop = undefined;
|
|
349
352
|
// pln#628 Focus 4B (Codex review of #87 BLOCKING 1) — a review lane must
|
|
350
353
|
// close/advance its loop on the plain report-only harvest path too, not only
|
|
351
354
|
// on `--integrate`. closeReviewLoopFromLaneResult is convergent + idempotent
|
|
@@ -371,7 +374,23 @@ export function harvestLaneResults(options = {}) {
|
|
|
371
374
|
}
|
|
372
375
|
// pln#521 P2-bis — the ideation analog: a critic lane records its critique +
|
|
373
376
|
// advances the ideation loop. Returns undefined for non-ideate scopes (no-op here).
|
|
374
|
-
closeIdeationLoopFromLaneResult(laneAssignment, lane, agent, cwd);
|
|
377
|
+
ideationLoop = closeIdeationLoopFromLaneResult(laneAssignment, lane, agent, cwd);
|
|
378
|
+
// pln#636 C2 (review F3) — the universal net's most important trigger.
|
|
379
|
+
// A file-fallback worker declares its own footprint in `files_changed`,
|
|
380
|
+
// which is BOTH cheaper and more reliable than a git diff here: by
|
|
381
|
+
// harvest time the lane's worktree may already have been reaped, so
|
|
382
|
+
// trusting the worker's declaration is the only thing that still works.
|
|
383
|
+
if (laneAssignment.claim_id && lane.files_changed?.length) {
|
|
384
|
+
const laneClaim = loadClaim(laneAssignment.claim_id, cwd);
|
|
385
|
+
if (laneClaim) {
|
|
386
|
+
const conformity = reconcileClaimConformity(laneClaim, cwd, {
|
|
387
|
+
touchedPaths: lane.files_changed,
|
|
388
|
+
});
|
|
389
|
+
if (conformity.warning) {
|
|
390
|
+
result.warnings.push(toWarningDetail(conformity.warning));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
375
394
|
}
|
|
376
395
|
}
|
|
377
396
|
catch { /* never block harvest on loop-close */ }
|
|
@@ -392,6 +411,9 @@ export function harvestLaneResults(options = {}) {
|
|
|
392
411
|
assignment_id: lane.assignment_id,
|
|
393
412
|
status: lane.status,
|
|
394
413
|
artifacts: lane.artifacts ?? [],
|
|
414
|
+
body: lane.body ?? null,
|
|
415
|
+
artifact_type: lane.artifact_type ?? null,
|
|
416
|
+
ideation_loop: ideationLoop ?? null,
|
|
395
417
|
files_changed: lane.files_changed ?? [],
|
|
396
418
|
source_worktree: worktreePath,
|
|
397
419
|
},
|
|
@@ -563,6 +585,7 @@ export function integrateLaneResults(options = {}) {
|
|
|
563
585
|
const ideationClose = closeIdeationLoopFromLaneResult(assignment, lane, actor, cwd);
|
|
564
586
|
if (ideationClose) {
|
|
565
587
|
reasons.push(`ideate-loop ${ideationClose.loop_id}: ${ideationClose.action} — ${ideationClose.reason}`);
|
|
588
|
+
entry.ideation_loop = ideationClose;
|
|
566
589
|
}
|
|
567
590
|
// pln#630 PR3a — a TURN-OWNED review lane finalizes via the exactly-once
|
|
568
591
|
// reconcileTurn, which REPLACES the legacy closer + teardown gate for this lane
|
|
@@ -704,6 +727,9 @@ export function integrateLaneResults(options = {}) {
|
|
|
704
727
|
files_changed: entry.files_changed,
|
|
705
728
|
assignment_completed: entry.assignment_completed,
|
|
706
729
|
claim_released: entry.claim_released,
|
|
730
|
+
body: lane.body ?? null,
|
|
731
|
+
artifact_type: lane.artifact_type ?? null,
|
|
732
|
+
ideation_loop: entry.ideation_loop ?? null,
|
|
707
733
|
},
|
|
708
734
|
}, cwd);
|
|
709
735
|
}
|
|
@@ -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() {
|
|
@@ -18,7 +18,10 @@ import { getTriggeredItems, renderTriggeredItems } from '../core/lifecycle.js';
|
|
|
18
18
|
import { buildContext } from '../core/context.js';
|
|
19
19
|
import { checkBrainclawInstallableUpdate, getInstalledBrainclawVersion, renderBrainclawInstallableUpdateNotice } from '../core/brainclaw-version.js';
|
|
20
20
|
import { loadConfig } from '../core/config.js';
|
|
21
|
-
import { generateClaimId, loadClaim, saveClaim, adoptClaimSession, releaseClaimWithCascade } from '../core/claims.js';
|
|
21
|
+
import { generateClaimId, loadClaim, saveClaim, adoptClaimSession, releaseClaimWithCascade, claimBaselineFields } from '../core/claims.js';
|
|
22
|
+
import { releaseClaimNextActions } from '../core/next-actions.js';
|
|
23
|
+
import { reconcileClaimConformity } from '../core/claim-conformity.js';
|
|
24
|
+
import { pushStructuredWarning } from '../core/warnings.js';
|
|
22
25
|
import { checkPolicy } from '../core/policy.js';
|
|
23
26
|
import { createWorktree as coreCreateWorktree, sanitizeBranchComponent } from '../core/worktree.js';
|
|
24
27
|
import { startSession } from './session-start.js';
|
|
@@ -125,6 +128,11 @@ export async function handleBclawClaim(payload, ctx) {
|
|
|
125
128
|
worktree_path: worktreePath,
|
|
126
129
|
expires_at: claimExpiresAt,
|
|
127
130
|
handoff_mode: handoffMode,
|
|
131
|
+
// pln#636 C0-b / trp#1292 — this handler builds its claim literal instead of
|
|
132
|
+
// going through acquireClaimScope, which is why the baseline was missing on
|
|
133
|
+
// every MCP-created claim and the conformity reconcile never had anything to
|
|
134
|
+
// compare against.
|
|
135
|
+
...claimBaselineFields(claimCwd),
|
|
128
136
|
}, claimCwd);
|
|
129
137
|
appendAuditEntry({ actor: resolvedIdentity.agent_name, actor_id: resolvedIdentity.agent_id, action: 'claim', item_id: claimId, item_type: 'claim', scope: claimScope, session_id: identity.session_id, host_id: identity.host_id }, claimCwd);
|
|
130
138
|
// Post-claim policy check: surface constraints/traps as warnings
|
|
@@ -251,6 +259,14 @@ export async function handleBclawReleaseClaim(payload, ctx) {
|
|
|
251
259
|
session_id: connectionSessionId,
|
|
252
260
|
override: coordinatorOverrideRequested,
|
|
253
261
|
};
|
|
262
|
+
// pln#636 C2 — read the claim BEFORE the cascade: release is what closes it,
|
|
263
|
+
// and the conformity comparison needs its baseline + declared footprint.
|
|
264
|
+
// Best-effort by construction; a missing claim just means no advisory.
|
|
265
|
+
let claimBeforeRelease;
|
|
266
|
+
try {
|
|
267
|
+
claimBeforeRelease = loadClaim(claimId, cwd);
|
|
268
|
+
}
|
|
269
|
+
catch { /* conformity is advisory — never block a release on it */ }
|
|
254
270
|
let cascadeResult;
|
|
255
271
|
try {
|
|
256
272
|
cascadeResult = releaseClaimWithCascade(claimId, {
|
|
@@ -268,12 +284,38 @@ export async function handleBclawReleaseClaim(payload, ctx) {
|
|
|
268
284
|
planTransitioned ? ` — plan ${cascadePlanId} → ${cascadeNewStatus}` : '',
|
|
269
285
|
planWarning ? ` ⚠ ${planWarning}` : '',
|
|
270
286
|
].join('');
|
|
287
|
+
// pln#634 — release is the single most protocol-loaded moment of the daily
|
|
288
|
+
// loop (it is where the plan cascade either fires or refuses), and it shipped
|
|
289
|
+
// pure data. Derived from what the cascade actually decided.
|
|
290
|
+
const releaseActions = releaseClaimNextActions({
|
|
291
|
+
claimId,
|
|
292
|
+
planId: cascadePlanId,
|
|
293
|
+
planTransitioned,
|
|
294
|
+
planWarning,
|
|
295
|
+
requestedPlanStatus: typeof args.planStatus === 'string' ? args.planStatus : undefined,
|
|
296
|
+
});
|
|
297
|
+
// pln#636 C2 — release is the natural reconcile point: the work is finished, so
|
|
298
|
+
// the footprint is final. Emits ONLY on a concrete, path-resolvable violation;
|
|
299
|
+
// every doubt (no baseline, prose scope, reaped worktree) stays silent.
|
|
300
|
+
const conformityWarnings = [];
|
|
301
|
+
const conformityDetails = [];
|
|
302
|
+
if (claimBeforeRelease) {
|
|
303
|
+
try {
|
|
304
|
+
const conformity = reconcileClaimConformity(claimBeforeRelease, cwd);
|
|
305
|
+
if (conformity.warning) {
|
|
306
|
+
pushStructuredWarning(conformityWarnings, conformityDetails, conformity.warning);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch { /* advisory only */ }
|
|
310
|
+
}
|
|
271
311
|
return {
|
|
272
312
|
response: toolResponse({
|
|
273
313
|
content: [{ type: 'text', text: summaryText }],
|
|
274
314
|
claim_id: claimId,
|
|
275
315
|
...(planTransitioned ? { plan_id: cascadePlanId, plan_status: cascadeNewStatus } : {}),
|
|
276
316
|
...(planWarning ? { plan_warning: planWarning, plan_id: cascadePlanId } : {}),
|
|
317
|
+
...(conformityWarnings.length ? { warnings: conformityWarnings, warning_details: conformityDetails } : {}),
|
|
318
|
+
...(releaseActions.length ? { next_actions: releaseActions } : {}),
|
|
277
319
|
}),
|
|
278
320
|
};
|
|
279
321
|
}
|
|
@@ -540,6 +582,23 @@ export async function handleBclawAssignmentUpdate(payload, ctx) {
|
|
|
540
582
|
actor: callerAgent,
|
|
541
583
|
actor_id: resolved.identity.agent_id,
|
|
542
584
|
}, cwd);
|
|
585
|
+
// pln#636 C2 — reconcile the linked claim's scope BEFORE the cascade below
|
|
586
|
+
// releases it (after release there is no claim left to read). This is the
|
|
587
|
+
// lifecycle boundary a worker crosses when it reports its own completion.
|
|
588
|
+
const asgnConformityWarnings = [];
|
|
589
|
+
const asgnConformityDetails = [];
|
|
590
|
+
if (status === 'completed' && assignment.claim_id) {
|
|
591
|
+
try {
|
|
592
|
+
const linkedClaim = loadClaim(assignment.claim_id, cwd);
|
|
593
|
+
if (linkedClaim) {
|
|
594
|
+
const conformity = reconcileClaimConformity(linkedClaim, cwd);
|
|
595
|
+
if (conformity.warning) {
|
|
596
|
+
pushStructuredWarning(asgnConformityWarnings, asgnConformityDetails, conformity.warning);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
catch { /* advisory only — never block a completion report */ }
|
|
601
|
+
}
|
|
543
602
|
// trp#928 — cascade-release the assignment's linked claim on completion.
|
|
544
603
|
// Before this landing an obedient worker had to make TWO calls to close
|
|
545
604
|
// the loop (bclaw_assignment_update status=completed AND
|
|
@@ -628,6 +687,9 @@ export async function handleBclawAssignmentUpdate(payload, ctx) {
|
|
|
628
687
|
...(result.assignment.completed_at && { completed_at: result.assignment.completed_at }),
|
|
629
688
|
last_heartbeat_at: result.assignment.last_heartbeat_at,
|
|
630
689
|
...(createdActionId ? { action_id: createdActionId } : {}),
|
|
690
|
+
...(asgnConformityWarnings.length
|
|
691
|
+
? { warnings: asgnConformityWarnings, warning_details: asgnConformityDetails }
|
|
692
|
+
: {}),
|
|
631
693
|
},
|
|
632
694
|
},
|
|
633
695
|
};
|
|
@@ -21,6 +21,8 @@ import { nowISO } from '../core/ids.js';
|
|
|
21
21
|
import { validateMcpField } from '../core/input-validation.js';
|
|
22
22
|
import { generateCandidateIdWithLabel, saveCandidate } from '../core/candidates.js';
|
|
23
23
|
import { validateLoopProjectResolution } from '../core/loops/project-resolution.js';
|
|
24
|
+
import { coordinateNextActions, dispatchNextActions } from '../core/next-actions.js';
|
|
25
|
+
import { agentValidationFailedWarning, planAlreadyAssignedWarning, pushStructuredWarning, scopeAlreadyClaimedWarning, } from '../core/warnings.js';
|
|
24
26
|
import { ackMessage, getThread, hasActiveAssignment, sendMessage } from '../core/messaging.js';
|
|
25
27
|
import { dispatch, dispatchReview, generateDispatchBrief } from '../core/dispatcher.js';
|
|
26
28
|
import { CoordinateRequestSchema } from '../core/facade-schema.js';
|
|
@@ -173,12 +175,25 @@ export async function handleBclawDispatch(args, ctx) {
|
|
|
173
175
|
item_type: 'dispatch',
|
|
174
176
|
scope: `${dispatchResult.messages_sent.length} assignments`,
|
|
175
177
|
}, cwd);
|
|
178
|
+
// pln#634 — the text body already tells a human what to do next; the
|
|
179
|
+
// structured payload told an agent nothing. Derived from the real cycle
|
|
180
|
+
// outcome: verification targets for what spawned, analysis for what is
|
|
181
|
+
// blocked, and a re-run hint for a dry run. Manual launch commands are
|
|
182
|
+
// deliberately NOT mirrored here — they are not MCP-callable.
|
|
183
|
+
const dispatchActions = dispatchNextActions({
|
|
184
|
+
spawnedTargets: spawned
|
|
185
|
+
.map((m) => m.assignment_id ?? m.run_id ?? m.claim_id)
|
|
186
|
+
.filter((id) => typeof id === 'string' && id.length > 0),
|
|
187
|
+
blockedCount: analysis.blocked.length,
|
|
188
|
+
dryRun: !!args.dryRun,
|
|
189
|
+
});
|
|
176
190
|
return {
|
|
177
191
|
response: toolResponse({
|
|
178
192
|
content: [{ type: 'text', text: lines.join('\n') }],
|
|
179
193
|
...dispatchResult,
|
|
180
194
|
sequence_id: analysis.sequence.id,
|
|
181
195
|
dry_run: !!args.dryRun,
|
|
196
|
+
...(dispatchActions.length ? { next_actions: dispatchActions } : {}),
|
|
182
197
|
}),
|
|
183
198
|
};
|
|
184
199
|
}
|
|
@@ -308,6 +323,11 @@ export async function handleBclawCoordinate(args, ctx) {
|
|
|
308
323
|
// for the intents that actually spawn a worktree worker). See the
|
|
309
324
|
// assessDirtyDispatchGuard call after the cross-project block.
|
|
310
325
|
const warnings = [];
|
|
326
|
+
// pln#635 — structured sibling of `warnings`. Deliberately a SUBSET: the
|
|
327
|
+
// codes that carry a recovery path write here, prose advisories stay
|
|
328
|
+
// string-only for now (`warnings` remains the complete channel — see
|
|
329
|
+
// core/warnings.ts for the reference-threading reason).
|
|
330
|
+
const warningDetails = [];
|
|
311
331
|
const artifacts = [];
|
|
312
332
|
const side_effects = [];
|
|
313
333
|
// can_5e62334e — codex sandboxed dispatches cannot commit in worktrees
|
|
@@ -694,8 +714,7 @@ export async function handleBclawCoordinate(args, ctx) {
|
|
|
694
714
|
// retry loops can react (e.g. fall back to a different agent).
|
|
695
715
|
const check = validateAgentForDispatch(agentName, { requireSpawnable: true });
|
|
696
716
|
if (!check.valid) {
|
|
697
|
-
warnings
|
|
698
|
-
warning: 'agent_validation_failed',
|
|
717
|
+
pushStructuredWarning(warnings, warningDetails, agentValidationFailedWarning({
|
|
699
718
|
agent: agentName,
|
|
700
719
|
code: check.code,
|
|
701
720
|
reason: check.reason,
|
|
@@ -707,21 +726,19 @@ export async function handleBclawCoordinate(args, ctx) {
|
|
|
707
726
|
const assignScope = req.scope ?? req.task;
|
|
708
727
|
// Guard: warn if there is already a non-archived assign message for this agent+scope
|
|
709
728
|
if (hasActiveAssignment(agentName, assignScope, dispatchCwd)) {
|
|
710
|
-
warnings
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
existing_agent: agentName,
|
|
729
|
+
pushStructuredWarning(warnings, warningDetails, planAlreadyAssignedWarning({
|
|
730
|
+
planId: assignScope,
|
|
731
|
+
existingAgent: agentName,
|
|
714
732
|
}));
|
|
715
733
|
}
|
|
716
734
|
// Guard: warn if there is already an active claim on the same scope
|
|
717
735
|
const conflictingClaims = listClaims(dispatchCwd).filter((c) => c.status === 'active' && c.scope === assignScope);
|
|
718
736
|
if (conflictingClaims.length > 0) {
|
|
719
737
|
const existing = conflictingClaims[0];
|
|
720
|
-
warnings
|
|
721
|
-
warning: 'scope_already_claimed',
|
|
738
|
+
pushStructuredWarning(warnings, warningDetails, scopeAlreadyClaimedWarning({
|
|
722
739
|
scope: assignScope,
|
|
723
|
-
|
|
724
|
-
|
|
740
|
+
existingAgent: existing.agent,
|
|
741
|
+
existingClaimId: existing.id,
|
|
725
742
|
}));
|
|
726
743
|
}
|
|
727
744
|
const claimResult = createCoordinatorClaim({
|
|
@@ -1255,8 +1272,7 @@ export async function handleBclawCoordinate(args, ctx) {
|
|
|
1255
1272
|
// trp#51: validate target agent before creating a new claim.
|
|
1256
1273
|
const check = validateAgentForDispatch(newAgentName, { requireSpawnable: true });
|
|
1257
1274
|
if (!check.valid) {
|
|
1258
|
-
warnings
|
|
1259
|
-
warning: 'agent_validation_failed',
|
|
1275
|
+
pushStructuredWarning(warnings, warningDetails, agentValidationFailedWarning({
|
|
1260
1276
|
agent: newAgentName,
|
|
1261
1277
|
code: check.code,
|
|
1262
1278
|
reason: check.reason,
|
|
@@ -1643,8 +1659,7 @@ export async function handleBclawCoordinate(args, ctx) {
|
|
|
1643
1659
|
// behind that only fails later at spawn time.
|
|
1644
1660
|
const critCheck = validateAgentForDispatch(slot.agent, { requireSpawnable: true });
|
|
1645
1661
|
if (!critCheck.valid) {
|
|
1646
|
-
warnings
|
|
1647
|
-
warning: 'agent_validation_failed',
|
|
1662
|
+
pushStructuredWarning(warnings, warningDetails, agentValidationFailedWarning({
|
|
1648
1663
|
agent: slot.agent,
|
|
1649
1664
|
code: critCheck.code,
|
|
1650
1665
|
reason: critCheck.reason,
|
|
@@ -1871,8 +1886,17 @@ export async function handleBclawCoordinate(args, ctx) {
|
|
|
1871
1886
|
// attach a self-documenting `verify_with` hint pointing at the assignment
|
|
1872
1887
|
// record. Callers should not take delivered_and_started at face value —
|
|
1873
1888
|
// it only attests the brief-ack sentinel was touched, not that the worker
|
|
1874
|
-
// is doing useful work.
|
|
1875
|
-
//
|
|
1889
|
+
// is doing useful work.
|
|
1890
|
+
//
|
|
1891
|
+
// pln#634 — the hint used to tell callers to expect "OS pid alive", which
|
|
1892
|
+
// directly contradicts the protocol every instruction file ships
|
|
1893
|
+
// (instruction-templates.ts: trust dispatch_status, NEVER the tracked pid).
|
|
1894
|
+
// On Windows an ack-wrapped spawn runs under cmd.exe, so agent_run.pid is
|
|
1895
|
+
// the wrapper — it exits by design and reads dead while the worker is alive
|
|
1896
|
+
// and committing (trp_7fc3e3c4). An obedient agent following the old text
|
|
1897
|
+
// killed healthy workers. The field shape is unchanged (retro-compat), the
|
|
1898
|
+
// expectation no longer lies, and the authoritative call now also ships in
|
|
1899
|
+
// `next_actions` below as bclaw_dispatch_status.
|
|
1876
1900
|
let verifyWith;
|
|
1877
1901
|
if (resultExecStatus === 'delivered_and_started') {
|
|
1878
1902
|
const firstAssignment = artifacts.find((a) => a.type === 'assignment');
|
|
@@ -1881,11 +1905,25 @@ export async function handleBclawCoordinate(args, ctx) {
|
|
|
1881
1905
|
action: 'bclaw_find',
|
|
1882
1906
|
entity: 'agent_run',
|
|
1883
1907
|
filter: { assignment_id: firstAssignment.id },
|
|
1884
|
-
expected_when_alive: 'agent_run with status="running" AND
|
|
1908
|
+
expected_when_alive: 'agent_run with status="running" AND last_event_at within the last few minutes. '
|
|
1909
|
+
+ 'Do NOT judge liveness from agent_run.pid — on an ack-wrapped spawn that pid is the '
|
|
1910
|
+
+ `wrapper, not the worker. Prefer bclaw_dispatch_status(target_id: "${firstAssignment.id}") `
|
|
1911
|
+
+ 'for a sentinel-based verdict.',
|
|
1885
1912
|
see_also: 'docs/concepts/dispatch-lifecycle.md',
|
|
1886
1913
|
};
|
|
1887
1914
|
}
|
|
1888
1915
|
}
|
|
1916
|
+
// pln#634 — outcome-derived affordances on the coordinate facade. Derived
|
|
1917
|
+
// from what actually happened (did anything spawn? was a loop opened?), not
|
|
1918
|
+
// from the intent alone; empty means the key is omitted rather than shipping
|
|
1919
|
+
// an empty array for an agent to parse.
|
|
1920
|
+
const coordinateActions = coordinateNextActions({
|
|
1921
|
+
intent: req.intent,
|
|
1922
|
+
assignmentIds: artifacts.filter((a) => a.type === 'assignment').map((a) => a.id),
|
|
1923
|
+
loopId: artifacts.find((a) => a.type === 'loop')?.id,
|
|
1924
|
+
candidateId: artifacts.find((a) => a.type === 'candidate')?.id,
|
|
1925
|
+
executionStatus: resultExecStatus,
|
|
1926
|
+
});
|
|
1889
1927
|
const facadeResponse = {
|
|
1890
1928
|
status: facadeStatus,
|
|
1891
1929
|
intent: req.intent,
|
|
@@ -1897,6 +1935,8 @@ export async function handleBclawCoordinate(args, ctx) {
|
|
|
1897
1935
|
...(resultExecStatus ? { execution_status: resultExecStatus } : {}),
|
|
1898
1936
|
...(resultExecReason ? { execution_reason: resultExecReason } : {}),
|
|
1899
1937
|
...(verifyWith ? { verify_with: verifyWith } : {}),
|
|
1938
|
+
...(coordinateActions.length ? { next_actions: coordinateActions } : {}),
|
|
1939
|
+
...(warningDetails.length ? { warning_details: warningDetails } : {}),
|
|
1900
1940
|
};
|
|
1901
1941
|
const summaryParts = [`✔ bclaw_coordinate [${req.intent}] targets=${resolvedAgents.length}`];
|
|
1902
1942
|
if (resultExecStatus)
|
|
@@ -23,6 +23,7 @@ import { createPlan, deletePlan as deletePlanOp } from '../core/operations/plan.
|
|
|
23
23
|
import { loadCandidate } from '../core/candidates.js';
|
|
24
24
|
import { resolveCrossProjectWritableTarget, resolveProjectCwd, writeCrossProjectSignal } from '../core/cross-project.js';
|
|
25
25
|
import { hasMinimumTrustLevel } from '../core/agent-registry.js';
|
|
26
|
+
import { createEntityNextActions, transitionNextActions } from '../core/next-actions.js';
|
|
26
27
|
import { acceptCandidate } from './accept.js';
|
|
27
28
|
import { rejectCandidate } from './reject.js';
|
|
28
29
|
import { applyHandoffUpdates } from './update-handoff.js';
|
|
@@ -432,6 +433,10 @@ export function handleBclawCreate(payload, ctx) {
|
|
|
432
433
|
const createContent = autoRepair
|
|
433
434
|
? [{ type: 'text', text: createText }, { type: 'text', text: renderAutoRepairWarning(autoRepair, actor ?? 'unknown') }]
|
|
434
435
|
: [{ type: 'text', text: createText }];
|
|
436
|
+
// pln#634 — a freshly created plan whose steps are never added is the most
|
|
437
|
+
// common half-finished shape in the store; a sequence with no readiness
|
|
438
|
+
// check is the second. Only those two emit a follow-up.
|
|
439
|
+
const createActions = createEntityNextActions({ entity, id: result.id });
|
|
435
440
|
return {
|
|
436
441
|
response: appendSecurityWarnings(toolResponse({
|
|
437
442
|
content: createContent,
|
|
@@ -441,6 +446,7 @@ export function handleBclawCreate(payload, ctx) {
|
|
|
441
446
|
active_source: autoSwitched ? 'auto_switch' : targetScope.active_source,
|
|
442
447
|
...(autoSwitched ? { auto_switched: true } : {}),
|
|
443
448
|
...(autoRepair ? { auto_repair: autoRepair } : {}),
|
|
449
|
+
...(createActions.length ? { next_actions: createActions } : {}),
|
|
444
450
|
},
|
|
445
451
|
}), createScan.warnings),
|
|
446
452
|
};
|
|
@@ -600,6 +606,10 @@ export function handleBclawTransition(payload, ctx) {
|
|
|
600
606
|
const transitionContent = auto_repair
|
|
601
607
|
? [{ type: 'text', text: transitionText }, { type: 'text', text: renderAutoRepairWarning(auto_repair, agent_name) }]
|
|
602
608
|
: [{ type: 'text', text: transitionText }];
|
|
609
|
+
// pln#634 — only the two transitions that imply an unambiguous next call
|
|
610
|
+
// emit anything (plan → in_progress / blocked); everything else is terminal
|
|
611
|
+
// for the caller and returns nothing rather than inventing busywork.
|
|
612
|
+
const transitionActions = transitionNextActions({ entity, id, to });
|
|
603
613
|
return {
|
|
604
614
|
response: toolResponse({
|
|
605
615
|
content: transitionContent,
|
|
@@ -609,6 +619,7 @@ export function handleBclawTransition(payload, ctx) {
|
|
|
609
619
|
active_source: autoSwitched ? 'auto_switch' : targetScope.active_source,
|
|
610
620
|
...(autoSwitched ? { auto_switched: true } : {}),
|
|
611
621
|
...(auto_repair ? { auto_repair } : {}),
|
|
622
|
+
...(transitionActions.length ? { next_actions: transitionActions } : {}),
|
|
612
623
|
},
|
|
613
624
|
}),
|
|
614
625
|
};
|