brainclaw 1.26.2 → 1.27.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 (86) 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 +87 -14
  8. package/dist/commands/mcp-catalog.js +42 -18
  9. package/dist/commands/mcp-schemas.generated.js +44 -0
  10. package/dist/commands/mcp-write-claims.js +128 -1
  11. package/dist/commands/mcp-write-coordination.js +146 -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 +160 -14
  25. package/dist/core/execution-contract.js +345 -0
  26. package/dist/core/execution.js +130 -16
  27. package/dist/core/harness-adapters/base.js +150 -0
  28. package/dist/core/harness-adapters/claude.js +39 -0
  29. package/dist/core/harness-adapters/codex.js +57 -0
  30. package/dist/core/harness-adapters/harvest.js +109 -0
  31. package/dist/core/harness-adapters/index.js +8 -0
  32. package/dist/core/harness-adapters/prompt-only.js +13 -0
  33. package/dist/core/harness-adapters/registry.js +48 -0
  34. package/dist/core/harness-adapters/result.js +33 -0
  35. package/dist/core/harness-adapters/types.js +2 -0
  36. package/dist/core/ideation-loop-close.js +25 -2
  37. package/dist/core/instruction-templates.js +3 -2
  38. package/dist/core/loop-turn-dispatch.js +207 -0
  39. package/dist/core/loops/artifact-contract.js +11 -0
  40. package/dist/core/loops/attempt-authority.js +476 -0
  41. package/dist/core/loops/attempt-generations.js +509 -0
  42. package/dist/core/loops/attempt-reservation.js +197 -35
  43. package/dist/core/loops/attempt-rollout.js +404 -0
  44. package/dist/core/loops/attempt-takeover.js +155 -0
  45. package/dist/core/loops/bootstrap-acquire.js +7 -3
  46. package/dist/core/loops/evidence.js +187 -0
  47. package/dist/core/loops/facade-schema.js +41 -10
  48. package/dist/core/loops/gate-policy.js +485 -0
  49. package/dist/core/loops/impl-bind.js +37 -79
  50. package/dist/core/loops/index.js +9 -0
  51. package/dist/core/loops/iteration-engine.js +31 -19
  52. package/dist/core/loops/kind-policies.js +90 -0
  53. package/dist/core/loops/lock.js +71 -13
  54. package/dist/core/loops/reconcile-turn.js +235 -18
  55. package/dist/core/loops/result-reducers.js +99 -10
  56. package/dist/core/loops/store.js +30 -3
  57. package/dist/core/loops/turn-execution.js +480 -0
  58. package/dist/core/loops/types.js +113 -2
  59. package/dist/core/loops/verbs.js +332 -99
  60. package/dist/core/loops/verify-command.js +31 -8
  61. package/dist/core/loops/workspace-digest.js +54 -0
  62. package/dist/core/review-loop-close.js +25 -3
  63. package/dist/core/review-loop-turn-dispatch.js +210 -161
  64. package/dist/core/runtime-signals.js +62 -25
  65. package/dist/core/schema.js +35 -0
  66. package/dist/core/spawn-check.js +3 -2
  67. package/dist/core/upgrades/backup.js +27 -4
  68. package/dist/facts.js +7 -6
  69. package/dist/facts.json +6 -5
  70. package/docs/cli.md +49 -1
  71. package/docs/concepts/attempt-authority.md +407 -0
  72. package/docs/concepts/evidence-attestations.md +135 -0
  73. package/docs/concepts/execution-contract.md +166 -0
  74. package/docs/concepts/harness-adapters.md +166 -0
  75. package/docs/concepts/ideation-loop.md +5 -4
  76. package/docs/concepts/loop-engine.md +302 -113
  77. package/docs/index.md +4 -1
  78. package/docs/integrations/codex.md +3 -3
  79. package/docs/integrations/mcp.md +59 -5
  80. package/docs/loops/debug.md +144 -0
  81. package/docs/loops/ideation.md +158 -0
  82. package/docs/loops/implementation.md +154 -0
  83. package/docs/loops/research.md +136 -0
  84. package/docs/loops/review.md +200 -0
  85. package/docs/mcp-schema-changelog.md +14 -5
  86. 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
@@ -115,12 +116,11 @@ const SLOT_BOUND_INTENTS = new Set(['complete_turn']);
115
116
  * took over" window — the verb will not proceed if the lock's mutation_id
116
117
  * changed between `acquireLock` and `work` dispatch. It does NOT cover mid-verb
117
118
  * 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.
119
+ * atomic-rename + JSONL append without consulting the fence. Safety therefore
120
+ * depends on lock.ts refusing deadline/lease-based takeover of a still-live
121
+ * local process (and failing closed for remote-host owners). If a future slice
122
+ * adds async work inside a mutation or enables time-based/remote takeover,
123
+ * `fenceCheck` must first be threaded to every committing write.
124
124
  */
125
125
  function withLockedLoopMutation(req, agentId, cwd, work) {
126
126
  return withLoopLock({
@@ -128,7 +128,7 @@ function withLockedLoopMutation(req, agentId, cwd, work) {
128
128
  intent: req.intent,
129
129
  agentId,
130
130
  scope: { kind: 'loop', loopId: req.loop_id },
131
- expectedVersion: req.expected_version,
131
+ expectedVersion: 'expected_version' in req ? req.expected_version : undefined,
132
132
  clientRequestId: req.client_request_id,
133
133
  requestPayload: requestPayload(req),
134
134
  currentVersion: () => currentLoopVersion(req.loop_id, cwd),
@@ -295,6 +295,45 @@ export async function handleBclawLoop(options) {
295
295
  return successResponse('list', { loops: sliced, total: loops.length }, sliced.map((l) => loopArtifactEntry(l.id)), [], [], Date.now() - startMs, `✔ list ${sliced.length}/${loops.length} loops`);
296
296
  }
297
297
  case 'turn': {
298
+ if (req.dispatch) {
299
+ if (!req.slot_id) {
300
+ return errorResponse('turn', 'validation_error', 'turn dispatch requires slot_id', Date.now() - startMs);
301
+ }
302
+ const dispatched = await dispatchLoopTurn({
303
+ loop_id: req.loop_id,
304
+ slot_id: req.slot_id,
305
+ task: req.input ?? `Execute ${req.loop_id} slot ${req.slot_id}`,
306
+ dispatcher_agent: actor,
307
+ dispatcher_agent_id: req.agentId,
308
+ session_id: options.sessionId,
309
+ model: req.model,
310
+ auto_execute: req.auto_execute,
311
+ candidate_agents: req.target_agents,
312
+ cwd: options.cwd ?? process.cwd(),
313
+ });
314
+ // Before AttemptAuthority exists an error is a true denial. Once the
315
+ // launch grant has crossed, however, transport may fall back to a
316
+ // manual command or become crossed_unknown. Preserve the created
317
+ // entities in a successful structured response instead of reporting
318
+ // an empty-side-effect error that invites a dangerous retry.
319
+ if (dispatched.error && !dispatched.turn_id) {
320
+ return errorResponse('turn', 'dispatch_denied', dispatched.error, Date.now() - startMs);
321
+ }
322
+ const loop = getLoop(req.loop_id, options.cwd);
323
+ return successResponse('turn', { loop, dispatch: dispatched, next_expected: loop ? computeNextExpected(loop) : undefined }, [
324
+ loopArtifactEntry(req.loop_id),
325
+ ...(dispatched.assignment_id ? [{ type: 'assignment', id: dispatched.assignment_id }] : []),
326
+ ...(dispatched.run_id ? [{ type: 'agent_run', id: dispatched.run_id }] : []),
327
+ ...(dispatched.claim_id ? [{ type: 'claim', id: dispatched.claim_id }] : []),
328
+ ], [
329
+ sideEffectUpdate('loop', req.loop_id),
330
+ ...(dispatched.claim_id ? [{ action: 'create', entity: 'claim', id: dispatched.claim_id }] : []),
331
+ ...(dispatched.assignment_id ? [{ action: 'create', entity: 'assignment', id: dispatched.assignment_id }] : []),
332
+ ...(dispatched.run_id ? [{ action: 'create', entity: 'agent_run', id: dispatched.run_id }] : []),
333
+ ], dispatched.error ? [dispatched.error] : [], Date.now() - startMs, dispatched.error
334
+ ? `⚠ ${dispatched.kind}.${dispatched.phase} turn ${dispatched.turn_id} crossed; ${dispatched.error}`
335
+ : `✔ dispatched ${dispatched.kind}.${dispatched.phase} turn ${dispatched.turn_id}`);
336
+ }
298
337
  return withLockedLoopMutation(req, agentId, options.cwd, () => {
299
338
  const beforeEvents = snapshotLoopEvents(req.loop_id, options.cwd);
300
339
  const loop = turn({
@@ -316,6 +355,13 @@ export async function handleBclawLoop(options) {
316
355
  const loop = complete_turn({
317
356
  id: req.loop_id,
318
357
  slot_id: req.slot_id,
358
+ assignment_id: req.assignment_id,
359
+ turn_id: req.turn_id,
360
+ run_id: req.run_id,
361
+ nonce: req.nonce,
362
+ attempt_epoch: req.attempt_epoch,
363
+ execution_contract_hash: req.execution_contract_hash,
364
+ workspace_digest: req.workspace_digest,
319
365
  outcome: req.outcome,
320
366
  failure_reason: req.failure_reason,
321
367
  artifact: req.artifact
@@ -337,6 +383,32 @@ export async function handleBclawLoop(options) {
337
383
  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
384
  });
339
385
  }
386
+ case 'takeover': {
387
+ const authorityHome = readLocalAuthorityHome(options.cwd ?? process.cwd());
388
+ if (!authorityHome) {
389
+ return errorResponse('takeover', 'authority_home_unavailable', 'local store/device authority identity is not initialized', Date.now() - startMs);
390
+ }
391
+ const result = takeoverLoopAttempt({
392
+ loop_id: req.loop_id,
393
+ slot_id: req.slot_id,
394
+ turn_id: req.turn_id,
395
+ expected_epoch: req.expected_epoch,
396
+ authority_home: authorityHome,
397
+ actor,
398
+ actor_id: agentId,
399
+ writer_id: agentId,
400
+ cause: req.cause,
401
+ liveness_evidence: req.liveness_evidence,
402
+ external_effect_policy: req.external_effect_policy,
403
+ next_workspace_path: req.next_workspace_path,
404
+ mode: req.takeover_mode,
405
+ cwd: options.cwd ?? process.cwd(),
406
+ });
407
+ return successResponse('takeover', {
408
+ ...result,
409
+ next_action: 'dispatch the same logical turn; the common path will project and contend on launch(next_epoch)',
410
+ }, [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)`);
411
+ }
340
412
  case 'advance': {
341
413
  return withLockedLoopMutation(req, agentId, options.cwd, () => {
342
414
  const beforeEvents = snapshotLoopEvents(req.loop_id, options.cwd);
@@ -360,7 +432,6 @@ export async function handleBclawLoop(options) {
360
432
  phase: req.artifact.phase,
361
433
  type: req.artifact.type,
362
434
  body: req.artifact.body,
363
- produced_by: req.artifact.produced_by,
364
435
  ref: req.artifact.ref,
365
436
  addresses_critique: req.artifact.addresses_critique,
366
437
  },
@@ -467,10 +538,9 @@ export async function handleBclawLoop(options) {
467
538
  }, [loopArtifactEntry(result.thread.id), ...loopEventArtifacts(newEvents)], [sideEffectUpdate('loop', result.thread.id), ...loopEventSideEffects(newEvents)], [], Date.now() - startMs, summary);
468
539
  }
469
540
  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.
541
+ // Implementation bind is engine-only: validate the linked sequence and
542
+ // advance bind -> execute. Worker launch belongs exclusively to
543
+ // turn(dispatch=true), the common AttemptAuthority path.
474
544
  const existing = getLoop(req.loop_id, options.cwd);
475
545
  if (!existing) {
476
546
  return errorResponse('bind', 'not_found', `unknown loop_id ${req.loop_id}`, Date.now() - startMs);
@@ -504,7 +574,7 @@ export async function handleBclawLoop(options) {
504
574
  dispatched: bind.messages_sent,
505
575
  dispatch: bind.dispatch,
506
576
  next_expected: computeNextExpected(loop),
507
- }, [loopArtifactEntry(loop.id), ...loopEventArtifacts(newEvents)], sideEffects, bind.dispatch?.warnings ?? [], Date.now() - startMs, bind.reason);
577
+ }, [loopArtifactEntry(loop.id), ...loopEventArtifacts(newEvents)], sideEffects, [...bind.warnings, ...(bind.dispatch?.warnings ?? [])], Date.now() - startMs, bind.reason);
508
578
  }
509
579
  }
510
580
  }
@@ -538,6 +608,9 @@ export async function handleBclawLoop(options) {
538
608
  });
539
609
  }
540
610
  const message = err instanceof Error ? err.message : String(err);
611
+ if (message.startsWith('attempt_fence_')) {
612
+ return errorResponse(req.intent, 'attempt_fence_rejected', message, Date.now() - startMs);
613
+ }
541
614
  if (message.includes('unauthorized_slot_write')) {
542
615
  return errorResponse(req.intent, 'unauthorized_slot_write', message, Date.now() - startMs);
543
616
  }
@@ -872,7 +872,7 @@ const MCP_WRITE_TOOLS = [
872
872
  },
873
873
  {
874
874
  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).',
875
+ 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
876
  // schemaSource is informational for now — grep target so future migrators
877
877
  // can locate zod-derived tools quickly. The parity test in
878
878
  // tests/unit/mcp-zod-parity.test.ts hard-codes its (tool, zod-schema)
@@ -890,12 +890,8 @@ const MCP_WRITE_TOOLS = [
890
890
  properties: {
891
891
  intent: {
892
892
  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.',
893
+ enum: ['open', 'get', 'list', 'turn', 'complete_turn', 'takeover', 'advance', 'add_artifact', 'pause', 'resume', 'close', 'verify', 'bind', 'request_input', 'provide_input'],
894
+ 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
895
  },
900
896
  loop_id: { type: 'string', description: 'Target loop id (lop_…). Required for every intent except open and list.' },
901
897
  kind: { type: 'string', enum: ['review', 'ideation', 'implementation', 'research', 'debug'], description: 'Loop kind for open / list filter.' },
@@ -906,28 +902,56 @@ const MCP_WRITE_TOOLS = [
906
902
  linked: { type: 'object', description: 'Optional top-level plan/sequence refs (open).' },
907
903
  stop_condition: { type: 'object', description: 'Optional stop_condition override (open). Composite any/all supported.' },
908
904
  mode: { type: 'string', enum: ['asymmetric', 'symmetric'], description: 'Review mode selector for open (review kind only).' },
905
+ allow_orphan: { type: 'boolean', description: 'Required true for direct open: acknowledges that the caller owns subsequent turn/bind/dispatch.' },
906
+ verify: { type: 'object', description: 'Optional open-time verification policy, e.g. { command: ["npm", "test"] }.' },
909
907
  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
908
  include_events: { type: 'boolean', description: 'get: include the event journal in the response.' },
911
909
  limit: { type: 'number', description: 'list: max loops returned.' },
912
910
  offset: { type: 'number', description: 'list: pagination offset.' },
913
- slot_id: { type: 'string', description: 'Slot id for turn / complete_turn.' },
911
+ slot_id: { type: 'string', description: 'Slot id for turn / complete_turn / takeover.' },
912
+ turn_id: { type: 'string', description: 'takeover: stable logical turn id; complete_turn: required as part of the full AttemptAuthority v2 generation fence.' },
913
+ expected_epoch: { type: 'number', description: 'takeover: active physical generation epoch expected by the caller.' },
914
+ cause: { type: 'string', description: 'takeover: audited reason for fencing the current producer.' },
915
+ liveness_evidence: { type: 'string', description: 'takeover: concrete evidence that the current producer cannot safely continue.' },
916
+ external_effect_policy: { type: 'string', enum: ['none', 'idempotent', 'externally_fenced'], description: 'takeover: declaration required before automatic re-execution.' },
917
+ next_workspace_path: { type: 'string', description: 'takeover: existing isolated workspace for the successor generation.' },
918
+ takeover_mode: { type: 'string', enum: ['takeover', 'retry'], description: 'takeover: causal close decision kind (default takeover).' },
914
919
  role: { type: 'string', description: 'Slot role for turn (resolves the first non-done slot with that role).' },
915
920
  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).' },
921
+ assignment_id: { type: 'string', description: 'turn: assignment id produced by an external dispatcher; complete_turn: current logical assignment id in the full v2 fence.' },
922
+ run_id: { type: 'string', description: 'complete_turn: current physical AgentRun id in the full AttemptAuthority v2 fence.' },
923
+ nonce: { type: 'string', description: 'complete_turn: current immutable launch nonce in the full AttemptAuthority v2 fence.' },
924
+ attempt_epoch: { type: 'number', description: 'complete_turn: current physical generation epoch in the full AttemptAuthority v2 fence.' },
925
+ execution_contract_hash: { type: 'string', description: 'complete_turn: SHA-256 hash of the current generation ExecutionContract.' },
926
+ workspace_digest: { type: 'string', description: 'complete_turn: digest binding the current generation to its isolated workspace.' },
927
+ 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
928
  outcome: { type: 'string', enum: ['done', 'failed', 'cancelled'], description: 'complete_turn outcome (default done).' },
919
929
  failure_reason: { type: 'string', description: 'complete_turn: optional failure/cancel reason.' },
920
930
  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.' },
931
+ dry_run: { type: 'boolean', description: 'bind: validate the linked sequence without advancing; bind never spawns.' },
932
+ lanes: { type: 'array', items: { type: 'string' }, description: 'Deprecated bind launch option retained for compatibility and ignored; dispatch independent slots with turn(dispatch=true).' },
933
+ auto_execute: { type: 'boolean', description: 'turn dispatch: false returns a contract-wrapped manual launch command. Deprecated and ignored for engine-only bind.' },
934
+ model: { type: 'string', description: 'turn dispatch: model override. Deprecated and ignored for engine-only bind.' },
935
+ target_agents: { type: 'array', items: { type: 'string' }, description: 'turn dispatch: deterministic capability candidate pool used when the slot has no frozen agent.' },
936
+ max_assignments: { type: 'number', description: 'Deprecated bind launch option retained for compatibility and ignored.' },
926
937
  to_phase: { type: 'string', description: 'advance: explicit target phase (otherwise the next phase).' },
927
938
  force: { type: 'boolean', description: 'advance: allow going backwards (increments iteration_count).' },
928
939
  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.' },
940
+ expected_version: { type: 'number', description: 'Optimistic-CAS version enforced under the loop lock for mutating intents; stale values fail with version_conflict before mutation.' },
941
+ phase: { type: 'string', description: 'request_input: current loop phase.' },
942
+ question_text: { type: 'string', description: 'request_input: operator question (max 500 characters).' },
943
+ evidence: { type: 'array', items: { type: 'string' }, description: 'request_input: concrete evidence motivating the question.' },
944
+ suggested_default: { type: 'string', description: 'request_input: optional default answer.' },
945
+ options: { type: 'array', items: { type: 'object' }, description: 'request_input: optional 2-4 structured choices.' },
946
+ pause_scope: { type: 'string', enum: ['slot', 'loop'], description: 'request_input: pause only the slot or the whole loop.' },
947
+ on_timeout: { type: 'string', enum: ['use_default', 'cancel_loop', 'continue_incomplete'], description: 'request_input: timeout policy.' },
948
+ timeout_at: { type: 'string', description: 'request_input: optional ISO timestamp.' },
949
+ replies_to: { type: 'string', description: 'provide_input: question id (qst_…).' },
950
+ resolved_via: { type: 'string', enum: ['answer', 'choose', 'skip', 'timeout_default'], description: 'provide_input: how the answer was resolved.' },
951
+ answer_text: { type: 'string', description: 'provide_input: free-form answer.' },
952
+ chosen_option_id: { type: 'string', description: 'provide_input: selected option id.' },
953
+ by: { type: 'string', enum: ['operator', 'system'], description: 'provide_input: answer actor (default operator).' },
954
+ 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
955
  project: { type: 'string', description: 'Optional linked project name/path. Routes loop reads and mutations to that project. Defaults to the current cwd.' },
932
956
  agent: { type: 'string', description: 'Caller agent name.' },
933
957
  agentId: { type: 'string', description: 'Caller registered agent id (enforced for slot-bound auth in complete_turn).' },
@@ -937,7 +961,7 @@ const MCP_WRITE_TOOLS = [
937
961
  },
938
962
  {
939
963
  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).',
964
+ 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
965
  annotations: { tier: 'standard', category: 'coordination', headlessApproval: 'auto' },
942
966
  inputSchema: { ...generatedSchemas.AssignmentUpdateRequest },
943
967
  },
@@ -650,6 +650,28 @@ export const generatedSchemas = {
650
650
  "coordinator_override": {
651
651
  "type": "boolean",
652
652
  "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."
653
+ },
654
+ "turn_id": {
655
+ "type": "string"
656
+ },
657
+ "run_id": {
658
+ "type": "string"
659
+ },
660
+ "nonce": {
661
+ "type": "string"
662
+ },
663
+ "attempt_epoch": {
664
+ "type": "integer",
665
+ "minimum": 0,
666
+ "maximum": 9007199254740991
667
+ },
668
+ "execution_contract_hash": {
669
+ "type": "string",
670
+ "pattern": "^[a-f0-9]{64}$"
671
+ },
672
+ "workspace_digest": {
673
+ "type": "string",
674
+ "pattern": "^[a-f0-9]{64}$"
653
675
  }
654
676
  },
655
677
  "required": [
@@ -1014,6 +1036,28 @@ export const generatedSchemas = {
1014
1036
  ],
1015
1037
  "description": "Optional ActionRequired payload when status=blocked. Lets the worker request approval, user input, or clarification before resuming."
1016
1038
  },
1039
+ "turn_id": {
1040
+ "type": "string"
1041
+ },
1042
+ "run_id": {
1043
+ "type": "string"
1044
+ },
1045
+ "nonce": {
1046
+ "type": "string"
1047
+ },
1048
+ "attempt_epoch": {
1049
+ "type": "integer",
1050
+ "minimum": 0,
1051
+ "maximum": 9007199254740991
1052
+ },
1053
+ "execution_contract_hash": {
1054
+ "type": "string",
1055
+ "pattern": "^[a-f0-9]{64}$"
1056
+ },
1057
+ "workspace_digest": {
1058
+ "type": "string",
1059
+ "pattern": "^[a-f0-9]{64}$"
1060
+ },
1017
1061
  "agent": {
1018
1062
  "type": "string",
1019
1063
  "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,