brainclaw 1.26.2 → 1.28.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 (88) hide show
  1. package/README.md +13 -0
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-coordination.js +65 -1
  4. package/dist/commands/attempt-authority.js +80 -0
  5. package/dist/commands/harvest.js +140 -61
  6. package/dist/commands/loop.js +34 -0
  7. package/dist/commands/loops-handlers.js +143 -15
  8. package/dist/commands/mcp-catalog.js +52 -18
  9. package/dist/commands/mcp-schemas.generated.js +64 -0
  10. package/dist/commands/mcp-write-claims.js +128 -1
  11. package/dist/commands/mcp-write-coordination.js +149 -76
  12. package/dist/core/agent-capability.js +1 -1
  13. package/dist/core/agentrun-reconciler.js +148 -22
  14. package/dist/core/agentruns.js +254 -29
  15. package/dist/core/assignment-request-schema.js +7 -0
  16. package/dist/core/assignment-sweeper.js +5 -3
  17. package/dist/core/assignments.js +131 -33
  18. package/dist/core/claim-request-schema.js +7 -0
  19. package/dist/core/claims.js +53 -2
  20. package/dist/core/dispatch-status.js +16 -6
  21. package/dist/core/dispatcher.js +51 -51
  22. package/dist/core/entity-operations.js +20 -0
  23. package/dist/core/events.js +4 -0
  24. package/dist/core/execution-adapters.js +189 -14
  25. package/dist/core/execution-contract.js +345 -0
  26. package/dist/core/execution.js +130 -16
  27. package/dist/core/facade-schema.js +3 -0
  28. package/dist/core/harness-adapters/base.js +150 -0
  29. package/dist/core/harness-adapters/claude.js +39 -0
  30. package/dist/core/harness-adapters/codex.js +57 -0
  31. package/dist/core/harness-adapters/harvest.js +109 -0
  32. package/dist/core/harness-adapters/index.js +8 -0
  33. package/dist/core/harness-adapters/prompt-only.js +13 -0
  34. package/dist/core/harness-adapters/registry.js +48 -0
  35. package/dist/core/harness-adapters/result.js +33 -0
  36. package/dist/core/harness-adapters/types.js +2 -0
  37. package/dist/core/ideation-loop-close.js +25 -2
  38. package/dist/core/instruction-templates.js +3 -2
  39. package/dist/core/loop-turn-dispatch.js +235 -0
  40. package/dist/core/loops/artifact-contract.js +11 -0
  41. package/dist/core/loops/attempt-authority.js +496 -0
  42. package/dist/core/loops/attempt-generations.js +509 -0
  43. package/dist/core/loops/attempt-reservation.js +197 -35
  44. package/dist/core/loops/attempt-rollout.js +404 -0
  45. package/dist/core/loops/attempt-takeover.js +155 -0
  46. package/dist/core/loops/bootstrap-acquire.js +7 -3
  47. package/dist/core/loops/brief-assembly.js +21 -4
  48. package/dist/core/loops/evidence.js +188 -0
  49. package/dist/core/loops/facade-schema.js +75 -11
  50. package/dist/core/loops/gate-policy.js +533 -0
  51. package/dist/core/loops/impl-bind.js +91 -81
  52. package/dist/core/loops/index.js +9 -0
  53. package/dist/core/loops/iteration-engine.js +31 -19
  54. package/dist/core/loops/kind-policies.js +90 -0
  55. package/dist/core/loops/lock.js +71 -13
  56. package/dist/core/loops/reconcile-turn.js +237 -18
  57. package/dist/core/loops/result-reducers.js +113 -10
  58. package/dist/core/loops/store.js +34 -3
  59. package/dist/core/loops/turn-execution.js +480 -0
  60. package/dist/core/loops/types.js +127 -3
  61. package/dist/core/loops/verbs.js +335 -99
  62. package/dist/core/loops/verify-command.js +105 -20
  63. package/dist/core/loops/workspace-digest.js +54 -0
  64. package/dist/core/review-loop-close.js +25 -3
  65. package/dist/core/review-loop-turn-dispatch.js +210 -161
  66. package/dist/core/runtime-signals.js +62 -25
  67. package/dist/core/schema.js +40 -0
  68. package/dist/core/spawn-check.js +3 -2
  69. package/dist/core/upgrades/backup.js +27 -4
  70. package/dist/facts.js +9 -8
  71. package/dist/facts.json +8 -7
  72. package/docs/cli.md +49 -1
  73. package/docs/concepts/attempt-authority.md +407 -0
  74. package/docs/concepts/evidence-attestations.md +135 -0
  75. package/docs/concepts/execution-contract.md +166 -0
  76. package/docs/concepts/harness-adapters.md +166 -0
  77. package/docs/concepts/ideation-loop.md +5 -4
  78. package/docs/concepts/loop-engine.md +302 -113
  79. package/docs/index.md +4 -1
  80. package/docs/integrations/codex.md +3 -3
  81. package/docs/integrations/mcp.md +59 -5
  82. package/docs/loops/debug.md +144 -0
  83. package/docs/loops/ideation.md +158 -0
  84. package/docs/loops/implementation.md +174 -0
  85. package/docs/loops/research.md +136 -0
  86. package/docs/loops/review.md +200 -0
  87. package/docs/mcp-schema-changelog.md +18 -5
  88. package/package.json +1 -1
@@ -1,10 +1,11 @@
1
1
  import { ZodError } from 'zod';
2
2
  import { listAgentRuns } from '../core/agentruns.js';
3
3
  import { reconcileAgentRun } from '../core/agentrun-reconciler.js';
4
+ import { dispatchLoopTurn } from '../core/loop-turn-dispatch.js';
4
5
  import { findReservationByRunId } from '../core/loops/attempt-reservation.js';
5
6
  import { runVerify } from '../core/loops/verify-command.js';
6
7
  import { runImplBind } from '../core/loops/impl-bind.js';
7
- import { add_artifact, advance, AwaitingFileApplyApprovalError, closeLoop, complete_turn, computeNextExpected, getLoop, IdempotencyKeyReusedError, IdempotencyOwnerMismatchError, listLoopEvents, listLoops, LockLostError, LockTimeoutError, openLoop, pause, provideInput, requestInput, resume, sweepPauseTimeouts, turn, VersionConflictError, withLoopLock, } from '../core/loops/index.js';
8
+ import { add_artifact, advance, AwaitingFileApplyApprovalError, closeLoop, complete_turn, computeNextExpected, getLoop, IdempotencyKeyReusedError, IdempotencyOwnerMismatchError, listLoopEvents, listLoops, LockLostError, LockTimeoutError, openLoop, pause, provideInput, requestInput, resume, sweepPauseTimeouts, takeoverLoopAttempt, readLocalAuthorityHome, turn, VersionConflictError, withLoopLock, } from '../core/loops/index.js';
8
9
  import { BclawLoopRequestSchema, BCLAW_LOOP_INTENTS, } from '../core/loops/facade-schema.js';
9
10
  // NextExpectedHint type now lives in src/core/loops/next-expected.ts
10
11
  // (hoisted per can_e57c7782 follow-up so MCP facade + CLI share the
@@ -15,6 +16,10 @@ function resolveActor(req, defaultActor) {
15
16
  return { actor, agentId };
16
17
  }
17
18
  function successResponse(intent, result, artifacts, side_effects, warnings, durationMs, summary) {
19
+ const resultLoop = result && typeof result === 'object' && 'loop' in result
20
+ ? result.loop
21
+ : undefined;
22
+ const nextActions = resultLoop ? pipelineNextActions(resultLoop) : [];
18
23
  return {
19
24
  response: {
20
25
  status: 'ok',
@@ -24,10 +29,59 @@ function successResponse(intent, result, artifacts, side_effects, warnings, dura
24
29
  side_effects,
25
30
  warnings,
26
31
  duration_ms: durationMs,
32
+ ...(nextActions.length > 0 ? { next_actions: nextActions } : {}),
27
33
  },
28
34
  summary,
29
35
  };
30
36
  }
37
+ /** Cross-loop affordances: explicit next calls, never hidden orchestration. */
38
+ function pipelineNextActions(loop) {
39
+ if (loop.kind === 'ideation') {
40
+ const draft = [...loop.artifacts].reverse().find((artifact) => artifact.type === 'plan_draft');
41
+ if (!draft || (loop.current_phase !== 'synthesis' && loop.status !== 'completed'))
42
+ return [];
43
+ const planIds = loop.linked?.plan_ids ?? [];
44
+ const sequenceIds = loop.linked?.sequence_ids ?? [];
45
+ if (planIds.length > 0 && sequenceIds.length > 0) {
46
+ return [{
47
+ tool: 'bclaw_loop',
48
+ args: {
49
+ intent: 'open', kind: 'implementation', title: `Implement ${loop.title}`,
50
+ goal: loop.goal, linked: { plan_ids: planIds, sequence_ids: sequenceIds, source_loop_id: loop.id },
51
+ verify: draft.implementation_verify,
52
+ slots: [{ role: 'implementer' }], allow_orphan: true,
53
+ },
54
+ when: 'start implementation from the accepted synthesis',
55
+ }];
56
+ }
57
+ return [{
58
+ tool: 'bclaw_create',
59
+ args: { entity: 'plan', text: draft.body ?? '<materialize the plan_draft artifact>', status: 'todo' },
60
+ when: 'materialize the synthesis before opening its implementation loop',
61
+ }];
62
+ }
63
+ if (loop.kind === 'implementation' && (loop.current_phase === 'handoff_ready' || loop.status === 'completed')) {
64
+ const handoff = [...loop.artifacts].reverse().find((artifact) => artifact.type === 'handoff');
65
+ const reviewScope = [...new Set(loop.slots.map((slot) => slot.scope_hint?.trim()).filter((scope) => Boolean(scope)))].join(',');
66
+ return [{
67
+ tool: 'bclaw_coordinate',
68
+ args: {
69
+ intent: 'review', open_loop: true,
70
+ task: handoff?.ref
71
+ ? `Review implementation loop ${loop.id}; handoff ${handoff.ref.kind}:${handoff.ref.id}`
72
+ : `Review implementation loop ${loop.id} (${loop.title})`,
73
+ targetAgents: ['<reviewer>'],
74
+ ...(reviewScope ? { scope: reviewScope } : {}),
75
+ ...(handoff?.ref && (handoff.ref.kind === 'commit' || handoff.ref.kind === 'branch')
76
+ ? { ref: handoff.ref.id }
77
+ : {}),
78
+ linked: { source_loop_id: loop.id, plan_ids: loop.linked?.plan_ids, sequence_ids: loop.linked?.sequence_ids },
79
+ },
80
+ when: 'implementation evidence is handoff-ready',
81
+ }];
82
+ }
83
+ return [];
84
+ }
31
85
  function errorResponse(intent, code, message, durationMs, result = null) {
32
86
  return {
33
87
  response: {
@@ -115,12 +169,11 @@ const SLOT_BOUND_INTENTS = new Set(['complete_turn']);
115
169
  * took over" window — the verb will not proceed if the lock's mutation_id
116
170
  * changed between `acquireLock` and `work` dispatch. It does NOT cover mid-verb
117
171
  * fs operations: the verbs themselves (`openLoop`, `advance`, …) perform their
118
- * atomic-rename + JSONL append without consulting the fence. That gap is
119
- * intentional for the MVP because the verbs are synchronous and complete in
120
- * single-digit milliseconds, much shorter than any reasonable hard_deadline
121
- * (default 30_000 ms). If a future slice adds async dispatch inside a
122
- * mutation, thread `fenceCheck` down into the verb and call it before each
123
- * committing write.
172
+ * atomic-rename + JSONL append without consulting the fence. Safety therefore
173
+ * depends on lock.ts refusing deadline/lease-based takeover of a still-live
174
+ * local process (and failing closed for remote-host owners). If a future slice
175
+ * adds async work inside a mutation or enables time-based/remote takeover,
176
+ * `fenceCheck` must first be threaded to every committing write.
124
177
  */
125
178
  function withLockedLoopMutation(req, agentId, cwd, work) {
126
179
  return withLoopLock({
@@ -128,7 +181,7 @@ function withLockedLoopMutation(req, agentId, cwd, work) {
128
181
  intent: req.intent,
129
182
  agentId,
130
183
  scope: { kind: 'loop', loopId: req.loop_id },
131
- expectedVersion: req.expected_version,
184
+ expectedVersion: 'expected_version' in req ? req.expected_version : undefined,
132
185
  clientRequestId: req.client_request_id,
133
186
  requestPayload: requestPayload(req),
134
187
  currentVersion: () => currentLoopVersion(req.loop_id, cwd),
@@ -295,6 +348,45 @@ export async function handleBclawLoop(options) {
295
348
  return successResponse('list', { loops: sliced, total: loops.length }, sliced.map((l) => loopArtifactEntry(l.id)), [], [], Date.now() - startMs, `✔ list ${sliced.length}/${loops.length} loops`);
296
349
  }
297
350
  case 'turn': {
351
+ if (req.dispatch) {
352
+ if (!req.slot_id) {
353
+ return errorResponse('turn', 'validation_error', 'turn dispatch requires slot_id', Date.now() - startMs);
354
+ }
355
+ const dispatched = await dispatchLoopTurn({
356
+ loop_id: req.loop_id,
357
+ slot_id: req.slot_id,
358
+ task: req.input ?? `Execute ${req.loop_id} slot ${req.slot_id}`,
359
+ dispatcher_agent: actor,
360
+ dispatcher_agent_id: req.agentId,
361
+ session_id: options.sessionId,
362
+ model: req.model,
363
+ auto_execute: req.auto_execute,
364
+ candidate_agents: req.target_agents,
365
+ cwd: options.cwd ?? process.cwd(),
366
+ });
367
+ // Before AttemptAuthority exists an error is a true denial. Once the
368
+ // launch grant has crossed, however, transport may fall back to a
369
+ // manual command or become crossed_unknown. Preserve the created
370
+ // entities in a successful structured response instead of reporting
371
+ // an empty-side-effect error that invites a dangerous retry.
372
+ if (dispatched.error && !dispatched.turn_id) {
373
+ return errorResponse('turn', 'dispatch_denied', dispatched.error, Date.now() - startMs);
374
+ }
375
+ const loop = getLoop(req.loop_id, options.cwd);
376
+ return successResponse('turn', { loop, dispatch: dispatched, next_expected: loop ? computeNextExpected(loop) : undefined }, [
377
+ loopArtifactEntry(req.loop_id),
378
+ ...(dispatched.assignment_id ? [{ type: 'assignment', id: dispatched.assignment_id }] : []),
379
+ ...(dispatched.run_id ? [{ type: 'agent_run', id: dispatched.run_id }] : []),
380
+ ...(dispatched.claim_id ? [{ type: 'claim', id: dispatched.claim_id }] : []),
381
+ ], [
382
+ sideEffectUpdate('loop', req.loop_id),
383
+ ...(dispatched.claim_id ? [{ action: 'create', entity: 'claim', id: dispatched.claim_id }] : []),
384
+ ...(dispatched.assignment_id ? [{ action: 'create', entity: 'assignment', id: dispatched.assignment_id }] : []),
385
+ ...(dispatched.run_id ? [{ action: 'create', entity: 'agent_run', id: dispatched.run_id }] : []),
386
+ ], dispatched.error ? [dispatched.error] : [], Date.now() - startMs, dispatched.error
387
+ ? `⚠ ${dispatched.kind}.${dispatched.phase} turn ${dispatched.turn_id} crossed; ${dispatched.error}`
388
+ : `✔ dispatched ${dispatched.kind}.${dispatched.phase} turn ${dispatched.turn_id}`);
389
+ }
298
390
  return withLockedLoopMutation(req, agentId, options.cwd, () => {
299
391
  const beforeEvents = snapshotLoopEvents(req.loop_id, options.cwd);
300
392
  const loop = turn({
@@ -316,6 +408,13 @@ export async function handleBclawLoop(options) {
316
408
  const loop = complete_turn({
317
409
  id: req.loop_id,
318
410
  slot_id: req.slot_id,
411
+ assignment_id: req.assignment_id,
412
+ turn_id: req.turn_id,
413
+ run_id: req.run_id,
414
+ nonce: req.nonce,
415
+ attempt_epoch: req.attempt_epoch,
416
+ execution_contract_hash: req.execution_contract_hash,
417
+ workspace_digest: req.workspace_digest,
319
418
  outcome: req.outcome,
320
419
  failure_reason: req.failure_reason,
321
420
  artifact: req.artifact
@@ -325,6 +424,7 @@ export async function handleBclawLoop(options) {
325
424
  body: req.artifact.body,
326
425
  ref: req.artifact.ref,
327
426
  addresses_critique: req.artifact.addresses_critique,
427
+ implementation_verify: req.artifact.implementation_verify,
328
428
  }
329
429
  : undefined,
330
430
  actor,
@@ -337,6 +437,32 @@ export async function handleBclawLoop(options) {
337
437
  return successResponse('complete_turn', { loop, next_expected: computeNextExpected(loop) }, [loopArtifactEntry(loop.id), ...loopEventArtifacts(newEvents)], [sideEffectUpdate('loop', loop.id), ...loopEventSideEffects(newEvents)], [], Date.now() - startMs, summarizeLoop(loop));
338
438
  });
339
439
  }
440
+ case 'takeover': {
441
+ const authorityHome = readLocalAuthorityHome(options.cwd ?? process.cwd());
442
+ if (!authorityHome) {
443
+ return errorResponse('takeover', 'authority_home_unavailable', 'local store/device authority identity is not initialized', Date.now() - startMs);
444
+ }
445
+ const result = takeoverLoopAttempt({
446
+ loop_id: req.loop_id,
447
+ slot_id: req.slot_id,
448
+ turn_id: req.turn_id,
449
+ expected_epoch: req.expected_epoch,
450
+ authority_home: authorityHome,
451
+ actor,
452
+ actor_id: agentId,
453
+ writer_id: agentId,
454
+ cause: req.cause,
455
+ liveness_evidence: req.liveness_evidence,
456
+ external_effect_policy: req.external_effect_policy,
457
+ next_workspace_path: req.next_workspace_path,
458
+ mode: req.takeover_mode,
459
+ cwd: options.cwd ?? process.cwd(),
460
+ });
461
+ return successResponse('takeover', {
462
+ ...result,
463
+ next_action: 'dispatch the same logical turn; the common path will project and contend on launch(next_epoch)',
464
+ }, [loopArtifactEntry(result.loop.id)], [sideEffectUpdate('loop', result.loop.id)], [], Date.now() - startMs, `✔ takeover ${result.turn_id} epoch=${result.attempt_epoch} run=${result.run_id} (armed, not spawned)`);
465
+ }
340
466
  case 'advance': {
341
467
  return withLockedLoopMutation(req, agentId, options.cwd, () => {
342
468
  const beforeEvents = snapshotLoopEvents(req.loop_id, options.cwd);
@@ -360,9 +486,9 @@ export async function handleBclawLoop(options) {
360
486
  phase: req.artifact.phase,
361
487
  type: req.artifact.type,
362
488
  body: req.artifact.body,
363
- produced_by: req.artifact.produced_by,
364
489
  ref: req.artifact.ref,
365
490
  addresses_critique: req.artifact.addresses_critique,
491
+ implementation_verify: req.artifact.implementation_verify,
366
492
  },
367
493
  actor,
368
494
  }, options.cwd);
@@ -451,7 +577,7 @@ export async function handleBclawLoop(options) {
451
577
  return errorResponse('verify', 'not_found', `unknown loop_id ${req.loop_id}`, Date.now() - startMs);
452
578
  }
453
579
  const beforeEvents = snapshotLoopEvents(req.loop_id, options.cwd);
454
- const result = runVerify({ loop_id: req.loop_id, actor }, options.cwd);
580
+ const result = runVerify({ loop_id: req.loop_id, slot_id: req.slot_id, actor }, options.cwd);
455
581
  const newEvents = findNewLoopEvents(result.thread.id, beforeEvents, options.cwd);
456
582
  const summary = result.unconfigured
457
583
  ? `verify: loop has no protocol.verify — falling back to an agent-narrated verify_report`
@@ -467,10 +593,9 @@ export async function handleBclawLoop(options) {
467
593
  }, [loopArtifactEntry(result.thread.id), ...loopEventArtifacts(newEvents)], [sideEffectUpdate('loop', result.thread.id), ...loopEventSideEffects(newEvents)], [], Date.now() - startMs, summary);
468
594
  }
469
595
  case 'bind': {
470
- // pln#632 impl-loop bind dispatch the loop's linked sequence + advance
471
- // bindexecute. runImplBind awaits the async spawn (the advance takes its own
472
- // lock via the verb), so it is NOT wrapped in withLockedLoopMutation — it mirrors
473
- // coordinate(open_loop)'s async-handler-spawns pattern, not a synchronous verb.
596
+ // Implementation bind is engine-only: validate the linked sequence and
597
+ // advance bind -> execute. Worker launch belongs exclusively to
598
+ // turn(dispatch=true), the common AttemptAuthority path.
474
599
  const existing = getLoop(req.loop_id, options.cwd);
475
600
  if (!existing) {
476
601
  return errorResponse('bind', 'not_found', `unknown loop_id ${req.loop_id}`, Date.now() - startMs);
@@ -504,7 +629,7 @@ export async function handleBclawLoop(options) {
504
629
  dispatched: bind.messages_sent,
505
630
  dispatch: bind.dispatch,
506
631
  next_expected: computeNextExpected(loop),
507
- }, [loopArtifactEntry(loop.id), ...loopEventArtifacts(newEvents)], sideEffects, bind.dispatch?.warnings ?? [], Date.now() - startMs, bind.reason);
632
+ }, [loopArtifactEntry(loop.id), ...loopEventArtifacts(newEvents)], sideEffects, [...bind.warnings, ...(bind.dispatch?.warnings ?? [])], Date.now() - startMs, bind.reason);
508
633
  }
509
634
  }
510
635
  }
@@ -538,6 +663,9 @@ export async function handleBclawLoop(options) {
538
663
  });
539
664
  }
540
665
  const message = err instanceof Error ? err.message : String(err);
666
+ if (message.startsWith('attempt_fence_')) {
667
+ return errorResponse(req.intent, 'attempt_fence_rejected', message, Date.now() - startMs);
668
+ }
541
669
  if (message.includes('unauthorized_slot_write')) {
542
670
  return errorResponse(req.intent, 'unauthorized_slot_write', message, Date.now() - startMs);
543
671
  }
@@ -854,6 +854,16 @@ const MCP_WRITE_TOOLS = [
854
854
  targetAgents: { type: 'array', items: { type: 'string' }, description: 'Agent names to target. If omitted, all spawnable agents are used.' },
855
855
  constraints: { type: 'object', description: 'Optional structured constraints passed alongside the brief (e.g. deadline, reviewCriteria).' },
856
856
  threadId: { type: 'string', description: 'Thread ID for summarize intent.' },
857
+ linked: {
858
+ type: 'object',
859
+ description: 'Optional pipeline provenance persisted on a review loop opened by this call.',
860
+ properties: {
861
+ plan_ids: { type: 'array', items: { type: 'string' } },
862
+ sequence_ids: { type: 'array', items: { type: 'string' } },
863
+ source_loop_id: { type: 'string', pattern: '^lop_[0-9a-z]+$' },
864
+ },
865
+ additionalProperties: false,
866
+ },
857
867
  autoExecute: { type: 'boolean', description: 'Attempt to spawn target agents after delivery (default: true). Applies to the spawning intents assign/review/reroute AND to multi-agent ideate (with targetAgents, it spawns one worktree-isolated critic worker per target). consult is inbox-only and ignores autoExecute; summarize just reads a thread and ignores it. When false on a spawning intent, returns command_ready_manual with bash commands for the supervisor to run.' },
858
868
  open_loop: { type: 'boolean', description: 'For intent=review only: also open a review Loop on top of the candidate (author + reviewer slots, advance to `findings`, dispatch turns). Default false — existing review callers are unaffected. See docs/concepts/loop-engine.md §Automation.' },
859
869
  review_mode: { type: 'string', enum: ['asymmetric', 'symmetric'], description: 'Optional review Loop mode when open_loop=true. `asymmetric` (default) keeps the classical author→reviewer handoff; `symmetric` lets each reviewer turn also apply fixes directly, halving round-trips for spec/doc reviews. Ignored when open_loop is false.' },
@@ -872,7 +882,7 @@ const MCP_WRITE_TOOLS = [
872
882
  },
873
883
  {
874
884
  name: 'bclaw_loop',
875
- description: 'Loop engine facade: open/turn/complete_turn/advance/add_artifact/pause/resume/close/get/list multi-turn work loops (review, ideation, implementation, research, debug). Returns a FacadeResponse with the loop thread, the newly-appended event, and a next_expected hint describing the natural next intent. Experimental schema may evolve; gate production callers behind MCP versioning (pln#392).',
885
+ description: 'Loop engine facade: open/turn/complete_turn/takeover/advance/add_artifact/pause/resume/close/verify/request_input/provide_input/get/list multi-turn work loops (review, ideation, implementation, research, debug). Direct open requires allow_orphan=true because the caller owns subsequent dispatch. `takeover` fences one physical run and arms a fresh generation; it never changes protocol gates.',
876
886
  // schemaSource is informational for now — grep target so future migrators
877
887
  // can locate zod-derived tools quickly. The parity test in
878
888
  // tests/unit/mcp-zod-parity.test.ts hard-codes its (tool, zod-schema)
@@ -890,12 +900,8 @@ const MCP_WRITE_TOOLS = [
890
900
  properties: {
891
901
  intent: {
892
902
  type: 'string',
893
- // 'open' is intentionally NOT exposed standalone (pln#542): it
894
- // created a loop structure without dispatching the first turn, so
895
- // nothing ever ran. Loops are opened via
896
- // bclaw_coordinate(intent='review', open_loop=true) or intent='ideate'.
897
- enum: ['get', 'list', 'turn', 'complete_turn', 'advance', 'add_artifact', 'pause', 'resume', 'close', 'bind'],
898
- description: 'Loop lifecycle intent for driving turns inside a loop that was already opened via the coordinate facade. To START a loop, use `bclaw_coordinate(intent="review", open_loop=true, targetAgents=[…])` or `intent="ideate"` — that opens the loop AND dispatches the first turn. `bind` (implementation loops only) dispatches the loop\'s linked sequence and advances bind→execute — the engine action for the `bind` phase. See docs/concepts/loop-engine.md.',
903
+ enum: ['open', 'get', 'list', 'turn', 'complete_turn', 'takeover', 'advance', 'add_artifact', 'pause', 'resume', 'close', 'verify', 'bind', 'request_input', 'provide_input'],
904
+ description: 'Loop lifecycle intent. Review/ideation normally start via bclaw_coordinate; implementation/research/debug may use open with allow_orphan=true and then explicitly bind/turn/dispatch. `verify` runs the configured verification; request_input/provide_input are cross-kind clarification primitives.',
899
905
  },
900
906
  loop_id: { type: 'string', description: 'Target loop id (lop_…). Required for every intent except open and list.' },
901
907
  kind: { type: 'string', enum: ['review', 'ideation', 'implementation', 'research', 'debug'], description: 'Loop kind for open / list filter.' },
@@ -906,28 +912,56 @@ const MCP_WRITE_TOOLS = [
906
912
  linked: { type: 'object', description: 'Optional top-level plan/sequence refs (open).' },
907
913
  stop_condition: { type: 'object', description: 'Optional stop_condition override (open). Composite any/all supported.' },
908
914
  mode: { type: 'string', enum: ['asymmetric', 'symmetric'], description: 'Review mode selector for open (review kind only).' },
915
+ allow_orphan: { type: 'boolean', description: 'Required true for direct open: acknowledges that the caller owns subsequent turn/bind/dispatch.' },
916
+ verify: { type: 'object', description: 'Optional open-time verification policy, e.g. { command: ["npm", "test"] }.' },
909
917
  status: { type: 'string', description: 'For intent="list": filter value (any loop status). For intent="close": target final status — accepted values are `completed` | `cancelled` | `blocked` only (NOT `failed`; map crashed/dead loops to `cancelled` with a `reason`).' },
910
918
  include_events: { type: 'boolean', description: 'get: include the event journal in the response.' },
911
919
  limit: { type: 'number', description: 'list: max loops returned.' },
912
920
  offset: { type: 'number', description: 'list: pagination offset.' },
913
- slot_id: { type: 'string', description: 'Slot id for turn / complete_turn.' },
921
+ slot_id: { type: 'string', description: 'Slot id for turn / complete_turn / takeover.' },
922
+ turn_id: { type: 'string', description: 'takeover: stable logical turn id; complete_turn: required as part of the full AttemptAuthority v2 generation fence.' },
923
+ expected_epoch: { type: 'number', description: 'takeover: active physical generation epoch expected by the caller.' },
924
+ cause: { type: 'string', description: 'takeover: audited reason for fencing the current producer.' },
925
+ liveness_evidence: { type: 'string', description: 'takeover: concrete evidence that the current producer cannot safely continue.' },
926
+ external_effect_policy: { type: 'string', enum: ['none', 'idempotent', 'externally_fenced'], description: 'takeover: declaration required before automatic re-execution.' },
927
+ next_workspace_path: { type: 'string', description: 'takeover: existing isolated workspace for the successor generation.' },
928
+ takeover_mode: { type: 'string', enum: ['takeover', 'retry'], description: 'takeover: causal close decision kind (default takeover).' },
914
929
  role: { type: 'string', description: 'Slot role for turn (resolves the first non-done slot with that role).' },
915
930
  input: { type: 'string', description: 'turn: free-form input passed to the slot.' },
916
- assignment_id: { type: 'string', description: 'turn: assignment id produced by the dispatcher to be recorded on the slot.' },
917
- dispatch: { type: 'boolean', description: 'turn: whether the caller has already dispatched the downstream work (recorded for auditability; no spawn happens here).' },
931
+ assignment_id: { type: 'string', description: 'turn: assignment id produced by an external dispatcher; complete_turn: current logical assignment id in the full v2 fence.' },
932
+ run_id: { type: 'string', description: 'complete_turn: current physical AgentRun id in the full AttemptAuthority v2 fence.' },
933
+ nonce: { type: 'string', description: 'complete_turn: current immutable launch nonce in the full AttemptAuthority v2 fence.' },
934
+ attempt_epoch: { type: 'number', description: 'complete_turn: current physical generation epoch in the full AttemptAuthority v2 fence.' },
935
+ execution_contract_hash: { type: 'string', description: 'complete_turn: SHA-256 hash of the current generation ExecutionContract.' },
936
+ workspace_digest: { type: 'string', description: 'complete_turn: digest binding the current generation to its isolated workspace.' },
937
+ dispatch: { type: 'boolean', description: 'turn: when true, run the trusted production driver (claim + AttemptAuthority + inbox + real worker launch). Plain turn remains a Loop Engine state mutation and never spawns.' },
918
938
  outcome: { type: 'string', enum: ['done', 'failed', 'cancelled'], description: 'complete_turn outcome (default done).' },
919
939
  failure_reason: { type: 'string', description: 'complete_turn: optional failure/cancel reason.' },
920
940
  artifact: { type: 'object', description: 'complete_turn / add_artifact payload: { phase, type, body?, produced_by?, ref? }.' },
921
- dry_run: { type: 'boolean', description: 'bind: analyze + report what would dispatch; no spawn, no advance.' },
922
- lanes: { type: 'array', items: { type: 'string' }, description: 'bind: restrict the dispatch to specific sequence lanes.' },
923
- auto_execute: { type: 'boolean', description: 'bind: deliver briefs without spawning (→ manual launch commands).' },
924
- model: { type: 'string', description: 'bind: model override for the dispatched agents.' },
925
- max_assignments: { type: 'number', description: 'bind: cap assignments made in this bind.' },
941
+ dry_run: { type: 'boolean', description: 'bind: validate the linked sequence without advancing; bind never spawns.' },
942
+ lanes: { type: 'array', items: { type: 'string' }, description: 'Deprecated bind launch option retained for compatibility and ignored; dispatch independent slots with turn(dispatch=true).' },
943
+ auto_execute: { type: 'boolean', description: 'turn dispatch: false returns a contract-wrapped manual launch command. Deprecated and ignored for engine-only bind.' },
944
+ model: { type: 'string', description: 'turn dispatch: model override. Deprecated and ignored for engine-only bind.' },
945
+ target_agents: { type: 'array', items: { type: 'string' }, description: 'turn dispatch: deterministic capability candidate pool used when the slot has no frozen agent.' },
946
+ max_assignments: { type: 'number', description: 'Deprecated bind launch option retained for compatibility and ignored.' },
926
947
  to_phase: { type: 'string', description: 'advance: explicit target phase (otherwise the next phase).' },
927
948
  force: { type: 'boolean', description: 'advance: allow going backwards (increments iteration_count).' },
928
949
  reason: { type: 'string', description: 'advance / pause / close: optional reason string.' },
929
- expected_version: { type: 'number', description: 'Accepted for RFC compatibility on mutating intents, but not enforced until lock/CAS wiring lands.' },
930
- client_request_id: { type: 'string', description: 'Accepted for RFC compatibility on mutating intents, but not enforced until lock/idempotency wiring lands.' },
950
+ expected_version: { type: 'number', description: 'Optimistic-CAS version enforced under the loop lock for mutating intents; stale values fail with version_conflict before mutation.' },
951
+ phase: { type: 'string', description: 'request_input: current loop phase.' },
952
+ question_text: { type: 'string', description: 'request_input: operator question (max 500 characters).' },
953
+ evidence: { type: 'array', items: { type: 'string' }, description: 'request_input: concrete evidence motivating the question.' },
954
+ suggested_default: { type: 'string', description: 'request_input: optional default answer.' },
955
+ options: { type: 'array', items: { type: 'object' }, description: 'request_input: optional 2-4 structured choices.' },
956
+ pause_scope: { type: 'string', enum: ['slot', 'loop'], description: 'request_input: pause only the slot or the whole loop.' },
957
+ on_timeout: { type: 'string', enum: ['use_default', 'cancel_loop', 'continue_incomplete'], description: 'request_input: timeout policy.' },
958
+ timeout_at: { type: 'string', description: 'request_input: optional ISO timestamp.' },
959
+ replies_to: { type: 'string', description: 'provide_input: question id (qst_…).' },
960
+ resolved_via: { type: 'string', enum: ['answer', 'choose', 'skip', 'timeout_default'], description: 'provide_input: how the answer was resolved.' },
961
+ answer_text: { type: 'string', description: 'provide_input: free-form answer.' },
962
+ chosen_option_id: { type: 'string', description: 'provide_input: selected option id.' },
963
+ by: { type: 'string', enum: ['operator', 'system'], description: 'provide_input: answer actor (default operator).' },
964
+ client_request_id: { type: 'string', description: 'Idempotency key enforced for mutating intents: exact replay returns the cached result; owner/payload reuse conflicts fail closed.' },
931
965
  project: { type: 'string', description: 'Optional linked project name/path. Routes loop reads and mutations to that project. Defaults to the current cwd.' },
932
966
  agent: { type: 'string', description: 'Caller agent name.' },
933
967
  agentId: { type: 'string', description: 'Caller registered agent id (enforced for slot-bound auth in complete_turn).' },
@@ -937,7 +971,7 @@ const MCP_WRITE_TOOLS = [
937
971
  },
938
972
  {
939
973
  name: 'bclaw_assignment_update',
940
- description: 'Report assignment lifecycle status. Part of the Agent SDK runtime protocol. Workers call this to report: accepted (acknowledging receipt), started (work begun), progress (heartbeat), completed (done with artifacts), failed (error), or blocked (external blocker). The assignment_id is provided in the dispatch brief. OWNERSHIP (trp#291): only the agent the assignment is OWNED BY (the dispatched worker) may update it — a different agent (e.g. the coordinator) gets `Agent <x> cannot update assignment owned by <y>`. If you are the coordinator and need to converge a worker run, do NOT call this; verify via bclaw_dispatch_status instead (the reconciler infers completion from sentinels/commits).',
974
+ description: 'Report assignment lifecycle status. Part of the Agent SDK runtime protocol. Legacy workers may report accepted/started/progress/completed/failed/blocked. For an AttemptAuthority v2 logical Assignment, the complete current generation fence is mandatory and only accepted/started/progress are allowed; terminal outcome goes through full-fence LANE-RESULT settlement, which then projects Assignment/Claim convergence. The assignment_id is provided in the dispatch brief. OWNERSHIP (trp#291): only the assigned agent may update it. Coordinators should verify via bclaw_dispatch_status rather than impersonating the worker.',
941
975
  annotations: { tier: 'standard', category: 'coordination', headlessApproval: 'auto' },
942
976
  inputSchema: { ...generatedSchemas.AssignmentUpdateRequest },
943
977
  },
@@ -283,6 +283,26 @@ export const generatedSchemas = {
283
283
  "phase": {
284
284
  "type": "string"
285
285
  },
286
+ "lane": {
287
+ "type": "string"
288
+ },
289
+ "scope_hint": {
290
+ "type": "string"
291
+ },
292
+ "plan_ids": {
293
+ "type": "array",
294
+ "items": {
295
+ "type": "string",
296
+ "minLength": 1
297
+ }
298
+ },
299
+ "step_ids": {
300
+ "type": "array",
301
+ "items": {
302
+ "type": "string",
303
+ "minLength": 1
304
+ }
305
+ },
286
306
  "status": {
287
307
  "type": "string",
288
308
  "enum": [
@@ -650,6 +670,28 @@ export const generatedSchemas = {
650
670
  "coordinator_override": {
651
671
  "type": "boolean",
652
672
  "description": "Opt-in override for a trusted+ caller releasing a claim they do NOT own (cross-agent teardown, ghost-claim cleanup). Rejected for contributor-level callers; audited when used. trp#928."
673
+ },
674
+ "turn_id": {
675
+ "type": "string"
676
+ },
677
+ "run_id": {
678
+ "type": "string"
679
+ },
680
+ "nonce": {
681
+ "type": "string"
682
+ },
683
+ "attempt_epoch": {
684
+ "type": "integer",
685
+ "minimum": 0,
686
+ "maximum": 9007199254740991
687
+ },
688
+ "execution_contract_hash": {
689
+ "type": "string",
690
+ "pattern": "^[a-f0-9]{64}$"
691
+ },
692
+ "workspace_digest": {
693
+ "type": "string",
694
+ "pattern": "^[a-f0-9]{64}$"
653
695
  }
654
696
  },
655
697
  "required": [
@@ -1014,6 +1056,28 @@ export const generatedSchemas = {
1014
1056
  ],
1015
1057
  "description": "Optional ActionRequired payload when status=blocked. Lets the worker request approval, user input, or clarification before resuming."
1016
1058
  },
1059
+ "turn_id": {
1060
+ "type": "string"
1061
+ },
1062
+ "run_id": {
1063
+ "type": "string"
1064
+ },
1065
+ "nonce": {
1066
+ "type": "string"
1067
+ },
1068
+ "attempt_epoch": {
1069
+ "type": "integer",
1070
+ "minimum": 0,
1071
+ "maximum": 9007199254740991
1072
+ },
1073
+ "execution_contract_hash": {
1074
+ "type": "string",
1075
+ "pattern": "^[a-f0-9]{64}$"
1076
+ },
1077
+ "workspace_digest": {
1078
+ "type": "string",
1079
+ "pattern": "^[a-f0-9]{64}$"
1080
+ },
1017
1081
  "agent": {
1018
1082
  "type": "string",
1019
1083
  "description": "Agent name."
@@ -247,8 +247,9 @@ export async function handleBclawReleaseClaim(payload, ctx) {
247
247
  };
248
248
  }
249
249
  const cwd = located.location?.cwd ?? payload.cwd;
250
+ let routedClaim;
250
251
  try {
251
- loadClaim(claimId, cwd); // validate existence before delegating
252
+ routedClaim = loadClaim(claimId, cwd); // validate existence before delegating
252
253
  }
253
254
  catch {
254
255
  const scope = located.enumeration_incomplete
@@ -290,6 +291,30 @@ export async function handleBclawReleaseClaim(payload, ctx) {
290
291
  };
291
292
  }
292
293
  }
294
+ if (routedClaim.assignment_id) {
295
+ const { findReservationByAssignmentId } = await import('../core/loops/attempt-reservation.js');
296
+ const { resolveTurnGenerationChain } = await import('../core/loops/attempt-generations.js');
297
+ const reservation = findReservationByAssignmentId(routedClaim.assignment_id, cwd);
298
+ const chain = reservation
299
+ ? resolveTurnGenerationChain(reservation.store_root, reservation.turn_id)
300
+ : undefined;
301
+ if (reservation && chain) {
302
+ if (coordinatorOverrideRequested) {
303
+ const { getLoop } = await import('../core/loops/store.js');
304
+ const loop = getLoop(reservation.loop_id, cwd);
305
+ if (!loop || loop.created_by !== releaseIdentity.identity.agent_id) {
306
+ return {
307
+ response: createToolErrorResponse('trust_error', `AttemptAuthority v2 coordinator override requires the authenticated loop creator (${loop?.created_by ?? 'unknown'}); caller is ${releaseIdentity.identity.agent_id}`, { claim_id: claimId, loop_id: reservation.loop_id }),
308
+ };
309
+ }
310
+ }
311
+ else {
312
+ return {
313
+ response: createToolErrorResponse('validation_error', `Claim ${claimId} belongs to a logical AttemptAuthority v2 Assignment and is released only after immutable settlement; worker release is refused`, { claim_id: claimId, active_epoch: chain.latest_generation.attempt_epoch }),
314
+ };
315
+ }
316
+ }
317
+ }
293
318
  const releaseAuth = {
294
319
  agent: releaseIdentity.identity.agent_name,
295
320
  agent_id: releaseIdentity.identity.agent_id,
@@ -661,6 +686,108 @@ export async function handleBclawAssignmentUpdate(payload, ctx) {
661
686
  if (assignment.agent !== callerAgent) {
662
687
  return { response: createToolErrorResponse('trust_error', `Agent ${callerAgent} cannot update assignment owned by ${assignment.agent}`) };
663
688
  }
689
+ // AttemptAuthority v2 keeps Assignment stable across physical generations.
690
+ // Therefore agent ownership alone is insufficient: a late epoch-0 worker
691
+ // still owns the same Assignment. Require its complete current fence before
692
+ // ANY progress/status mutation, AgentRun synchronization, or claim cascade.
693
+ const { findReservationByAssignmentId, evidenceMatchesAttempt } = await import('../core/loops/attempt-reservation.js');
694
+ const { resolveTurnGenerationChain } = await import('../core/loops/attempt-generations.js');
695
+ const reservation = findReservationByAssignmentId(assignmentId, cwd);
696
+ const generationChain = reservation
697
+ ? resolveTurnGenerationChain(reservation.store_root, reservation.turn_id)
698
+ : undefined;
699
+ if (reservation && generationChain) {
700
+ const matches = evidenceMatchesAttempt(reservation, {
701
+ assignment_id: assignmentId,
702
+ turn_id: typeof args.turn_id === 'string' ? args.turn_id : undefined,
703
+ run_id: typeof args.run_id === 'string' ? args.run_id : undefined,
704
+ nonce: typeof args.nonce === 'string' ? args.nonce : undefined,
705
+ attempt_epoch: typeof args.attempt_epoch === 'number' ? args.attempt_epoch : undefined,
706
+ contract_hash: typeof args.execution_contract_hash === 'string' ? args.execution_contract_hash : undefined,
707
+ workspace_digest: typeof args.workspace_digest === 'string' ? args.workspace_digest : undefined,
708
+ });
709
+ if (!matches) {
710
+ return {
711
+ response: createToolErrorResponse('validation_error', `Assignment ${assignmentId} mutation rejected: missing or stale AttemptAuthority v2 generation fence`, { assignment_id: assignmentId, active_epoch: generationChain.latest_generation.attempt_epoch }),
712
+ };
713
+ }
714
+ }
715
+ // A v2 Assignment is the stable LOGICAL identity shared by every physical
716
+ // generation. Worker lifecycle reports therefore drive only the current
717
+ // AgentRun; terminal Assignment/Claim convergence is reserved for
718
+ // reconcileTurn after close(epoch)=settled. This also lets a successor
719
+ // acknowledge/start while the stable Assignment is already `started`.
720
+ if (generationChain) {
721
+ const generation = generationChain.latest_generation;
722
+ if (!['accepted', 'started', 'progress'].includes(status)) {
723
+ return {
724
+ response: createToolErrorResponse('validation_error', `AttemptAuthority v2 Assignment ${assignmentId} is logical and cannot transition to '${status}' from a worker; submit full-fence LANE-RESULT evidence for settlement`, { assignment_id: assignmentId, run_id: generation.run_id, attempt_epoch: generation.attempt_epoch }),
725
+ };
726
+ }
727
+ if (status === 'accepted') {
728
+ if (assignment.status === 'offered') {
729
+ transitionAsgn(assignmentId, 'accepted', {
730
+ session_id: effectiveSessionId,
731
+ actor: callerAgent,
732
+ actor_id: resolved.identity.agent_id,
733
+ }, cwd);
734
+ }
735
+ else if (assignment.status !== 'accepted' && assignment.status !== 'started') {
736
+ return { response: createToolErrorResponse('operation_error', `Logical Assignment ${assignmentId} is ${assignment.status}, expected offered/accepted/started`) };
737
+ }
738
+ if (assignment.message_id) {
739
+ try {
740
+ const { ackMessage } = await import('../core/messaging.js');
741
+ ackMessage(assignment.message_id, callerAgent, cwd, { claimId: assignment.claim_id });
742
+ }
743
+ catch { /* best-effort */ }
744
+ }
745
+ return {
746
+ response: toolResponse({
747
+ content: [{ type: 'text', text: `Attempt generation ${generation.attempt_epoch} accepted for logical Assignment ${assignmentId}` }],
748
+ assignment_id: assignmentId,
749
+ status: assignment.status === 'offered' ? 'accepted' : assignment.status,
750
+ run_id: generation.run_id,
751
+ attempt_epoch: generation.attempt_epoch,
752
+ }),
753
+ };
754
+ }
755
+ if (status === 'started') {
756
+ if (assignment.status === 'accepted') {
757
+ transitionAsgn(assignmentId, 'started', {
758
+ session_id: effectiveSessionId,
759
+ actor: callerAgent,
760
+ actor_id: resolved.identity.agent_id,
761
+ }, cwd);
762
+ }
763
+ else if (assignment.status !== 'started') {
764
+ return { response: createToolErrorResponse('operation_error', `Logical Assignment ${assignmentId} is ${assignment.status}, expected accepted/started`) };
765
+ }
766
+ const { transitionAgentRun } = await import('../core/agentruns.js');
767
+ transitionAgentRun(generation.run_id, 'running', {
768
+ actor: callerAgent,
769
+ actor_id: resolved.identity.agent_id,
770
+ session_id: effectiveSessionId,
771
+ }, cwd);
772
+ return {
773
+ response: toolResponse({
774
+ content: [{ type: 'text', text: `Attempt generation ${generation.attempt_epoch} started for logical Assignment ${assignmentId}` }],
775
+ assignment_id: assignmentId,
776
+ status: 'started',
777
+ run_id: generation.run_id,
778
+ attempt_epoch: generation.attempt_epoch,
779
+ }),
780
+ };
781
+ }
782
+ const { recordAgentRunProgress } = await import('../core/agentruns.js');
783
+ recordAgentRunProgress(generation.run_id, {
784
+ message,
785
+ artifacts,
786
+ actor: callerAgent,
787
+ actor_id: resolved.identity.agent_id,
788
+ session_id: effectiveSessionId,
789
+ }, cwd);
790
+ }
664
791
  if (status === 'progress') {
665
792
  const updated = recordProg(assignmentId, {
666
793
  message,