brainclaw 1.17.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +5 -5
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/commands/code-map.js +4 -1
  4. package/dist/commands/codev.js +61 -30
  5. package/dist/commands/doctor.js +14 -1
  6. package/dist/commands/harvest.js +223 -43
  7. package/dist/commands/inbox.js +10 -4
  8. package/dist/commands/install-hooks.js +184 -27
  9. package/dist/commands/loop.js +2 -2
  10. package/dist/commands/loops-handlers.js +82 -1
  11. package/dist/commands/mcp-catalog.js +12 -4
  12. package/dist/commands/mcp-read-handlers.js +90 -7
  13. package/dist/commands/mcp-schemas.generated.js +3 -0
  14. package/dist/commands/mcp-write-claims.js +57 -0
  15. package/dist/commands/mcp-write-coordination.js +216 -57
  16. package/dist/commands/mcp-write-entities.js +11 -0
  17. package/dist/commands/mcp.js +29 -2
  18. package/dist/commands/session-end.js +15 -0
  19. package/dist/commands/session-start.js +19 -0
  20. package/dist/core/agentrun-reconciler.js +171 -7
  21. package/dist/core/agentruns.js +6 -1
  22. package/dist/core/claim-conformity.js +193 -0
  23. package/dist/core/claim-scope.js +155 -0
  24. package/dist/core/claims.js +127 -2
  25. package/dist/core/code-map/aggregate.js +473 -0
  26. package/dist/core/code-map/backend.js +36 -10
  27. package/dist/core/code-map/freshness.js +36 -1
  28. package/dist/core/code-map/lang/c/imports.scm +12 -0
  29. package/dist/core/code-map/lang/c/index.js +150 -0
  30. package/dist/core/code-map/lang/c/tags.scm +68 -0
  31. package/dist/core/code-map/lang/cpp/imports.scm +14 -0
  32. package/dist/core/code-map/lang/cpp/index.js +149 -0
  33. package/dist/core/code-map/lang/cpp/tags.scm +87 -0
  34. package/dist/core/code-map/lang/csharp/imports.scm +20 -0
  35. package/dist/core/code-map/lang/csharp/index.js +224 -0
  36. package/dist/core/code-map/lang/csharp/tags.scm +63 -0
  37. package/dist/core/code-map/lang/go/imports.scm +13 -0
  38. package/dist/core/code-map/lang/go/index.js +139 -0
  39. package/dist/core/code-map/lang/go/tags.scm +36 -0
  40. package/dist/core/code-map/lang/providers.js +12 -1
  41. package/dist/core/code-map/lang/ruby/imports.scm +24 -0
  42. package/dist/core/code-map/lang/ruby/index.js +198 -0
  43. package/dist/core/code-map/lang/ruby/tags.scm +49 -0
  44. package/dist/core/code-map/lang/rust/imports.scm +44 -0
  45. package/dist/core/code-map/lang/rust/index.js +136 -0
  46. package/dist/core/code-map/lang/rust/tags.scm +47 -0
  47. package/dist/core/code-map/query.js +229 -80
  48. package/dist/core/code-map/types.js +18 -0
  49. package/dist/core/code-map/work-section.js +8 -7
  50. package/dist/core/codev-responses.js +16 -0
  51. package/dist/core/dispatcher.js +176 -22
  52. package/dist/core/execution-adapters.js +29 -3
  53. package/dist/core/facade-schema.js +32 -0
  54. package/dist/core/guidance-telemetry.js +197 -0
  55. package/dist/core/ideation-loop-close.js +152 -0
  56. package/dist/core/instruction-templates.js +11 -3
  57. package/dist/core/loops/artifact-resolver.js +197 -0
  58. package/dist/core/loops/attempt-reservation.js +576 -0
  59. package/dist/core/loops/commit-intent.js +494 -0
  60. package/dist/core/loops/facade-schema.js +48 -0
  61. package/dist/core/loops/impl-bind.js +144 -0
  62. package/dist/core/loops/index.js +1 -1
  63. package/dist/core/loops/iteration-engine.js +29 -0
  64. package/dist/core/loops/lock.js +14 -0
  65. package/dist/core/loops/project-resolution.js +157 -0
  66. package/dist/core/loops/reconcile-turn.js +369 -0
  67. package/dist/core/loops/result-reducers.js +88 -0
  68. package/dist/core/loops/store.js +46 -7
  69. package/dist/core/loops/types.js +139 -11
  70. package/dist/core/loops/verbs.js +49 -4
  71. package/dist/core/loops/verify-command.js +209 -0
  72. package/dist/core/messaging.js +58 -5
  73. package/dist/core/next-actions.js +157 -0
  74. package/dist/core/review-loop-close.js +27 -6
  75. package/dist/core/review-loop-turn-dispatch.js +290 -28
  76. package/dist/core/runtime-signals.js +68 -0
  77. package/dist/core/schema.js +64 -0
  78. package/dist/core/surface-freshness.js +150 -0
  79. package/dist/core/warnings.js +98 -0
  80. package/dist/core/worktree.js +24 -0
  81. package/dist/facts.js +9 -9
  82. package/dist/facts.json +8 -8
  83. package/dist/wasm/tree-sitter-c.wasm +0 -0
  84. package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
  85. package/dist/wasm/tree-sitter-cpp.wasm +0 -0
  86. package/dist/wasm/tree-sitter-go.wasm +0 -0
  87. package/dist/wasm/tree-sitter-ruby.wasm +0 -0
  88. package/dist/wasm/tree-sitter-rust.wasm +0 -0
  89. package/docs/cli.md +1 -1
  90. package/docs/code-map.md +22 -6
  91. package/docs/concepts/loop-engine.md +24 -0
  92. package/docs/concepts/observer-protocol.md +22 -0
  93. package/docs/concepts/plans-and-claims.md +57 -0
  94. package/docs/integrations/claude-code.md +53 -0
  95. package/docs/integrations/mcp.md +45 -0
  96. package/docs/mcp-schema-changelog.md +118 -2
  97. package/package.json +1 -1
@@ -19,6 +19,9 @@ 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
21
  import { generateClaimId, loadClaim, saveClaim, adoptClaimSession, releaseClaimWithCascade } 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';
@@ -251,6 +254,14 @@ export async function handleBclawReleaseClaim(payload, ctx) {
251
254
  session_id: connectionSessionId,
252
255
  override: coordinatorOverrideRequested,
253
256
  };
257
+ // pln#636 C2 — read the claim BEFORE the cascade: release is what closes it,
258
+ // and the conformity comparison needs its baseline + declared footprint.
259
+ // Best-effort by construction; a missing claim just means no advisory.
260
+ let claimBeforeRelease;
261
+ try {
262
+ claimBeforeRelease = loadClaim(claimId, cwd);
263
+ }
264
+ catch { /* conformity is advisory — never block a release on it */ }
254
265
  let cascadeResult;
255
266
  try {
256
267
  cascadeResult = releaseClaimWithCascade(claimId, {
@@ -268,12 +279,38 @@ export async function handleBclawReleaseClaim(payload, ctx) {
268
279
  planTransitioned ? ` — plan ${cascadePlanId} → ${cascadeNewStatus}` : '',
269
280
  planWarning ? ` ⚠ ${planWarning}` : '',
270
281
  ].join('');
282
+ // pln#634 — release is the single most protocol-loaded moment of the daily
283
+ // loop (it is where the plan cascade either fires or refuses), and it shipped
284
+ // pure data. Derived from what the cascade actually decided.
285
+ const releaseActions = releaseClaimNextActions({
286
+ claimId,
287
+ planId: cascadePlanId,
288
+ planTransitioned,
289
+ planWarning,
290
+ requestedPlanStatus: typeof args.planStatus === 'string' ? args.planStatus : undefined,
291
+ });
292
+ // pln#636 C2 — release is the natural reconcile point: the work is finished, so
293
+ // the footprint is final. Emits ONLY on a concrete, path-resolvable violation;
294
+ // every doubt (no baseline, prose scope, reaped worktree) stays silent.
295
+ const conformityWarnings = [];
296
+ const conformityDetails = [];
297
+ if (claimBeforeRelease) {
298
+ try {
299
+ const conformity = reconcileClaimConformity(claimBeforeRelease, cwd);
300
+ if (conformity.warning) {
301
+ pushStructuredWarning(conformityWarnings, conformityDetails, conformity.warning);
302
+ }
303
+ }
304
+ catch { /* advisory only */ }
305
+ }
271
306
  return {
272
307
  response: toolResponse({
273
308
  content: [{ type: 'text', text: summaryText }],
274
309
  claim_id: claimId,
275
310
  ...(planTransitioned ? { plan_id: cascadePlanId, plan_status: cascadeNewStatus } : {}),
276
311
  ...(planWarning ? { plan_warning: planWarning, plan_id: cascadePlanId } : {}),
312
+ ...(conformityWarnings.length ? { warnings: conformityWarnings, warning_details: conformityDetails } : {}),
313
+ ...(releaseActions.length ? { next_actions: releaseActions } : {}),
277
314
  }),
278
315
  };
279
316
  }
@@ -540,6 +577,23 @@ export async function handleBclawAssignmentUpdate(payload, ctx) {
540
577
  actor: callerAgent,
541
578
  actor_id: resolved.identity.agent_id,
542
579
  }, cwd);
580
+ // pln#636 C2 — reconcile the linked claim's scope BEFORE the cascade below
581
+ // releases it (after release there is no claim left to read). This is the
582
+ // lifecycle boundary a worker crosses when it reports its own completion.
583
+ const asgnConformityWarnings = [];
584
+ const asgnConformityDetails = [];
585
+ if (status === 'completed' && assignment.claim_id) {
586
+ try {
587
+ const linkedClaim = loadClaim(assignment.claim_id, cwd);
588
+ if (linkedClaim) {
589
+ const conformity = reconcileClaimConformity(linkedClaim, cwd);
590
+ if (conformity.warning) {
591
+ pushStructuredWarning(asgnConformityWarnings, asgnConformityDetails, conformity.warning);
592
+ }
593
+ }
594
+ }
595
+ catch { /* advisory only — never block a completion report */ }
596
+ }
543
597
  // trp#928 — cascade-release the assignment's linked claim on completion.
544
598
  // Before this landing an obedient worker had to make TWO calls to close
545
599
  // the loop (bclaw_assignment_update status=completed AND
@@ -628,6 +682,9 @@ export async function handleBclawAssignmentUpdate(payload, ctx) {
628
682
  ...(result.assignment.completed_at && { completed_at: result.assignment.completed_at }),
629
683
  last_heartbeat_at: result.assignment.last_heartbeat_at,
630
684
  ...(createdActionId ? { action_id: createdActionId } : {}),
685
+ ...(asgnConformityWarnings.length
686
+ ? { warnings: asgnConformityWarnings, warning_details: asgnConformityDetails }
687
+ : {}),
631
688
  },
632
689
  },
633
690
  };
@@ -20,12 +20,16 @@ import { appendAuditEntry } from '../core/audit.js';
20
20
  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
+ 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';
23
26
  import { ackMessage, getThread, hasActiveAssignment, sendMessage } from '../core/messaging.js';
24
27
  import { dispatch, dispatchReview, generateDispatchBrief } from '../core/dispatcher.js';
25
28
  import { CoordinateRequestSchema } from '../core/facade-schema.js';
26
29
  import { buildInvokeCommand, getCapabilityProfile, getSpawnableAgents, resolveModel, validateAgentForDispatch, } from '../core/agent-capability.js';
27
30
  import { attemptExecution } from '../core/execution.js';
28
31
  import { createAgentRun, transitionAgentRun } from '../core/agentruns.js';
32
+ import { prepareTurnOwnedReviewDispatch, turnOwnedReviewEnabled } from '../core/review-loop-turn-dispatch.js';
29
33
  import { createAssignment, generateAssignmentId, patchAssignmentMessageId, transitionAssignment, } from '../core/assignments.js';
30
34
  import { createToolErrorResponse, toolResponse, } from './mcp-contract.js';
31
35
  import { handleMcpReadToolCall } from './mcp-read-handlers.js';
@@ -171,12 +175,25 @@ export async function handleBclawDispatch(args, ctx) {
171
175
  item_type: 'dispatch',
172
176
  scope: `${dispatchResult.messages_sent.length} assignments`,
173
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
+ });
174
190
  return {
175
191
  response: toolResponse({
176
192
  content: [{ type: 'text', text: lines.join('\n') }],
177
193
  ...dispatchResult,
178
194
  sequence_id: analysis.sequence.id,
179
195
  dry_run: !!args.dryRun,
196
+ ...(dispatchActions.length ? { next_actions: dispatchActions } : {}),
180
197
  }),
181
198
  };
182
199
  }
@@ -228,11 +245,13 @@ export function handleBclawSendMessage(args, ctx) {
228
245
  }, cwd);
229
246
  appendAuditEntry({ actor: resolved.identity.agent_name, actor_id: resolved.identity.agent_id, action: 'create', item_id: result.id, item_type: 'message', scope: to }, cwd);
230
247
  const threadInfo = threadId ? ` thread:${threadId}` : '';
248
+ const warningLine = result.warning ? `\n⚠ ${result.warning}` : '';
231
249
  return {
232
250
  response: toolResponse({
233
- content: [{ type: 'text', text: `✔ Message sent: [${result.shortLabel}] ${msgType} → ${to}${threadInfo}` }],
251
+ content: [{ type: 'text', text: `✔ Message sent: [${result.shortLabel}] ${msgType} → ${to}${threadInfo}${warningLine}` }],
234
252
  message_id: result.id,
235
253
  thread_id: threadId,
254
+ ...(result.warning ? { warning: result.warning } : {}),
236
255
  }),
237
256
  };
238
257
  }
@@ -270,7 +289,7 @@ export function handleBclawAckMessage(args, ctx) {
270
289
  }
271
290
  }
272
291
  export async function handleBclawCoordinate(args, ctx) {
273
- const { cwd, connectionSessionId, currentModel } = ctx;
292
+ const { cwd, connectionSessionId, currentModel, effectiveScope } = ctx;
274
293
  const startMs = Date.now();
275
294
  const parseResult = CoordinateRequestSchema.safeParse(args);
276
295
  if (!parseResult.success) {
@@ -304,6 +323,11 @@ export async function handleBclawCoordinate(args, ctx) {
304
323
  // for the intents that actually spawn a worktree worker). See the
305
324
  // assessDirtyDispatchGuard call after the cross-project block.
306
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 = [];
307
331
  const artifacts = [];
308
332
  const side_effects = [];
309
333
  // can_5e62334e — codex sandboxed dispatches cannot commit in worktrees
@@ -356,6 +380,35 @@ export async function handleBclawCoordinate(args, ctx) {
356
380
  warnings.push(`cross-project dispatch (project='${req.project}') — auto-spawn disabled; the target agent picks up the brief async via its own bclaw_work.`);
357
381
  }
358
382
  const effectiveAutoExecute = isCrossProject ? false : req.autoExecute;
383
+ // pln#521 P1 — project resolution gate. A review loop written into the wrong
384
+ // store is worse than one that never opened: candidate, claim, assignment and
385
+ // loop all persist where nobody is watching, and the reviewer spawns against
386
+ // the wrong repo (DGX misroute). So when this store can host several projects
387
+ // and NONE was selected, refuse here — before the pre-flight spawn and before
388
+ // the first write — instead of silently defaulting to cwd. Explicit `project`,
389
+ // a session switch, or an active-project pointer all count as a choice; ref /
390
+ // scope / path never do (B3 rejected, art_e29e88878209). Scoped to the failing
391
+ // path (review + open_loop): every other intent keeps its routing untouched.
392
+ let projectResolution;
393
+ if (req.intent === 'review' && req.open_loop === true) {
394
+ // Resolve from `cwd`, not `dispatchCwd`: an explicit project name is
395
+ // resolvable from the SOURCE store (its links / store chain), which is
396
+ // exactly what produced dispatchCwd above. Re-resolving from the target
397
+ // could fail for a link whose name differs from the target's project_name.
398
+ const resolution = validateLoopProjectResolution({
399
+ cwd,
400
+ projectArg: req.project,
401
+ activeSource: effectiveScope?.active_source,
402
+ });
403
+ if (!resolution.ok) {
404
+ return {
405
+ response: createToolErrorResponse(resolution.code, resolution.message, {
406
+ candidates: resolution.candidates,
407
+ }),
408
+ };
409
+ }
410
+ projectResolution = resolution;
411
+ }
359
412
  // can_30c295b4 / trp#371 Tier 2 — scope-aware dirty-working-tree guard.
360
413
  // Intents that spawn a worktree worker from HEAD can review/edit stale code,
361
414
  // so they are guarded; consult/summarize (no worktree) are not. pln#626
@@ -403,7 +456,7 @@ export async function handleBclawCoordinate(args, ctx) {
403
456
  /** Run E2E execution phase on prepared delivery entries. Returns overall execution status. */
404
457
  const runCoordinateExecution = async (prepared, opts) => {
405
458
  let overall = 'inbox_only';
406
- for (const { entry, invoke, worktreePath } of prepared) {
459
+ for (const { entry, invoke, worktreePath, turnEcho } of prepared) {
407
460
  const execResult = await attemptExecution(invoke, {
408
461
  agent: entry.agent,
409
462
  autoExecute: opts.autoExecute,
@@ -414,6 +467,8 @@ export async function handleBclawCoordinate(args, ctx) {
414
467
  dispatcherAgentId: opts.senderAgentId,
415
468
  cwd: opts.cwd,
416
469
  requireWorktree: true, // pln#531: never spawn a worker in the integration repo
470
+ turnEcho, // pln#630 — turn-owned reviewer (initial dispatch): the ack-wrapper writes the
471
+ // turn-keyed completion sentinel. undefined for every non-turn-owned entry.
417
472
  });
418
473
  entry.execution_status = execResult.execution_status;
419
474
  // pln#626 Phase 1 — carry the machine-readable reason (+ failure_kind) to
@@ -436,7 +491,23 @@ export async function handleBclawCoordinate(args, ctx) {
436
491
  // emit three identical context-free warnings (pln#626 Phase 1 R5).
437
492
  if (execResult.error)
438
493
  opts.warnings.push(`${entry.agent}: ${execResult.error}`);
439
- if (entry.assignment_id && entry.claim_id) {
494
+ if (turnEcho) {
495
+ // pln#630 risk #1 — a turn-owned reviewer's run was ALREADY created (`created`) by
496
+ // prepareTurnOwnedReviewDispatch. Do NOT mint a second run here (double-mint). Transition
497
+ // the deterministic run → running on a real spawn (mirrors dispatchReviewLoopTurn); leave
498
+ // it `created` otherwise so the no-sentinel legacy fallback (turnOwnedLaneEvidence) + the
499
+ // pre-run lease reconciler govern it. Non-turn-owned entries take the unchanged else-branch.
500
+ if (execResult.execution_status === 'delivered_and_started') {
501
+ try {
502
+ transitionAgentRun(turnEcho.run_id, 'running', {
503
+ actor: opts.senderAgent, actor_id: opts.senderAgentId, pid: execResult.pid,
504
+ status_reason: 'turn-owned reviewer spawned by coordinator',
505
+ }, opts.cwd);
506
+ }
507
+ catch { /* best-effort — the reconciler converges if this races */ }
508
+ }
509
+ }
510
+ else if (entry.assignment_id && entry.claim_id) {
440
511
  if (execResult.failure_kind === 'spawn_no_handshake') {
441
512
  try {
442
513
  const run = createAgentRun({
@@ -643,8 +714,7 @@ export async function handleBclawCoordinate(args, ctx) {
643
714
  // retry loops can react (e.g. fall back to a different agent).
644
715
  const check = validateAgentForDispatch(agentName, { requireSpawnable: true });
645
716
  if (!check.valid) {
646
- warnings.push(JSON.stringify({
647
- warning: 'agent_validation_failed',
717
+ pushStructuredWarning(warnings, warningDetails, agentValidationFailedWarning({
648
718
  agent: agentName,
649
719
  code: check.code,
650
720
  reason: check.reason,
@@ -656,21 +726,19 @@ export async function handleBclawCoordinate(args, ctx) {
656
726
  const assignScope = req.scope ?? req.task;
657
727
  // Guard: warn if there is already a non-archived assign message for this agent+scope
658
728
  if (hasActiveAssignment(agentName, assignScope, dispatchCwd)) {
659
- warnings.push(JSON.stringify({
660
- warning: 'plan_already_assigned',
661
- plan_id: assignScope,
662
- existing_agent: agentName,
729
+ pushStructuredWarning(warnings, warningDetails, planAlreadyAssignedWarning({
730
+ planId: assignScope,
731
+ existingAgent: agentName,
663
732
  }));
664
733
  }
665
734
  // Guard: warn if there is already an active claim on the same scope
666
735
  const conflictingClaims = listClaims(dispatchCwd).filter((c) => c.status === 'active' && c.scope === assignScope);
667
736
  if (conflictingClaims.length > 0) {
668
737
  const existing = conflictingClaims[0];
669
- warnings.push(JSON.stringify({
670
- warning: 'scope_already_claimed',
738
+ pushStructuredWarning(warnings, warningDetails, scopeAlreadyClaimedWarning({
671
739
  scope: assignScope,
672
- existing_agent: existing.agent,
673
- existing_claim_id: existing.id,
740
+ existingAgent: existing.agent,
741
+ existingClaimId: existing.id,
674
742
  }));
675
743
  }
676
744
  const claimResult = createCoordinatorClaim({
@@ -969,38 +1037,82 @@ export async function handleBclawCoordinate(args, ctx) {
969
1037
  id: claimResult.claimId,
970
1038
  });
971
1039
  let reviewAssignmentId;
972
- try {
973
- const preId = generateAssignmentId(dispatchCwd);
974
- const assignment = createAssignment({
975
- id: preId.id,
976
- short_label: preId.short_label,
977
- claim_id: claimResult.claimId,
1040
+ let reviewTurnEcho;
1041
+ // pln#630 turn-own the INITIAL reviewer dispatch (same default + kill-switch as the
1042
+ // fix cycle). Skipped for cross-project reviews (no local worktree/sentinel → they never
1043
+ // spawn here). WON: prepare minted the DETERMINISTIC assignment + run + turn()-bound the
1044
+ // slot, so we reuse prep.assignmentId for the brief/message/linkage below and carry the
1045
+ // turnEcho so the ack-wrapper writes the turn-keyed sentinel. DENIED: the exactly-once
1046
+ // fence says this dispatch is NOT the spawner — do NOT spawn, do NOT fall back to legacy
1047
+ // (that is the double-spawn hole), and do NOT release the (possibly shared) claim; leave
1048
+ // the slot for reconcile/self-heal. LEGACY: the unchanged inline mint runs.
1049
+ let usedTurnOwned = false;
1050
+ if (turnOwnedReviewEnabled() && !req.project) {
1051
+ const prep = prepareTurnOwnedReviewDispatch({
1052
+ loopId: loop.id,
1053
+ slotId: slot.slot_id,
978
1054
  agent: slot.agent ?? '',
979
- dispatcher_agent: senderAgent,
980
- dispatcher_session_id: connectionSessionId,
981
- scope: reviewScope,
1055
+ agentId: slot.agent_id,
1056
+ phase: 'findings',
1057
+ task: req.task,
982
1058
  description: reviewDescription,
983
- tags: ['coordinate', 'review', 'loop'],
984
- }, dispatchCwd);
985
- reviewAssignmentId = assignment.id;
986
- out.artifacts.push({ type: 'assignment', id: assignment.id });
1059
+ scope: reviewScope,
1060
+ claimId: claimResult.claimId,
1061
+ worktreePath: claimResult.worktreePath,
1062
+ dispatcherAgent: senderAgent,
1063
+ dispatcherAgentId: senderAgentId,
1064
+ sessionId: connectionSessionId,
1065
+ isReviewer: true,
1066
+ cwd: dispatchCwd,
1067
+ });
1068
+ if (prep.kind === 'won') {
1069
+ reviewAssignmentId = prep.assignmentId; // deterministic — harvest correlates on it
1070
+ reviewTurnEcho = { turn_id: prep.turnId, run_id: prep.runId, nonce: prep.nonce };
1071
+ out.artifacts.push({ type: 'assignment', id: prep.assignmentId });
1072
+ usedTurnOwned = true; // prepare already created the assignment + run + bound the slot
1073
+ }
1074
+ else if (prep.kind === 'denied') {
1075
+ out.partial = true;
1076
+ out.warnings.push(`open_loop: turn-owned reviewer dispatch denied for slot ${slot.slot_id} (${prep.reason}); not spawning — slot left for reconcile/self-heal`);
1077
+ continue; // MUST NOT spawn AND MUST NOT legacy-fallback beside a live reservation
1078
+ }
1079
+ // prep.kind === 'legacy' (pre-identity failure) → fall through to the inline mint.
987
1080
  }
988
- catch (asgErr) {
989
- out.warnings.push(`Review assignment creation failed for slot ${slot.slot_id}: ${asgErr instanceof Error ? asgErr.message : String(asgErr)}`);
1081
+ if (!usedTurnOwned) {
1082
+ try {
1083
+ const preId = generateAssignmentId(dispatchCwd);
1084
+ const assignment = createAssignment({
1085
+ id: preId.id,
1086
+ short_label: preId.short_label,
1087
+ claim_id: claimResult.claimId,
1088
+ agent: slot.agent ?? '',
1089
+ dispatcher_agent: senderAgent,
1090
+ dispatcher_session_id: connectionSessionId,
1091
+ scope: reviewScope,
1092
+ description: reviewDescription,
1093
+ tags: ['coordinate', 'review', 'loop'],
1094
+ }, dispatchCwd);
1095
+ reviewAssignmentId = assignment.id;
1096
+ out.artifacts.push({ type: 'assignment', id: assignment.id });
1097
+ }
1098
+ catch (asgErr) {
1099
+ out.warnings.push(`Review assignment creation failed for slot ${slot.slot_id}: ${asgErr instanceof Error ? asgErr.message : String(asgErr)}`);
1100
+ }
1101
+ // pln#628 Focus 4B (BLOCKING 2) — assign the slot NOW that the
1102
+ // claim/assignment exist, binding their ids onto the slot so the
1103
+ // harvest close resolves this exact reviewer by assignment_id. Runs
1104
+ // even if assignment creation failed (undefined id → the harvest
1105
+ // falls back to the legacy agent match for this one slot). (For the
1106
+ // turn-owned WON path, prepare already turn()-bound the slot.)
1107
+ turn({
1108
+ id: loop.id,
1109
+ slot_id: slot.slot_id,
1110
+ actor: creatorActor,
1111
+ input: req.task,
1112
+ assignment_id: reviewAssignmentId,
1113
+ claim_id: claimResult.claimId,
1114
+ }, dispatchCwd);
990
1115
  }
991
- // pln#628 Focus 4B (BLOCKING 2) — assign the slot NOW that the
992
- // claim/assignment exist, binding their ids onto the slot so the
993
- // harvest close resolves this exact reviewer by assignment_id. Runs
994
- // even if assignment creation failed (undefined id → the harvest
995
- // falls back to the legacy agent match for this one slot).
996
- turn({
997
- id: loop.id,
998
- slot_id: slot.slot_id,
999
- actor: creatorActor,
1000
- input: req.task,
1001
- assignment_id: reviewAssignmentId,
1002
- claim_id: claimResult.claimId,
1003
- }, dispatchCwd);
1004
1116
  const reviewBrief = buildCoordinateBrief(slot.agent ?? '', reviewDescription + reviewVerdictBriefSuffix, {
1005
1117
  claimId: claimResult.claimId,
1006
1118
  scope: reviewScope,
@@ -1045,6 +1157,7 @@ export async function handleBclawCoordinate(args, ctx) {
1045
1157
  entry: queued.entry,
1046
1158
  invoke: queued.invoke,
1047
1159
  worktreePath: claimResult.worktreePath,
1160
+ turnEcho: reviewTurnEcho,
1048
1161
  });
1049
1162
  }
1050
1163
  catch (dispatchErr) {
@@ -1105,6 +1218,16 @@ export async function handleBclawCoordinate(args, ctx) {
1105
1218
  result = {
1106
1219
  candidate_id: output.candidateId,
1107
1220
  selected_targets: resolvedAgents,
1221
+ // pln#521 P1 (B4) — echo the routing decision so an operator can see WHERE
1222
+ // the loop landed without reverse-engineering cwd and store state. Present
1223
+ // for open_loop reviews (the gated path); the reasoning behind the decision
1224
+ // ships as `_resolution_trace` on dispatch_status, not here.
1225
+ ...(projectResolution
1226
+ ? {
1227
+ project_cwd: projectResolution.project_cwd,
1228
+ ...(projectResolution.project_name ? { project_name: projectResolution.project_name } : {}),
1229
+ }
1230
+ : {}),
1108
1231
  // pln#626 Phase 1 (review rework) — expose the reviewer delivery entries
1109
1232
  // so review is as honest as assign/reroute: each entry's execution_reason
1110
1233
  // (set by runCoordinateExecution) feeds the top-level derivation, so a
@@ -1149,8 +1272,7 @@ export async function handleBclawCoordinate(args, ctx) {
1149
1272
  // trp#51: validate target agent before creating a new claim.
1150
1273
  const check = validateAgentForDispatch(newAgentName, { requireSpawnable: true });
1151
1274
  if (!check.valid) {
1152
- warnings.push(JSON.stringify({
1153
- warning: 'agent_validation_failed',
1275
+ pushStructuredWarning(warnings, warningDetails, agentValidationFailedWarning({
1154
1276
  agent: newAgentName,
1155
1277
  code: check.code,
1156
1278
  reason: check.reason,
@@ -1537,8 +1659,7 @@ export async function handleBclawCoordinate(args, ctx) {
1537
1659
  // behind that only fails later at spawn time.
1538
1660
  const critCheck = validateAgentForDispatch(slot.agent, { requireSpawnable: true });
1539
1661
  if (!critCheck.valid) {
1540
- warnings.push(JSON.stringify({
1541
- warning: 'agent_validation_failed',
1662
+ pushStructuredWarning(warnings, warningDetails, agentValidationFailedWarning({
1542
1663
  agent: slot.agent,
1543
1664
  code: critCheck.code,
1544
1665
  reason: critCheck.reason,
@@ -1550,12 +1671,6 @@ export async function handleBclawCoordinate(args, ctx) {
1550
1671
  slotRole: slot.role,
1551
1672
  memoryProvider: provider,
1552
1673
  });
1553
- turn({
1554
- id: loopId,
1555
- slot_id: slot.slot_id,
1556
- actor: creatorActor,
1557
- input: briefResult.text,
1558
- }, dispatchCwd);
1559
1674
  // pln#626 Phase 2 (Option B) — spawn the critic as a worktree-isolated
1560
1675
  // worker, mirroring the intent=assign / review chain. Each critic gets
1561
1676
  // its OWN claim + worktree (scope is unique per slot) so parallel
@@ -1605,6 +1720,23 @@ export async function handleBclawCoordinate(args, ctx) {
1605
1720
  catch (asgErr) {
1606
1721
  warnings.push(`ideate assignment creation failed for slot ${slot.slot_id}: ${asgErr instanceof Error ? asgErr.message : String(asgErr)}`);
1607
1722
  }
1723
+ // pln#629 — bind the slot to its claim/assignment NOW that both
1724
+ // exist (mirrors the review path, pln#628 BLOCKING 2). The turn()
1725
+ // used to fire BEFORE the assignment was created, leaving
1726
+ // slot.assignment_id undefined: bclaw_loop get's reconcile then
1727
+ // skipped the critic slot (loops-handlers.ts `if (!assignmentId)
1728
+ // continue`) and dispatch_status(lop_) resolved no assignment, so
1729
+ // ideate loops could never be reconciled (trp_dfe0b941 /
1730
+ // trp_2187b340 / trp_1de94516). Runs even if assignment creation
1731
+ // failed (undefined id → legacy agent-match fallback, as review).
1732
+ turn({
1733
+ id: loopId,
1734
+ slot_id: slot.slot_id,
1735
+ actor: creatorActor,
1736
+ input: briefResult.text,
1737
+ assignment_id: criticAssignmentId,
1738
+ claim_id: claimResult.claimId,
1739
+ }, dispatchCwd);
1608
1740
  // pln#626 Phase 2 — the critique-only contract must reach the
1609
1741
  // DELIVERED brief, not just the claim record: buildCoordinateBrief
1610
1742
  // wraps this in a worker envelope, so prepend the constraint + the
@@ -1754,8 +1886,17 @@ export async function handleBclawCoordinate(args, ctx) {
1754
1886
  // attach a self-documenting `verify_with` hint pointing at the assignment
1755
1887
  // record. Callers should not take delivered_and_started at face value —
1756
1888
  // it only attests the brief-ack sentinel was touched, not that the worker
1757
- // is doing useful work. The hint tells them exactly which canonical-
1758
- // 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.
1759
1900
  let verifyWith;
1760
1901
  if (resultExecStatus === 'delivered_and_started') {
1761
1902
  const firstAssignment = artifacts.find((a) => a.type === 'assignment');
@@ -1764,11 +1905,25 @@ export async function handleBclawCoordinate(args, ctx) {
1764
1905
  action: 'bclaw_find',
1765
1906
  entity: 'agent_run',
1766
1907
  filter: { assignment_id: firstAssignment.id },
1767
- 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.',
1768
1912
  see_also: 'docs/concepts/dispatch-lifecycle.md',
1769
1913
  };
1770
1914
  }
1771
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
+ });
1772
1927
  const facadeResponse = {
1773
1928
  status: facadeStatus,
1774
1929
  intent: req.intent,
@@ -1780,6 +1935,8 @@ export async function handleBclawCoordinate(args, ctx) {
1780
1935
  ...(resultExecStatus ? { execution_status: resultExecStatus } : {}),
1781
1936
  ...(resultExecReason ? { execution_reason: resultExecReason } : {}),
1782
1937
  ...(verifyWith ? { verify_with: verifyWith } : {}),
1938
+ ...(coordinateActions.length ? { next_actions: coordinateActions } : {}),
1939
+ ...(warningDetails.length ? { warning_details: warningDetails } : {}),
1783
1940
  };
1784
1941
  const summaryParts = [`✔ bclaw_coordinate [${req.intent}] targets=${resolvedAgents.length}`];
1785
1942
  if (resultExecStatus)
@@ -1806,7 +1963,9 @@ export async function handleBclawLoop(args, ctx) {
1806
1963
  }
1807
1964
  // pln#562 step 4 — dispatching a turn hands work to another agent; gate
1808
1965
  // it at the same trust bar as the other dispatch surfaces.
1809
- if (args?.intent === 'turn' && args?.dispatch === true) {
1966
+ // pln#632 `bind` also SPAWNS real workers (it dispatches the loop's linked
1967
+ // sequence), so it is gated at the same 'trusted' bar as turn-dispatch / coordinate.
1968
+ if ((args?.intent === 'turn' && args?.dispatch === true) || args?.intent === 'bind') {
1810
1969
  const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'trusted', cwd, connectionSessionId);
1811
1970
  if (resolved.error) {
1812
1971
  return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
@@ -1814,7 +1973,7 @@ export async function handleBclawLoop(args, ctx) {
1814
1973
  }
1815
1974
  const { handleBclawLoop: runLoopIntent } = await import('./loops-handlers.js');
1816
1975
  const targetCwd = resolveProjectCwd(args?.project, cwd);
1817
- const result = runLoopIntent({ args: args, cwd: targetCwd });
1976
+ const result = await runLoopIntent({ args: args, cwd: targetCwd, sessionId: connectionSessionId });
1818
1977
  return {
1819
1978
  response: toolResponse({
1820
1979
  content: [{ type: 'text', text: result.summary }],
@@ -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
  };