brainclaw 1.27.0 → 1.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/cli/register-coordination.js +12 -0
  3. package/dist/commands/loop.js +12 -0
  4. package/dist/commands/loops-handlers.js +261 -2
  5. package/dist/commands/mcp-catalog.js +16 -3
  6. package/dist/commands/mcp-schemas.generated.js +20 -0
  7. package/dist/commands/mcp-write-claims.js +55 -8
  8. package/dist/commands/mcp-write-coordination.js +3 -0
  9. package/dist/core/actions.js +17 -3
  10. package/dist/core/execution-adapters.js +29 -0
  11. package/dist/core/facade-schema.js +3 -0
  12. package/dist/core/loop-turn-dispatch.js +31 -3
  13. package/dist/core/loops/attempt-authority.js +20 -0
  14. package/dist/core/loops/brief-assembly.js +21 -4
  15. package/dist/core/loops/continuation.js +337 -0
  16. package/dist/core/loops/evidence.js +1 -0
  17. package/dist/core/loops/facade-schema.js +49 -1
  18. package/dist/core/loops/gate-policy.js +52 -4
  19. package/dist/core/loops/impl-bind.js +58 -6
  20. package/dist/core/loops/index.js +1 -0
  21. package/dist/core/loops/reconcile-turn.js +2 -0
  22. package/dist/core/loops/result-reducers.js +15 -1
  23. package/dist/core/loops/store.js +4 -0
  24. package/dist/core/loops/types.js +20 -1
  25. package/dist/core/loops/verbs.js +3 -0
  26. package/dist/core/loops/verify-command.js +77 -15
  27. package/dist/core/reviewer-policy.js +39 -0
  28. package/dist/core/schema.js +21 -1
  29. package/dist/facts.js +7 -7
  30. package/dist/facts.json +6 -6
  31. package/docs/cli.md +4 -2
  32. package/docs/concepts/loop-engine.md +25 -0
  33. package/docs/loops/implementation.md +20 -0
  34. package/docs/mcp-schema-changelog.md +10 -1
  35. package/package.json +1 -1
Binary file
@@ -466,6 +466,18 @@ export function registerCoordinationCommands(program) {
466
466
  const { runLoopCommand } = await import('../commands/loop.js');
467
467
  await runLoopCommand('add-artifact', { loop_id }, options, globalOpts.cwd);
468
468
  });
469
+ loopCmd
470
+ .command('continue <loop_id>')
471
+ .description('Evaluate and apply a persisted cross-loop continuation')
472
+ .option('--action-index <n>', 'Zero-based next_action index', '0')
473
+ .option('--autonomy-mode <mode>', 'autonomous, require_approval, or deny', 'autonomous')
474
+ .option('--risk <risk>', 'normal or protected', 'normal')
475
+ .option('--json', 'Machine-readable output')
476
+ .action(async (loop_id, options) => {
477
+ const globalOpts = program.opts();
478
+ const { runLoopCommand } = await import('../commands/loop.js');
479
+ await runLoopCommand('continue', { loop_id }, options, globalOpts.cwd);
480
+ });
469
481
  // --- attempt-authority (two-release writer guard; P4) ---
470
482
  const attemptAuthorityCmd = program
471
483
  .command('attempt-authority')
@@ -154,6 +154,18 @@ function buildRequest(subcommand, loopId, opts) {
154
154
  ref: parseOptionalRef(opts.ref, opts),
155
155
  },
156
156
  };
157
+ case 'continue': {
158
+ const actionIndex = opts.actionIndex === undefined ? 0 : Number(opts.actionIndex);
159
+ if (!Number.isInteger(actionIndex) || actionIndex < 0)
160
+ fail('--action-index must be a non-negative integer', 1, opts);
161
+ return {
162
+ intent: 'continue',
163
+ loop_id: loopId,
164
+ action_index: actionIndex,
165
+ autonomy_mode: opts.autonomyMode ?? 'autonomous',
166
+ risk: opts.risk ?? 'normal',
167
+ };
168
+ }
157
169
  }
158
170
  }
159
171
  export async function runLoopCommand(subcommand, args, options = {}, cwd) {
@@ -5,7 +5,11 @@ import { dispatchLoopTurn } from '../core/loop-turn-dispatch.js';
5
5
  import { findReservationByRunId } from '../core/loops/attempt-reservation.js';
6
6
  import { runVerify } from '../core/loops/verify-command.js';
7
7
  import { runImplBind } from '../core/loops/impl-bind.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
+ import { loadSequence } from '../core/sequence.js';
9
+ import { createActionRequired, loadActionRequired } from '../core/actions.js';
10
+ import { selectImplementationReviewer } from '../core/reviewer-policy.js';
11
+ import { handleBclawCoordinate } from './mcp-write-coordination.js';
12
+ 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, artifactEvidenceDigest, attachContinuationActionRequired, ensureContinuation, } from '../core/loops/index.js';
9
13
  import { BclawLoopRequestSchema, BCLAW_LOOP_INTENTS, } from '../core/loops/facade-schema.js';
10
14
  // NextExpectedHint type now lives in src/core/loops/next-expected.ts
11
15
  // (hoisted per can_e57c7782 follow-up so MCP facade + CLI share the
@@ -16,6 +20,10 @@ function resolveActor(req, defaultActor) {
16
20
  return { actor, agentId };
17
21
  }
18
22
  function successResponse(intent, result, artifacts, side_effects, warnings, durationMs, summary) {
23
+ const resultLoop = result && typeof result === 'object' && 'loop' in result
24
+ ? result.loop
25
+ : undefined;
26
+ const nextActions = resultLoop ? pipelineNextActions(resultLoop) : [];
19
27
  return {
20
28
  response: {
21
29
  status: 'ok',
@@ -25,10 +33,114 @@ function successResponse(intent, result, artifacts, side_effects, warnings, dura
25
33
  side_effects,
26
34
  warnings,
27
35
  duration_ms: durationMs,
36
+ ...(nextActions.length > 0 ? { next_actions: nextActions } : {}),
28
37
  },
29
38
  summary,
30
39
  };
31
40
  }
41
+ /** Cross-loop affordances: explicit next calls, never hidden orchestration. */
42
+ function pipelineNextActions(loop) {
43
+ if (loop.kind === 'ideation') {
44
+ const draft = [...loop.artifacts].reverse().find((artifact) => artifact.type === 'plan_draft');
45
+ if (!draft || (loop.current_phase !== 'synthesis' && loop.status !== 'completed'))
46
+ return [];
47
+ const planIds = loop.linked?.plan_ids ?? [];
48
+ const sequenceIds = loop.linked?.sequence_ids ?? [];
49
+ if (planIds.length > 0 && sequenceIds.length > 0) {
50
+ return [{
51
+ tool: 'bclaw_loop',
52
+ args: {
53
+ intent: 'continue', loop_id: loop.id, action_index: 0,
54
+ autonomy_mode: 'autonomous', risk: 'normal',
55
+ },
56
+ when: 'evaluate and apply the accepted synthesis through persisted continuation policy',
57
+ }];
58
+ }
59
+ return [{
60
+ tool: 'bclaw_create',
61
+ args: { entity: 'plan', text: draft.body ?? '<materialize the plan_draft artifact>', status: 'todo' },
62
+ when: 'materialize the synthesis before opening its implementation loop',
63
+ }];
64
+ }
65
+ if (loop.kind === 'implementation' && loop.current_phase === 'execute' && loop.status === 'open') {
66
+ return loop.slots
67
+ .filter((slot) => slot.status === 'open')
68
+ .map((slot) => ({
69
+ tool: 'bclaw_loop',
70
+ args: {
71
+ intent: 'turn', loop_id: loop.id, slot_id: slot.slot_id,
72
+ input: loop.goal ?? loop.title, dispatch: true,
73
+ },
74
+ when: `dispatch implementation lane ${slot.lane ?? slot.role} through AttemptAuthority`,
75
+ }));
76
+ }
77
+ if (loop.kind === 'implementation' && (loop.current_phase === 'handoff_ready' || loop.status === 'completed')) {
78
+ return [{
79
+ tool: 'bclaw_loop',
80
+ args: {
81
+ intent: 'continue', loop_id: loop.id, action_index: 0,
82
+ autonomy_mode: 'autonomous', risk: 'normal',
83
+ },
84
+ when: 'evaluate and apply the attested handoff through persisted continuation policy',
85
+ }];
86
+ }
87
+ return [];
88
+ }
89
+ /** Concrete action evaluated by continuation policy; never exposed as an ungoverned hint. */
90
+ function proposedPipelineActions(loop, cwd) {
91
+ if (loop.kind === 'ideation') {
92
+ const draft = [...loop.artifacts].reverse().find((artifact) => artifact.type === 'plan_draft');
93
+ const planIds = loop.linked?.plan_ids ?? [];
94
+ const sequenceIds = loop.linked?.sequence_ids ?? [];
95
+ if (!draft || planIds.length === 0 || sequenceIds.length !== 1)
96
+ return [];
97
+ const sequence = loadSequence(sequenceIds[0], cwd);
98
+ const lanes = [...new Set(sequence.items.map((item) => item.lane?.trim() || 'default'))].sort();
99
+ const sourceDigest = artifactEvidenceDigest(draft);
100
+ return [{
101
+ tool: 'bclaw_loop',
102
+ args: {
103
+ intent: 'open', kind: 'implementation', title: `Implement ${loop.title}`,
104
+ goal: loop.goal ?? loop.title,
105
+ linked: {
106
+ plan_ids: planIds, sequence_ids: sequenceIds, source_loop_id: loop.id,
107
+ source_artifact_id: draft.artifact_id, source_artifact_digest: sourceDigest,
108
+ },
109
+ verify: draft.implementation_verify,
110
+ slots: lanes.map((lane) => ({ role: 'implementer', lane })),
111
+ allow_orphan: true,
112
+ },
113
+ when: 'start implementation from the accepted synthesis',
114
+ }];
115
+ }
116
+ if (loop.kind === 'implementation' && (loop.current_phase === 'handoff_ready' || loop.status === 'completed')) {
117
+ const handoff = [...loop.artifacts].reverse().find((artifact) => artifact.type === 'handoff');
118
+ if (!handoff?.ref)
119
+ return [];
120
+ const reviewer = selectImplementationReviewer(loop, cwd);
121
+ const reviewScope = [...new Set(loop.slots.map((slot) => slot.scope_hint?.trim()).filter((scope) => Boolean(scope)))].join(',');
122
+ const sourceDigest = artifactEvidenceDigest(handoff);
123
+ return [{
124
+ tool: 'bclaw_coordinate',
125
+ args: {
126
+ intent: 'review', open_loop: true, review_mode: 'asymmetric',
127
+ task: `Review implementation loop ${loop.id}; handoff ${handoff.ref.kind}:${handoff.ref.id}`,
128
+ targetAgents: [reviewer.agent],
129
+ ...(reviewScope ? { scope: reviewScope } : {}),
130
+ ...((handoff.ref.kind === 'commit' || handoff.ref.kind === 'branch') ? { ref: handoff.ref.id } : {}),
131
+ linked: {
132
+ source_loop_id: loop.id,
133
+ source_artifact_id: handoff.artifact_id,
134
+ source_artifact_digest: sourceDigest,
135
+ plan_ids: loop.linked?.plan_ids,
136
+ sequence_ids: loop.linked?.sequence_ids,
137
+ },
138
+ },
139
+ when: `reviewer ${reviewer.agent} selected by ${reviewer.policy_version}`,
140
+ }];
141
+ }
142
+ return [];
143
+ }
32
144
  function errorResponse(intent, code, message, durationMs, result = null) {
33
145
  return {
34
146
  response: {
@@ -182,6 +294,59 @@ function trySweepLoopTimeouts(loop_id, cwd) {
182
294
  }
183
295
  catch { /* best-effort: never block facade on sweep errors */ }
184
296
  }
297
+ /** Execute a persisted continuation through the same public handler used by MCP/CLI callers. */
298
+ export async function executeContinuationPublicAction(record, options) {
299
+ const args = record.action.args ?? {};
300
+ const linked = (args.linked && typeof args.linked === 'object' ? args.linked : {});
301
+ const publicArgs = {
302
+ ...args,
303
+ linked: { ...linked, continuation_key: record.continuation_key },
304
+ client_request_id: `ctn_${record.continuation_key}`,
305
+ agent: options.actor,
306
+ agentId: options.agentId,
307
+ };
308
+ let downstreamId;
309
+ if (record.action.tool === 'bclaw_loop') {
310
+ const opened = await handleBclawLoop({
311
+ args: publicArgs, cwd: options.cwd, defaultActor: options.actor, sessionId: options.sessionId,
312
+ });
313
+ if (opened.response.status !== 'ok')
314
+ throw new Error(opened.response.error ?? opened.summary);
315
+ downstreamId = opened.response.result.loop?.id;
316
+ }
317
+ else if (record.action.tool === 'bclaw_coordinate') {
318
+ const coordinateCwd = options.cwd ?? process.cwd();
319
+ const coordinated = await handleBclawCoordinate(publicArgs, {
320
+ cwd: coordinateCwd,
321
+ connectionSessionId: options.sessionId,
322
+ // The persisted source loop is an explicit store selector. Preserve that
323
+ // provenance so a multi-project workspace cannot reinterpret this as a
324
+ // bare-cwd review and reject or misroute the downstream loop.
325
+ effectiveScope: {
326
+ cwd: coordinateCwd,
327
+ active_source: 'explicit',
328
+ resolved_project: { path: coordinateCwd },
329
+ },
330
+ });
331
+ if (coordinated.response.isError) {
332
+ const details = coordinated.response.structuredContent;
333
+ throw new Error(details?.error ?? details?.message ?? 'continuation_coordinate_failed');
334
+ }
335
+ const facade = coordinated.response.structuredContent;
336
+ if (facade?.status === 'error')
337
+ throw new Error(facade.error ?? 'continuation_coordinate_failed');
338
+ downstreamId = facade?.result?.loop_id;
339
+ }
340
+ else {
341
+ throw new Error(`continuation_action_unsupported: ${record.action.tool}`);
342
+ }
343
+ if (!downstreamId)
344
+ throw new Error('continuation_open_missing_loop');
345
+ if (process.env.BRAINCLAW_TEST_FAULT_CONTINUATION_AFTER_OPEN === '1') {
346
+ throw new Error('fault_injection: continuation_after_open');
347
+ }
348
+ return { kind: 'loop', id: downstreamId };
349
+ }
185
350
  export async function handleBclawLoop(options) {
186
351
  const startMs = Date.now();
187
352
  const defaultActor = options.defaultActor ?? 'bclaw_loop';
@@ -371,6 +536,7 @@ export async function handleBclawLoop(options) {
371
536
  body: req.artifact.body,
372
537
  ref: req.artifact.ref,
373
538
  addresses_critique: req.artifact.addresses_critique,
539
+ implementation_verify: req.artifact.implementation_verify,
374
540
  }
375
541
  : undefined,
376
542
  actor,
@@ -434,6 +600,7 @@ export async function handleBclawLoop(options) {
434
600
  body: req.artifact.body,
435
601
  ref: req.artifact.ref,
436
602
  addresses_critique: req.artifact.addresses_critique,
603
+ implementation_verify: req.artifact.implementation_verify,
437
604
  },
438
605
  actor,
439
606
  }, options.cwd);
@@ -522,7 +689,7 @@ export async function handleBclawLoop(options) {
522
689
  return errorResponse('verify', 'not_found', `unknown loop_id ${req.loop_id}`, Date.now() - startMs);
523
690
  }
524
691
  const beforeEvents = snapshotLoopEvents(req.loop_id, options.cwd);
525
- const result = runVerify({ loop_id: req.loop_id, actor }, options.cwd);
692
+ const result = runVerify({ loop_id: req.loop_id, slot_id: req.slot_id, actor }, options.cwd);
526
693
  const newEvents = findNewLoopEvents(result.thread.id, beforeEvents, options.cwd);
527
694
  const summary = result.unconfigured
528
695
  ? `verify: loop has no protocol.verify — falling back to an agent-narrated verify_report`
@@ -537,6 +704,98 @@ export async function handleBclawLoop(options) {
537
704
  next_expected: computeNextExpected(result.thread),
538
705
  }, [loopArtifactEntry(result.thread.id), ...loopEventArtifacts(newEvents)], [sideEffectUpdate('loop', result.thread.id), ...loopEventSideEffects(newEvents)], [], Date.now() - startMs, summary);
539
706
  }
707
+ case 'continue': {
708
+ const source = getLoop(req.loop_id, options.cwd);
709
+ if (!source) {
710
+ return errorResponse('continue', 'not_found', `unknown loop_id ${req.loop_id}`, Date.now() - startMs);
711
+ }
712
+ const actions = proposedPipelineActions(source, options.cwd);
713
+ const action = actions[req.action_index];
714
+ if (!action) {
715
+ return errorResponse('continue', 'continuation_unavailable', `no executable continuation action ${req.action_index} for ${source.id}`, Date.now() - startMs);
716
+ }
717
+ const sourceArtifactId = action.args?.linked?.source_artifact_id;
718
+ const sourceArtifact = source.artifacts.find((artifact) => artifact.artifact_id === sourceArtifactId);
719
+ if (!sourceArtifact) {
720
+ return errorResponse('continue', 'continuation_source_missing', 'source continuation artifact disappeared', Date.now() - startMs);
721
+ }
722
+ const ensured = await ensureContinuation({
723
+ source_loop: source,
724
+ source_artifact: sourceArtifact,
725
+ action,
726
+ action_index: req.action_index,
727
+ autonomy_mode: req.autonomy_mode,
728
+ risk: req.risk,
729
+ actor,
730
+ actor_id: agentId,
731
+ execute: (record) => executeContinuationPublicAction(record, {
732
+ cwd: options.cwd, actor, agentId, sessionId: options.sessionId,
733
+ }),
734
+ }, options.cwd);
735
+ let continuation = ensured.record;
736
+ if (continuation.state === 'approval_required') {
737
+ let approval = continuation.action_required_id
738
+ ? loadActionRequired(continuation.action_required_id, options.cwd)
739
+ : undefined;
740
+ if (!approval) {
741
+ approval = createActionRequired({
742
+ target: { kind: 'continuation', continuation_id: continuation.id },
743
+ plan_id: source.linked?.plan_ids?.[0],
744
+ sequence_id: source.linked?.sequence_ids?.[0],
745
+ agent: actor,
746
+ agent_id: agentId,
747
+ session_id: options.sessionId,
748
+ kind: 'plan_approval',
749
+ scope: source.goal,
750
+ title: `Approve continuation from ${source.id}`,
751
+ prompt: continuation.reason.join('; '),
752
+ tags: ['loop-engine', 'continuation', 'approval-required'],
753
+ }, options.cwd);
754
+ continuation = attachContinuationActionRequired(continuation.id, approval.id, actor, agentId, options.cwd);
755
+ }
756
+ const handled = successResponse('continue', { continuation, action_required: approval }, [{ type: 'continuation', id: continuation.id }, { type: 'action', id: approval.id }], [{ action: 'create', entity: 'action', id: approval.id }], [], Date.now() - startMs, `continuation ${continuation.id} requires approval ${approval.id}`);
757
+ handled.response.next_actions = [{
758
+ tool: 'bclaw_assignment_action',
759
+ args: { action_id: approval.id, outcome: 'resolved' },
760
+ when: 'a different trusted supervisor approves this continuation',
761
+ }];
762
+ return handled;
763
+ }
764
+ if (continuation.state === 'denied') {
765
+ return successResponse('continue', { continuation }, [{ type: 'continuation', id: continuation.id }], [], [], Date.now() - startMs, `continuation ${continuation.id} denied: ${continuation.reason.join('; ')}`);
766
+ }
767
+ if (ensured.executing_elsewhere) {
768
+ const handled = successResponse('continue', { continuation, executing_elsewhere: true }, [{ type: 'continuation', id: continuation.id }], [], [], Date.now() - startMs, `continuation ${continuation.id} is applying in another live process`);
769
+ handled.response.next_actions = [{
770
+ tool: 'bclaw_loop',
771
+ args: { intent: 'continue', loop_id: source.id, action_index: req.action_index },
772
+ when: 'retry after the current continuation owner settles',
773
+ }];
774
+ return handled;
775
+ }
776
+ const downstreamId = continuation.downstream?.id;
777
+ if (!downstreamId)
778
+ throw new Error('continuation_applied_without_downstream');
779
+ let loop = getLoop(downstreamId, options.cwd);
780
+ if (!loop)
781
+ throw new Error('continuation_downstream_disappeared');
782
+ let bind;
783
+ if (loop.kind === 'implementation') {
784
+ const bound = await handleBclawLoop({
785
+ args: { intent: 'bind', loop_id: downstreamId, agent: actor, agentId },
786
+ cwd: options.cwd,
787
+ defaultActor: actor,
788
+ sessionId: options.sessionId,
789
+ });
790
+ if (bound.response.status !== 'ok')
791
+ throw new Error(bound.response.error ?? bound.summary);
792
+ bind = bound.response.result;
793
+ loop = getLoop(downstreamId, options.cwd);
794
+ if (!loop)
795
+ throw new Error('continuation_downstream_disappeared');
796
+ }
797
+ return successResponse('continue', { loop, continuation, ...(bind ? { bind } : {}), reused: ensured.reused, next_expected: computeNextExpected(loop) }, [{ type: 'continuation', id: continuation.id }, loopArtifactEntry(loop.id)], [{ action: 'update', entity: 'continuation', id: continuation.id }, sideEffectUpdate('loop', loop.id)], [], Date.now() - startMs, `continuation ${continuation.id} applied to ${loop.id} phase=${loop.current_phase}`);
798
+ }
540
799
  case 'bind': {
541
800
  // Implementation bind is engine-only: validate the linked sequence and
542
801
  // advance bind -> execute. Worker launch belongs exclusively to
@@ -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/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.',
885
+ description: 'Loop engine facade: open/turn/complete_turn/takeover/advance/add_artifact/pause/resume/close/verify/bind/continue/request_input/provide_input/get/list multi-turn work loops (review, ideation, implementation, research, debug). `continue` evaluates and persists a cross-loop continuation, then invokes the same public open or coordinate-review path; implementation downstreams additionally bind. Direct open requires allow_orphan=true because the caller owns subsequent dispatch.',
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,8 +900,8 @@ const MCP_WRITE_TOOLS = [
890
900
  properties: {
891
901
  intent: {
892
902
  type: 'string',
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.',
903
+ enum: ['open', 'get', 'list', 'turn', 'complete_turn', 'takeover', 'advance', 'add_artifact', 'pause', 'resume', 'close', 'verify', 'bind', 'continue', 'request_input', 'provide_input'],
904
+ description: 'Loop lifecycle intent. `continue` evaluates one next_action through persisted continuation policy and applies it through public open/bind semantics. Review/ideation normally start via bclaw_coordinate; implementation/research/debug may use open with allow_orphan=true and then explicitly bind/turn/dispatch.',
895
905
  },
896
906
  loop_id: { type: 'string', description: 'Target loop id (lop_…). Required for every intent except open and list.' },
897
907
  kind: { type: 'string', enum: ['review', 'ideation', 'implementation', 'research', 'debug'], description: 'Loop kind for open / list filter.' },
@@ -934,6 +944,9 @@ const MCP_WRITE_TOOLS = [
934
944
  model: { type: 'string', description: 'turn dispatch: model override. Deprecated and ignored for engine-only bind.' },
935
945
  target_agents: { type: 'array', items: { type: 'string' }, description: 'turn dispatch: deterministic capability candidate pool used when the slot has no frozen agent.' },
936
946
  max_assignments: { type: 'number', description: 'Deprecated bind launch option retained for compatibility and ignored.' },
947
+ action_index: { type: 'number', description: 'continue: zero-based proposed next_action index (default 0).' },
948
+ autonomy_mode: { type: 'string', enum: ['autonomous', 'require_approval', 'deny'], description: 'continue: policy mode. require_approval creates ActionRequired; deny persists a terminal denial.' },
949
+ risk: { type: 'string', enum: ['normal', 'protected'], description: 'continue: protected risk always requires approval.' },
937
950
  to_phase: { type: 'string', description: 'advance: explicit target phase (otherwise the next phase).' },
938
951
  force: { type: 'boolean', description: 'advance: allow going backwards (increments iteration_count).' },
939
952
  reason: { type: 'string', description: 'advance / pause / close: optional reason string.' },
@@ -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": [
@@ -951,24 +951,71 @@ export async function handleBclawAssignmentAction(payload, ctx) {
951
951
  if (pendingAction && pendingAction.agent === resolved.identity.agent_name) {
952
952
  return { response: createToolErrorResponse('trust_error', `Agent '${resolved.identity.agent_name}' cannot resolve its own action. A supervisor or different agent must respond.`) };
953
953
  }
954
- const action = resolveActionRequired(actionId, {
955
- outcome: outcome,
956
- text: typeof args.text === 'string' ? args.text : undefined,
957
- payload: args.payload && typeof args.payload === 'object' ? args.payload : undefined,
958
- responded_by: resolved.identity.agent_name,
959
- responded_by_id: resolved.identity.agent_id,
960
- session_id: connectionSessionId ?? 'unknown',
961
- }, cwd);
954
+ const typedOutcome = outcome;
955
+ // Continuation approvals are safe to replay. This matters when the first
956
+ // response is lost after the downstream loop was created: the supervisor
957
+ // can submit the same decision again and observe the same continuation.
958
+ const continuationReplay = pendingAction?.target?.kind === 'continuation'
959
+ && pendingAction.status === typedOutcome;
960
+ const action = continuationReplay
961
+ ? pendingAction
962
+ : resolveActionRequired(actionId, {
963
+ outcome: typedOutcome,
964
+ text: typeof args.text === 'string' ? args.text : undefined,
965
+ payload: args.payload && typeof args.payload === 'object' ? args.payload : undefined,
966
+ responded_by: resolved.identity.agent_name,
967
+ responded_by_id: resolved.identity.agent_id,
968
+ session_id: connectionSessionId ?? 'unknown',
969
+ }, cwd);
970
+ let continuationResult;
971
+ if (action.target?.kind === 'continuation') {
972
+ const { denyContinuation, resumeApprovedContinuation } = await import('../core/loops/continuation.js');
973
+ if (outcome === 'resolved') {
974
+ const { executeContinuationPublicAction, handleBclawLoop } = await import('./loops-handlers.js');
975
+ const resumed = await resumeApprovedContinuation(action.target.continuation_id, action.id, resolved.identity.agent_name, resolved.identity.agent_id, (record) => executeContinuationPublicAction(record, {
976
+ cwd,
977
+ actor: resolved.identity.agent_name,
978
+ agentId: resolved.identity.agent_id,
979
+ sessionId: connectionSessionId,
980
+ }), cwd);
981
+ const downstreamId = resumed.record.downstream?.id;
982
+ let bind;
983
+ if (downstreamId) {
984
+ const { getLoop } = await import('../core/loops/store.js');
985
+ if (getLoop(downstreamId, cwd)?.kind === 'implementation') {
986
+ const handled = await handleBclawLoop({
987
+ args: {
988
+ intent: 'bind', loop_id: downstreamId,
989
+ agent: resolved.identity.agent_name, agentId: resolved.identity.agent_id,
990
+ },
991
+ cwd,
992
+ defaultActor: resolved.identity.agent_name,
993
+ sessionId: connectionSessionId,
994
+ });
995
+ if (handled.response.status !== 'ok')
996
+ throw new Error(handled.response.error ?? handled.summary);
997
+ bind = handled.response.result;
998
+ }
999
+ }
1000
+ continuationResult = { continuation: resumed.record, bind };
1001
+ }
1002
+ else {
1003
+ const denied = denyContinuation(action.target.continuation_id, `approval ${action.id} ${outcome}`, resolved.identity.agent_name, resolved.identity.agent_id, cwd);
1004
+ continuationResult = { continuation: denied };
1005
+ }
1006
+ }
962
1007
  return {
963
1008
  response: {
964
1009
  content: [{ type: 'text', text: `Action ${actionId} ${action.status}` }],
965
1010
  structuredContent: {
966
1011
  action_id: action.id,
967
1012
  assignment_id: action.assignment_id,
1013
+ target: action.target,
968
1014
  run_id: action.run_id,
969
1015
  status: action.status,
970
1016
  resolved_at: action.resolved_at,
971
1017
  response: action.response,
1018
+ ...(continuationResult ? { continuation_result: continuationResult } : {}),
972
1019
  },
973
1020
  },
974
1021
  };
@@ -1002,6 +1002,7 @@ export async function handleBclawCoordinate(args, ctx) {
1002
1002
  created_by: creatorActor,
1003
1003
  slots,
1004
1004
  mode: req.review_mode ?? 'asymmetric',
1005
+ linked: req.linked,
1005
1006
  }, dispatchCwd);
1006
1007
  out.loopId = loop.id;
1007
1008
  out.artifacts.push({ type: 'loop', id: loop.id });
@@ -1585,6 +1586,7 @@ export async function handleBclawCoordinate(args, ctx) {
1585
1586
  goal: req.scope,
1586
1587
  created_by: creatorActor,
1587
1588
  slots,
1589
+ linked: req.linked,
1588
1590
  ...(presetSelected
1589
1591
  ? {
1590
1592
  phases: presetSelected.phases,
@@ -1677,6 +1679,7 @@ export async function handleBclawCoordinate(args, ctx) {
1677
1679
  category,
1678
1680
  text: r.text,
1679
1681
  score: r.score,
1682
+ relatedPaths: r.related_paths,
1680
1683
  }));
1681
1684
  },
1682
1685
  };
@@ -11,6 +11,7 @@ import { emitRegistryPostImage, registryFaultPoint } from './events/registry-pos
11
11
  import { createRuntimeEvent } from './events.js';
12
12
  import { loadAssignment, transitionAssignment } from './assignments.js';
13
13
  import { loadAgentRun, transitionAgentRun } from './agentruns.js';
14
+ import { denyContinuation } from './loops/continuation.js';
14
15
  function actionsDir(cwd, mode = 'read') {
15
16
  return resolveEntityDir('actions', cwd ?? process.cwd(), mode);
16
17
  }
@@ -90,7 +91,7 @@ function expireStaleActions(actions, cwd) {
90
91
  }
91
92
  catch { /* best-effort */ }
92
93
  try {
93
- const assignment = loadAssignment(action.assignment_id, cwd);
94
+ const assignment = action.assignment_id ? loadAssignment(action.assignment_id, cwd) : undefined;
94
95
  if (assignment && assignment.status === 'blocked') {
95
96
  transitionAssignment(assignment.id, 'failed', {
96
97
  actor: action.agent,
@@ -102,6 +103,12 @@ function expireStaleActions(actions, cwd) {
102
103
  }
103
104
  }
104
105
  catch { /* best-effort */ }
106
+ try {
107
+ if (action.target?.kind === 'continuation') {
108
+ denyContinuation(action.target.continuation_id, `approval ${action.id} expired`, action.agent, action.agent_id, cwd);
109
+ }
110
+ }
111
+ catch { /* best-effort */ }
105
112
  try {
106
113
  appendAuditEntry({
107
114
  actor: action.agent,
@@ -184,11 +191,15 @@ function saveActionRequired(action, cwd) {
184
191
  export function createActionRequired(options, cwd) {
185
192
  const generated = generateIdWithLabel('actions', cwd);
186
193
  const now = nowISO();
194
+ const target = options.target ?? (options.assignment_id
195
+ ? { kind: 'assignment', assignment_id: options.assignment_id }
196
+ : undefined);
187
197
  const action = ActionRequiredSchema.parse({
188
198
  schema_version: 1,
189
199
  id: generated.id,
190
200
  short_label: generated.short_label,
191
201
  assignment_id: options.assignment_id,
202
+ target,
192
203
  run_id: options.run_id,
193
204
  claim_id: options.claim_id,
194
205
  message_id: options.message_id,
@@ -216,7 +227,7 @@ export function createActionRequired(options, cwd) {
216
227
  action: 'create',
217
228
  item_id: action.id,
218
229
  item_type: 'state',
219
- after: { kind: action.kind, assignment_id: action.assignment_id, run_id: action.run_id },
230
+ after: { kind: action.kind, target: action.target, assignment_id: action.assignment_id, run_id: action.run_id },
220
231
  scope: action.scope,
221
232
  session_id: action.session_id,
222
233
  }, cwd);
@@ -268,6 +279,9 @@ export function resolveActionRequired(id, options, cwd) {
268
279
  responded_at: now,
269
280
  };
270
281
  saveActionRequired(action, cwd);
282
+ if (action.target?.kind === 'continuation' && options.outcome !== 'resolved') {
283
+ denyContinuation(action.target.continuation_id, `approval ${action.id} ${options.outcome}`, options.responded_by, options.responded_by_id, cwd);
284
+ }
271
285
  appendAuditEntry({
272
286
  actor: options.responded_by,
273
287
  actor_id: options.responded_by_id,
@@ -301,7 +315,7 @@ export function resolveActionRequired(id, options, cwd) {
301
315
  }
302
316
  }
303
317
  }
304
- const assignment = loadAssignment(action.assignment_id, cwd);
318
+ const assignment = action.assignment_id ? loadAssignment(action.assignment_id, cwd) : undefined;
305
319
  if (assignment) {
306
320
  if (options.outcome === 'resolved' && assignment.status === 'blocked') {
307
321
  transitionAssignment(assignment.id, 'started', {