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.
Binary file
@@ -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 }, () => {
@@ -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 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() {
@@ -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.push(JSON.stringify({
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.push(JSON.stringify({
711
- warning: 'plan_already_assigned',
712
- plan_id: assignScope,
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.push(JSON.stringify({
721
- warning: 'scope_already_claimed',
738
+ pushStructuredWarning(warnings, warningDetails, scopeAlreadyClaimedWarning({
722
739
  scope: assignScope,
723
- existing_agent: existing.agent,
724
- existing_claim_id: existing.id,
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.push(JSON.stringify({
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.push(JSON.stringify({
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. The hint tells them exactly which canonical-
1875
- // grammar call to make next to verify spawn liveness.
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 OS pid alive AND last_event_at within the last few minutes',
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
  };