brainclaw 1.27.0 → 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.
Binary file
@@ -16,6 +16,10 @@ function resolveActor(req, defaultActor) {
16
16
  return { actor, agentId };
17
17
  }
18
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) : [];
19
23
  return {
20
24
  response: {
21
25
  status: 'ok',
@@ -25,10 +29,59 @@ function successResponse(intent, result, artifacts, side_effects, warnings, dura
25
29
  side_effects,
26
30
  warnings,
27
31
  duration_ms: durationMs,
32
+ ...(nextActions.length > 0 ? { next_actions: nextActions } : {}),
28
33
  },
29
34
  summary,
30
35
  };
31
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
+ }
32
85
  function errorResponse(intent, code, message, durationMs, result = null) {
33
86
  return {
34
87
  response: {
@@ -371,6 +424,7 @@ export async function handleBclawLoop(options) {
371
424
  body: req.artifact.body,
372
425
  ref: req.artifact.ref,
373
426
  addresses_critique: req.artifact.addresses_critique,
427
+ implementation_verify: req.artifact.implementation_verify,
374
428
  }
375
429
  : undefined,
376
430
  actor,
@@ -434,6 +488,7 @@ export async function handleBclawLoop(options) {
434
488
  body: req.artifact.body,
435
489
  ref: req.artifact.ref,
436
490
  addresses_critique: req.artifact.addresses_critique,
491
+ implementation_verify: req.artifact.implementation_verify,
437
492
  },
438
493
  actor,
439
494
  }, options.cwd);
@@ -522,7 +577,7 @@ export async function handleBclawLoop(options) {
522
577
  return errorResponse('verify', 'not_found', `unknown loop_id ${req.loop_id}`, Date.now() - startMs);
523
578
  }
524
579
  const beforeEvents = snapshotLoopEvents(req.loop_id, options.cwd);
525
- 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);
526
581
  const newEvents = findNewLoopEvents(result.thread.id, beforeEvents, options.cwd);
527
582
  const summary = result.unconfigured
528
583
  ? `verify: loop has no protocol.verify — falling back to an agent-narrated verify_report`
@@ -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.' },
@@ -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": [
@@ -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
  };
@@ -178,6 +178,33 @@ function buildManualEnvPrefix(claimId) {
178
178
  // wrapper for symmetry with the dispatcher's buildEnvPrefix.
179
179
  return buildClaimEnvPrefix(claimId);
180
180
  }
181
+ /**
182
+ * Make the isolated worktree the explicit Codex workspace root. Relying only
183
+ * on child_process.cwd is insufficient for non-interactive Windows launches:
184
+ * the Codex sandbox can retain the coordinator workspace and apply_patch then
185
+ * refuses writes in ~/.brainclaw/worktrees even when NTFS grants access.
186
+ * `--cd` defines the primary root. Do not redundantly add the same path with
187
+ * `--add-dir`: the unelevated Windows sandbox cannot enforce split writable
188
+ * root sets and refuses to prepare its wrapper in that configuration.
189
+ */
190
+ export function withCodexWorkspaceRoot(invoke, agent, worktreePath, isWin32 = process.platform === 'win32') {
191
+ const executableName = path.win32.basename(invoke.executable).replace(/\.(?:cmd|exe|bat|com)$/i, '').toLowerCase();
192
+ if (agent.trim().toLowerCase() !== 'codex' || executableName !== 'codex' || !worktreePath)
193
+ return invoke;
194
+ const args = [...invoke.args];
195
+ const subcommandIndex = args.indexOf('exec');
196
+ const insertAt = subcommandIndex >= 0 ? subcommandIndex : 0;
197
+ args.splice(insertAt, 0, '--cd', worktreePath);
198
+ const quote = (value) => isWin32
199
+ ? `"${value.replace(/"/g, '""')}"`
200
+ : `'${value.replace(/'/g, `'\\''`)}'`;
201
+ const flags = `--cd ${quote(worktreePath)}`;
202
+ const prefix = invoke.executable;
203
+ const suffix = invoke.bashCommand.startsWith(`${prefix} `)
204
+ ? invoke.bashCommand.slice(prefix.length + 1)
205
+ : invoke.bashCommand;
206
+ return { ...invoke, args, bashCommand: `${prefix} ${flags} ${suffix}` };
207
+ }
181
208
  export class CliExecutionAdapter {
182
209
  id = 'cli';
183
210
  canSpawn(agentName) {
@@ -198,6 +225,7 @@ export class CliExecutionAdapter {
198
225
  }
199
226
  prepareManualCommand(invoke, options) {
200
227
  const isWin32 = process.platform === 'win32';
228
+ invoke = withCodexWorkspaceRoot(invoke, options.agent, options.worktreePath, isWin32);
201
229
  const shell = isWin32 ? 'cmd' : (invoke.shell ? 'bash' : 'sh');
202
230
  if (options.turnEcho?.contract_hash
203
231
  && options.turnEcho.capability_snapshot_hash
@@ -246,6 +274,7 @@ export class CliExecutionAdapter {
246
274
  }
247
275
  start(invoke, options) {
248
276
  const isWin32 = process.platform === 'win32';
277
+ invoke = withCodexWorkspaceRoot(invoke, options.agent, options.worktreePath, isWin32);
249
278
  // F7 (trp_0e5150d3): route worker env through buildWorkerIdentityEnv so the
250
279
  // worker is an independent agent — coordinator identity (BRAINCLAW_AGENT*,
251
280
  // SESSION_ID, PROJECT) is scrubbed LAST and cannot be reintroduced by
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { LoopLinksSchema } from './loops/types.js';
2
3
  export const ExecutionStatusSchema = z.enum(['delivered_and_started', 'command_ready_manual', 'inbox_only']);
3
4
  export const WorkIntentSchema = z.enum(['execute', 'consult', 'resume', 'review']);
4
5
  // pln#626 — coordinate intents split into three honest contracts:
@@ -37,6 +38,8 @@ export const CoordinateRequestSchema = z.object({
37
38
  targetAgents: z.array(z.string()).optional(),
38
39
  constraints: z.record(z.string(), z.unknown()).optional(),
39
40
  threadId: z.string().optional(),
41
+ /** Optional pipeline provenance persisted when open_loop creates a loop. */
42
+ linked: LoopLinksSchema.optional(),
40
43
  autoExecute: z.boolean().optional(),
41
44
  /**
42
45
  * When intent=review and open_loop=true, a review Loop is opened on top of
@@ -12,10 +12,12 @@ import { transitionAgentRun } from './agentruns.js';
12
12
  import { loadAssignment, patchAssignmentMessageId, transitionAssignment } from './assignments.js';
13
13
  import { attachAssignmentMessageToClaim, createCoordinatorClaim, ensureClaimAssignmentBinding, } from './claims.js';
14
14
  import { generateDispatchBrief } from './dispatcher.js';
15
+ import { search } from './search.js';
15
16
  import { attemptExecution } from './execution.js';
16
17
  import { resolveExecutionCandidate } from './execution-contract.js';
17
18
  import { buildHarnessInvocation, resolveHarnessBinding } from './harness-adapters/index.js';
18
19
  import { phasePolicy } from './loops/kind-policies.js';
20
+ import { buildIdeationBrief } from './loops/brief-assembly.js';
19
21
  import { getLoop } from './loops/store.js';
20
22
  import { prepareTurnExecution } from './loops/turn-execution.js';
21
23
  import { sendMessage } from './messaging.js';
@@ -60,7 +62,33 @@ export async function dispatchLoopTurn(input) {
60
62
  agentId = selection.selected.agent_id;
61
63
  result.agent = agent;
62
64
  }
63
- const scope = `loop:${loop.kind}:${loop.id}:slot:${slot.slot_id}`;
65
+ const scope = slot.scope_hint ?? `loop:${loop.kind}:${loop.id}:slot:${slot.slot_id}`;
66
+ const sectionByCategory = {
67
+ traps: 'traps', decisions: 'decisions', constraints: 'constraints', handoffs: 'handoffs',
68
+ plans: 'plans', candidates: 'candidates',
69
+ };
70
+ const provider = {
71
+ fetch(category, query, topK) {
72
+ const section = sectionByCategory[category];
73
+ if (!section)
74
+ return [];
75
+ return search({ query, section, maxResults: topK, cwd: input.cwd, includePending: section === 'candidates' })
76
+ .map((item) => ({
77
+ id: item.id, category, text: item.text, score: item.score, relatedPaths: item.related_paths,
78
+ }));
79
+ },
80
+ };
81
+ const phaseBrief = buildIdeationBrief({
82
+ thread: loop,
83
+ slotRole: slot.role,
84
+ memoryProvider: provider,
85
+ seedText: input.task,
86
+ scopeHints: slot.scope_hint ? slot.scope_hint.split(',').map((value) => value.trim()) : [],
87
+ });
88
+ const laneContext = slot.lane
89
+ ? `Lane: ${slot.lane}\nPlans: ${(slot.plan_ids ?? []).join(', ') || '(none)'}\nSteps: ${(slot.step_ids ?? []).join(', ') || '(whole plan)'}`
90
+ : '';
91
+ const scopedTask = [phaseBrief.text, laneContext].filter(Boolean).join('\n\n');
64
92
  const description = `${loop.kind} loop turn for ${loop.id} slot ${slot.slot_id} phase ${loop.current_phase}. ${input.task}`;
65
93
  try {
66
94
  const claim = createCoordinatorClaim({
@@ -92,7 +120,7 @@ export async function dispatchLoopTurn(input) {
92
120
  dispatcher_session_id: input.session_id,
93
121
  scope,
94
122
  description,
95
- task: input.task,
123
+ task: scopedTask,
96
124
  cwd: input.cwd,
97
125
  worktree_path: claim.worktreePath,
98
126
  model,
@@ -121,7 +149,7 @@ export async function dispatchLoopTurn(input) {
121
149
  ...(prepared.workspace_digest ? { workspace_digest: prepared.workspace_digest } : {}),
122
150
  };
123
151
  const brief = generateDispatchBrief({
124
- task: input.task,
152
+ task: scopedTask,
125
153
  agent,
126
154
  claimId: claim.claimId,
127
155
  scope,
@@ -199,6 +199,26 @@ export function executionContractForGeneration(reservation, generation) {
199
199
  if (!reservation.execution_contract || !reservation.capability_snapshot) {
200
200
  throw new AttemptGenerationError('invalid_transition', `turn ${reservation.turn_id} has no immutable execution contract`);
201
201
  }
202
+ // Generation zero anchors the already-crossed immutable reservation. Keep
203
+ // its original serialized contract: on Windows the generation cell stores a
204
+ // canonicalized (case-folded) workspace path, and rebuilding the contract
205
+ // from that path changes its hash even though it names the same checkout.
206
+ // Successor generations still derive a new contract below because their
207
+ // epoch, run id, and workspace are intentionally different.
208
+ if (generation.attempt_epoch === 0) {
209
+ const contract = reservation.execution_contract;
210
+ if (contract.identity.assignment_id !== generation.assignment_id
211
+ || contract.identity.run_id !== generation.run_id
212
+ || canonicalWorkspacePath(contract.workspace_policy.worktree_path ?? contract.workspace_policy.cwd)
213
+ !== canonicalWorkspacePath(generation.workspace_path)) {
214
+ throw new AttemptGenerationError('fenced', 'generation zero diverges from its immutable reservation contract');
215
+ }
216
+ const ref = executionContractRef(contract, reservation.capability_snapshot);
217
+ if (ref.hash !== generation.contract_hash) {
218
+ throw new AttemptGenerationError('fenced', `generation zero contract hash ${generation.contract_hash} does not match reservation ${ref.hash}`);
219
+ }
220
+ return { contract, ref };
221
+ }
202
222
  const contract = ExecutionContractSchema.parse({
203
223
  ...reservation.execution_contract,
204
224
  identity: {
@@ -45,9 +45,9 @@ const LOOP_INTERNAL_CATEGORIES = new Set([
45
45
  'synthesis_artifact',
46
46
  ]);
47
47
  export function buildIdeationBrief(input) {
48
- const { thread, slotRole, memoryProvider, maxChars = DEFAULT_MAX_CHARS, topKPerCategory = DEFAULT_TOP_K_PER_CATEGORY, } = input;
48
+ const { thread, slotRole, memoryProvider, maxChars = DEFAULT_MAX_CHARS, topKPerCategory = DEFAULT_TOP_K_PER_CATEGORY, seedText, scopeHints = [], } = input;
49
49
  const proposal = findProposalArtifact(thread);
50
- const proposalText = proposal?.body?.trim() ?? '(no proposal seed found)';
50
+ const proposalText = seedText?.trim() || proposal?.body?.trim() || '(no proposal seed found)';
51
51
  // Resolve which memory categories the current phase wants. If the
52
52
  // current phase has no context_filter, fall back to '*' (full bundle).
53
53
  const currentPhaseDef = thread.phases.find((p) => p.name === thread.current_phase);
@@ -57,7 +57,7 @@ export function buildIdeationBrief(input) {
57
57
  const fetchedItemsByCategory = new Map();
58
58
  const categoriesUsed = [];
59
59
  for (const category of userFacingCategories) {
60
- const items = memoryProvider.fetch(category, proposalText, topKPerCategory);
60
+ const items = scopeMemoryItems(memoryProvider.fetch(category, `${proposalText} ${scopeHints.join(' ')}`.trim(), topKPerCategory), scopeHints);
61
61
  if (items.length > 0) {
62
62
  fetchedItemsByCategory.set(category, items);
63
63
  categoriesUsed.push(category);
@@ -116,7 +116,7 @@ function expandUserFacingCategories(requested) {
116
116
  }
117
117
  function renderHeader(thread, slotRole, phase) {
118
118
  const lines = [
119
- `# ideation_loop brief`,
119
+ `# ${thread.kind}_loop brief`,
120
120
  `loop: ${thread.id}`,
121
121
  `phase: ${phase}`,
122
122
  `iteration: ${thread.iteration_count}`,
@@ -127,6 +127,23 @@ function renderHeader(thread, slotRole, phase) {
127
127
  lines.push(`goal: ${thread.goal}`);
128
128
  return lines.join('\n');
129
129
  }
130
+ function normalizeScope(value) {
131
+ return value.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
132
+ }
133
+ /** Keep project-wide memories and memories whose related_paths overlap this lane. */
134
+ function scopeMemoryItems(items, scopeHints) {
135
+ const scopes = scopeHints.map(normalizeScope).filter(Boolean);
136
+ if (scopes.length === 0)
137
+ return items;
138
+ return items.filter((item) => {
139
+ if (!item.relatedPaths || item.relatedPaths.length === 0)
140
+ return true;
141
+ return item.relatedPaths.some((related) => {
142
+ const path = normalizeScope(related);
143
+ return scopes.some((scope) => path.startsWith(scope) || scope.startsWith(path));
144
+ });
145
+ });
146
+ }
130
147
  function renderProposalBlock(proposalText) {
131
148
  return `## proposal\n\n${proposalText}`;
132
149
  }
@@ -24,6 +24,7 @@ export function artifactEvidenceDigest(artifact) {
24
24
  produced_by: artifact.produced_by,
25
25
  produced_at: artifact.produced_at,
26
26
  addresses_critique: artifact.addresses_critique,
27
+ implementation_verify: artifact.implementation_verify,
27
28
  iteration: artifact.iteration ?? 0,
28
29
  });
29
30
  }
@@ -102,6 +102,21 @@ export const BclawLoopCompleteTurnSchema = z.object({
102
102
  ref: LoopRefSchema.optional(),
103
103
  /** pln#492 synthesis audit trail. Required when type === 'plan_draft'. */
104
104
  addresses_critique: z.array(z.string().min(1)).optional(),
105
+ implementation_verify: z
106
+ .object({
107
+ command: z.array(z.string().min(1)).min(1),
108
+ timeout_ms: z.number().int().positive().optional(),
109
+ })
110
+ .optional(),
111
+ })
112
+ .superRefine((artifact, ctx) => {
113
+ if (artifact.type === 'plan_draft' && !artifact.implementation_verify) {
114
+ ctx.addIssue({
115
+ code: z.ZodIssueCode.custom,
116
+ message: "plan_draft requires implementation_verify for deterministic downstream verification",
117
+ path: ['implementation_verify'],
118
+ });
119
+ }
105
120
  })
106
121
  .optional(),
107
122
  expected_version: z.number().int().nonnegative().optional(),
@@ -132,13 +147,29 @@ export const BclawLoopAdvanceSchema = z.object({
132
147
  export const BclawLoopAddArtifactSchema = z.object({
133
148
  intent: z.literal('add_artifact'),
134
149
  loop_id: z.string().regex(/^lop_[0-9a-z]+$/),
135
- artifact: z.object({
150
+ artifact: z
151
+ .object({
136
152
  phase: z.string().min(1),
137
153
  type: z.string().min(1),
138
154
  body: z.string().optional(),
139
155
  ref: LoopRefSchema.optional(),
140
156
  /** pln#492 synthesis audit trail. Required when type === 'plan_draft'. */
141
157
  addresses_critique: z.array(z.string().min(1)).optional(),
158
+ implementation_verify: z
159
+ .object({
160
+ command: z.array(z.string().min(1)).min(1),
161
+ timeout_ms: z.number().int().positive().optional(),
162
+ })
163
+ .optional(),
164
+ })
165
+ .superRefine((artifact, ctx) => {
166
+ if (artifact.type === 'plan_draft' && !artifact.implementation_verify) {
167
+ ctx.addIssue({
168
+ code: z.ZodIssueCode.custom,
169
+ message: "plan_draft requires implementation_verify for deterministic downstream verification",
170
+ path: ['implementation_verify'],
171
+ });
172
+ }
142
173
  }),
143
174
  expected_version: z.number().int().nonnegative().optional(),
144
175
  ...CallerEnvelopeFields,
@@ -173,6 +204,8 @@ export const BclawLoopCloseSchema = z.object({
173
204
  export const BclawLoopVerifySchema = z.object({
174
205
  intent: z.literal('verify'),
175
206
  loop_id: z.string().regex(/^lop_[0-9a-z]+$/),
207
+ /** Required when an implementation loop has more than one bound lane. */
208
+ slot_id: z.string().regex(/^lsl_[0-9a-z]+$/).optional(),
176
209
  // No expected_version: runVerify is idempotent by (loop, iteration) via its own
177
210
  // two-lock re-check, not optimistic-concurrency CAS (review F3).
178
211
  ...CallerEnvelopeFields,
@@ -93,11 +93,14 @@ function hasUsableContent(artifact) {
93
93
  return (artifact.body ?? '').trim().length > 0 || artifact.ref !== undefined;
94
94
  }
95
95
  function legacyEligibleCount(thread, artifacts, purpose, cwd) {
96
+ return legacyEligibleArtifacts(thread, artifacts, purpose, cwd).length;
97
+ }
98
+ function legacyEligibleArtifacts(thread, artifacts, purpose, cwd) {
96
99
  // Kind-specialized purposes stay fail-closed even for persisted legacy
97
100
  // loops. Legacy relaxes envelope presence; it does not invent an authority
98
101
  // that the kind's policy explicitly forbids.
99
102
  if (GATE_POLICIES[thread.kind].requirements[purpose].authorities.length === 0)
100
- return 0;
103
+ return [];
101
104
  return artifacts.filter((artifact) => {
102
105
  if (purpose !== 'critic_signal' && !hasUsableContent(artifact))
103
106
  return false;
@@ -105,7 +108,7 @@ function legacyEligibleCount(thread, artifacts, purpose, cwd) {
105
108
  return true;
106
109
  return validateArtifactEvidence(thread, artifact).valid
107
110
  && reconciledV2AuthorityRejection(thread, artifact, cwd) === undefined;
108
- }).length;
111
+ });
109
112
  }
110
113
  function payloadFingerprint(artifact) {
111
114
  return evidenceDigest({
@@ -425,7 +428,24 @@ export function evaluateGateCondition(thread, condition, cwd) {
425
428
  case 'min_artifacts_by_type': {
426
429
  const candidates = artifactCandidates(thread, condition);
427
430
  const set = selectEligible(thread, candidates, 'artifact', cwd);
428
- return decision(thread, condition, set.eligible.length >= condition.n, legacyEligibleCount(thread, candidates, 'artifact', cwd) >= condition.n, set);
431
+ const requiredLanes = thread.kind === 'implementation' && condition.type === 'verify_report'
432
+ ? [...new Set(thread.slots.map((slot) => slot.lane).filter((lane) => Boolean(lane)))]
433
+ : [];
434
+ const covers = (artifacts) => {
435
+ if (requiredLanes.length === 0)
436
+ return artifacts.length >= condition.n;
437
+ const reported = new Set(artifacts.flatMap((artifact) => {
438
+ try {
439
+ const lane = JSON.parse(artifact.body ?? '{}').lane;
440
+ return lane ? [lane] : [];
441
+ }
442
+ catch {
443
+ return [];
444
+ }
445
+ }));
446
+ return requiredLanes.every((lane) => reported.has(lane));
447
+ };
448
+ return decision(thread, condition, covers(set.eligible), covers(legacyEligibleArtifacts(thread, candidates, 'artifact', cwd)), set);
429
449
  }
430
450
  case 'any': {
431
451
  const children = condition.conditions.map((child) => evaluateGateCondition(thread, child, cwd));
@@ -460,7 +480,35 @@ export function evaluateCommandGreen(thread, iteration, cwd) {
460
480
  }
461
481
  });
462
482
  const set = selectEligible(thread, candidates, 'command_green', cwd);
463
- return decision(thread, { kind: 'command_green', iteration }, set.eligible.length > 0, legacyEligibleCount(thread, candidates, 'command_green', cwd) > 0, set);
483
+ const requiredLanes = thread.kind === 'implementation'
484
+ ? [...new Set(thread.slots.map((slot) => slot.lane).filter((lane) => Boolean(lane)))]
485
+ : [];
486
+ const greenLanes = new Set(set.eligible.flatMap((artifact) => {
487
+ try {
488
+ const lane = JSON.parse(artifact.body ?? '{}').lane;
489
+ return lane ? [lane] : [];
490
+ }
491
+ catch {
492
+ return [];
493
+ }
494
+ }));
495
+ const allLanesGreen = requiredLanes.length === 0
496
+ ? set.eligible.length > 0
497
+ : requiredLanes.every((lane) => greenLanes.has(lane));
498
+ const legacyCandidates = legacyEligibleArtifacts(thread, candidates, 'command_green', cwd);
499
+ const legacyGreenLanes = new Set(legacyCandidates.flatMap((artifact) => {
500
+ try {
501
+ const lane = JSON.parse(artifact.body ?? '{}').lane;
502
+ return lane ? [lane] : [];
503
+ }
504
+ catch {
505
+ return [];
506
+ }
507
+ }));
508
+ const legacyAllLanesGreen = requiredLanes.length === 0
509
+ ? legacyCandidates.length > 0
510
+ : requiredLanes.every((lane) => legacyGreenLanes.has(lane));
511
+ return decision(thread, { kind: 'command_green', iteration }, allLanesGreen, legacyAllLanesGreen, set);
464
512
  }
465
513
  export function evaluateCriticSignal(thread, iteration, cwd) {
466
514
  const candidates = thread.artifacts.filter((artifact) => artifact.type === 'critic_signal' && (artifact.iteration ?? 0) === iteration);
@@ -1,7 +1,53 @@
1
- import { listSequences } from '../sequence.js';
1
+ import { loadSequence } from '../sequence.js';
2
+ import { loadState } from '../state.js';
2
3
  import { withLoopLock } from './lock.js';
3
4
  import { getLoop } from './store.js';
4
5
  import { advance } from './verbs.js';
6
+ function deriveBindings(loop, sequenceId, cwd) {
7
+ if (!loop)
8
+ throw new Error('implementation loop disappeared during bind');
9
+ const sequence = loadSequence(sequenceId, cwd);
10
+ if (sequence.items.length === 0)
11
+ throw new Error(`linked sequence ${sequenceId} has no items`);
12
+ const linkedPlans = new Set(loop.linked?.plan_ids ?? []);
13
+ if (linkedPlans.size === 0) {
14
+ throw new Error(`impl-bind requires linked.plan_ids in addition to linked.sequence_ids`);
15
+ }
16
+ const plans = new Map(loadState(cwd).plan_items.map((plan) => [plan.id, plan]));
17
+ for (const item of sequence.items) {
18
+ if (!linkedPlans.has(item.planId)) {
19
+ throw new Error(`sequence item rank ${item.rank} references unlinked plan ${item.planId}`);
20
+ }
21
+ const plan = plans.get(item.planId);
22
+ if (!plan)
23
+ throw new Error(`linked sequence ${sequenceId} references missing plan ${item.planId}`);
24
+ if (item.stepId && !(plan.steps ?? []).some((step) => step.id === item.stepId)) {
25
+ throw new Error(`sequence item rank ${item.rank} references missing step ${item.stepId} on plan ${item.planId}`);
26
+ }
27
+ }
28
+ const grouped = new Map();
29
+ for (const item of sequence.items) {
30
+ const lane = item.lane?.trim() || 'default';
31
+ grouped.set(lane, [...(grouped.get(lane) ?? []), item]);
32
+ }
33
+ const lanes = [...grouped.keys()].sort();
34
+ if (loop.slots.length !== lanes.length) {
35
+ throw new Error(`impl-bind lane/slot mismatch: sequence ${sequenceId} has ${lanes.length} lane(s) (${lanes.join(', ')}) but loop has ${loop.slots.length} slot(s); open one worker slot per lane`);
36
+ }
37
+ const bindings = {};
38
+ loop.slots.forEach((slot, index) => {
39
+ const lane = lanes[index];
40
+ const items = grouped.get(lane);
41
+ const scopes = [...new Set(items.map((item) => item.scope_hint?.trim()).filter((value) => Boolean(value)))];
42
+ bindings[slot.slot_id] = {
43
+ lane,
44
+ scope_hint: scopes.length > 0 ? scopes.join(', ') : undefined,
45
+ plan_ids: [...new Set(items.map((item) => item.planId))],
46
+ step_ids: [...new Set(items.flatMap((item) => item.stepId ? [item.stepId] : []))],
47
+ };
48
+ });
49
+ return bindings;
50
+ }
5
51
  const ENGINE_ONLY_WARNING = 'implementation bind is engine-only and does not dispatch workers; use bclaw_loop(intent="turn", dispatch=true, slot_id=...) in execute';
6
52
  function compatibilityWarnings(input) {
7
53
  const usedLaunchOption = input.lanes !== undefined
@@ -44,8 +90,12 @@ export async function runImplBind(input, cwd) {
44
90
  if (!sequenceId) {
45
91
  throw new Error(`impl-bind requires a linked sequence: open the implementation loop with linked.sequence_ids=[...] (the sequence whose lanes it executes). None found on ${loop_id}.`);
46
92
  }
47
- if (!listSequences(cwd).some((sequence) => sequence.id === sequenceId)) {
48
- throw new Error(`linked sequence ${sequenceId} not found for loop ${loop_id}`);
93
+ let bindings;
94
+ try {
95
+ bindings = deriveBindings(loop, sequenceId, cwd);
96
+ }
97
+ catch (error) {
98
+ throw new Error(`impl-bind validation failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
49
99
  }
50
100
  if (input.dryRun) {
51
101
  return {
@@ -56,6 +106,7 @@ export async function runImplBind(input, cwd) {
56
106
  messages_sent: 0,
57
107
  warnings: compatibilityWarnings(input),
58
108
  reason: `dry run: linked sequence ${sequenceId} is valid; loop stays in 'bind' and no worker is dispatched`,
109
+ lanes: Object.entries(bindings).map(([slot_id, binding]) => ({ slot_id, lane: binding.lane, scope_hint: binding.scope_hint })),
59
110
  };
60
111
  }
61
112
  const advanced = withLoopLock({
@@ -68,11 +119,11 @@ export async function runImplBind(input, cwd) {
68
119
  if (!fresh || fresh.status !== 'open' || fresh.current_phase !== 'bind')
69
120
  return null;
70
121
  const freshSequenceId = fresh.linked?.sequence_ids?.[0];
71
- if (freshSequenceId !== sequenceId
72
- || !listSequences(cwd).some((sequence) => sequence.id === sequenceId)) {
122
+ if (freshSequenceId !== sequenceId) {
73
123
  throw new Error(`linked sequence ${sequenceId} changed or disappeared before bind could advance`);
74
124
  }
75
- const result = advance({ id: loop_id, actor: dispatcherAgent }, cwd);
125
+ const freshBindings = deriveBindings(fresh, sequenceId, cwd);
126
+ const result = advance({ id: loop_id, actor: dispatcherAgent, slot_bindings: freshBindings }, cwd);
76
127
  return { phase: result.loop.current_phase, auto_closed: result.auto_closed };
77
128
  },
78
129
  });
@@ -97,6 +148,7 @@ export async function runImplBind(input, cwd) {
97
148
  messages_sent: 0,
98
149
  warnings: compatibilityWarnings(input),
99
150
  reason: `validated linked sequence ${sequenceId}; advanced bind -> ${advanced.phase}; dispatch worker slots with turn(dispatch=true)`,
151
+ lanes: Object.entries(bindings).map(([slot_id, binding]) => ({ slot_id, lane: binding.lane, scope_hint: binding.scope_hint })),
100
152
  };
101
153
  }
102
154
  //# sourceMappingURL=impl-bind.js.map
@@ -385,6 +385,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
385
385
  body: a.body,
386
386
  produced_by: a.produced_by,
387
387
  addresses_critique: a.addresses_critique,
388
+ implementation_verify: a.implementation_verify,
388
389
  },
389
390
  }, cwd);
390
391
  }
@@ -418,6 +419,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
418
419
  type: primary.type,
419
420
  body: primary.body,
420
421
  addresses_critique: primary.addresses_critique,
422
+ implementation_verify: primary.implementation_verify,
421
423
  },
422
424
  } : {}),
423
425
  }, cwd);
@@ -85,8 +85,22 @@ export const ideationReducer = (input, attempt) => {
85
85
  if (uniqueAddresses.length === 0) {
86
86
  return { artifacts: [], slot_outcome: 'failed', failure_reason: 'ideation synthesis must cite critique artifact ids in lane.artifacts' };
87
87
  }
88
+ if (!lane.implementation_verify) {
89
+ return {
90
+ artifacts: [],
91
+ slot_outcome: 'failed',
92
+ failure_reason: 'ideation synthesis must declare implementation_verify for deterministic downstream verification',
93
+ };
94
+ }
88
95
  return {
89
- artifacts: [{ phase, type: artifactType, body: capBody(body), produced_by: attempt.agent, addresses_critique: uniqueAddresses }],
96
+ artifacts: [{
97
+ phase,
98
+ type: artifactType,
99
+ body: capBody(body),
100
+ produced_by: attempt.agent,
101
+ addresses_critique: uniqueAddresses,
102
+ implementation_verify: lane.implementation_verify,
103
+ }],
90
104
  slot_outcome: 'done',
91
105
  };
92
106
  }
@@ -71,6 +71,10 @@ function buildSlot(partial) {
71
71
  assignment_id: partial.assignment_id,
72
72
  claim_id: partial.claim_id,
73
73
  phase: partial.phase,
74
+ lane: partial.lane,
75
+ scope_hint: partial.scope_hint,
76
+ plan_ids: partial.plan_ids,
77
+ step_ids: partial.step_ids,
74
78
  status: partial.status ?? 'open',
75
79
  };
76
80
  }
@@ -15,7 +15,7 @@ export const REVIEW_MODES = ['asymmetric', 'symmetric'];
15
15
  */
16
16
  export const SLOT_STATUSES = ['open', 'assigned', 'working', 'waiting_input', 'done', 'failed', 'cancelled'];
17
17
  export const TERMINAL_SLOT_STATUSES = ['done', 'failed', 'cancelled'];
18
- export const LOOP_REF_KINDS = ['plan', 'sequence', 'claim', 'handoff', 'candidate', 'message'];
18
+ export const LOOP_REF_KINDS = ['plan', 'sequence', 'claim', 'handoff', 'candidate', 'message', 'commit', 'branch'];
19
19
  export const LoopRefSchema = z.object({
20
20
  kind: z.enum(LOOP_REF_KINDS),
21
21
  id: z.string().min(1),
@@ -23,6 +23,8 @@ export const LoopRefSchema = z.object({
23
23
  export const LoopLinksSchema = z.object({
24
24
  plan_ids: z.array(z.string().min(1)).optional(),
25
25
  sequence_ids: z.array(z.string().min(1)).optional(),
26
+ /** Upstream loop in an ideation → implementation → review pipeline. */
27
+ source_loop_id: z.string().regex(/^lop_[0-9a-z]+$/).optional(),
26
28
  });
27
29
  /**
28
30
  * Memory categories a loop phase can request via `context_filter` (pln#492).
@@ -155,6 +157,13 @@ export const LoopSlotSchema = z.object({
155
157
  assignment_id: z.string().optional(),
156
158
  claim_id: z.string().optional(),
157
159
  phase: z.string().optional(),
160
+ /** Implementation-loop lane bound from the linked sequence at bind time. */
161
+ lane: z.string().optional(),
162
+ /** File/path scope carried by the bound sequence lane. */
163
+ scope_hint: z.string().optional(),
164
+ /** Plans and steps executed by this lane (derived, never worker-authored). */
165
+ plan_ids: z.array(z.string().min(1)).optional(),
166
+ step_ids: z.array(z.string().min(1)).optional(),
158
167
  status: z.enum(SLOT_STATUSES),
159
168
  /**
160
169
  * pln#630 PR2b-a (§13 R1) — pointer to the immutable turn-attempt record for
@@ -418,6 +427,8 @@ export const VerifyReportBodySchema = z.object({
418
427
  command_digest: z.string().regex(/^[0-9a-f]{64}$/).optional(),
419
428
  workspace_digest: z.string().regex(/^[0-9a-f]{64}$/).optional(),
420
429
  workspace_stable: z.boolean().optional(),
430
+ /** Implementation lane whose worktree was verified. */
431
+ lane: z.string().optional(),
421
432
  });
422
433
  export const KNOWN_ARTIFACT_BODY_SCHEMAS = {
423
434
  // inline JSON body: body = JSON.stringify({ ...fields per OperatorQuestionBodySchema })
@@ -457,6 +468,8 @@ export const LoopArtifactSchema = z
457
468
  * critique artifact) is deferred to v1.1 per the plan.
458
469
  */
459
470
  addresses_critique: z.array(z.string().min(1)).optional(),
471
+ /** Executable acceptance command carried from synthesis into implementation. */
472
+ implementation_verify: LoopVerifyConfigSchema.optional(),
460
473
  /**
461
474
  * pln#492 phase 2.b — iteration window the artifact was produced in.
462
475
  * 0-indexed (proposal/early phases produce iteration=0). Optional for
@@ -283,6 +283,9 @@ export function advance(input, cwd) {
283
283
  mutation_id,
284
284
  current_phase: to_phase,
285
285
  iteration_count,
286
+ slots: input.slot_bindings
287
+ ? current.slots.map((slot) => ({ ...slot, ...(input.slot_bindings?.[slot.slot_id] ?? {}) }))
288
+ : current.slots,
286
289
  updated_at: now,
287
290
  };
288
291
  // pln#492 phase 2.b — when the iteration engine forces the cycle out
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { spawnSync } from 'node:child_process';
26
26
  import path from 'node:path';
27
+ import { loadAssignment } from '../assignments.js';
27
28
  import { getLoop } from './store.js';
28
29
  import { withLoopLock } from './lock.js';
29
30
  import { addArtifactWithEvidence } from './verbs.js';
@@ -31,6 +32,8 @@ import { artifactsInIteration } from './iteration-engine.js';
31
32
  import { evidenceDigest } from './evidence.js';
32
33
  import { eligibleArtifactsForPurpose } from './gate-policy.js';
33
34
  import { captureWorkspaceDigest } from './workspace-digest.js';
35
+ import { findReservationByAssignmentId } from './attempt-reservation.js';
36
+ import { resolveTurnGenerationChain } from './attempt-generations.js';
34
37
  import { VERIFY_DEFAULT_TIMEOUT_MS, LOOP_ARTIFACT_BODY_MAX_BYTES, } from './types.js';
35
38
  /** VerifyReportBodySchema caps stdout_tail/stderr_tail at 1024. */
36
39
  const TAIL_MAX = 1024;
@@ -51,7 +54,15 @@ export const defaultVerifyRunner = (config) => {
51
54
  delete env[k];
52
55
  }
53
56
  const started = Date.now();
54
- const r = spawnSync(config.command[0], config.command.slice(1), {
57
+ const requestedExecutable = config.command[0];
58
+ const npmCli = process.platform === 'win32' && (requestedExecutable === 'npm' || requestedExecutable === 'npx')
59
+ ? path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', `${requestedExecutable}-cli.js`)
60
+ : undefined;
61
+ // Node cannot spawn .cmd shims with shell:false on Windows. Invoke npm's JS
62
+ // entrypoint through the current Node binary so the no-shell security contract holds.
63
+ const executable = npmCli ? process.execPath : requestedExecutable;
64
+ const commandArgs = npmCli ? [npmCli, ...config.command.slice(1)] : config.command.slice(1);
65
+ const r = spawnSync(executable, commandArgs, {
55
66
  cwd: config.cwd,
56
67
  env,
57
68
  shell: false,
@@ -79,19 +90,52 @@ export const defaultVerifyRunner = (config) => {
79
90
  };
80
91
  };
81
92
  /**
82
- * Resolve the verify command for a loop. PR1: the loop-PROJECT cwd only (the
83
- * lane-worktree cwd for a sequenced impl loop is a follow-up). Returns `unconfigured`
84
- * when the loop opted out (no `protocol.verify`).
93
+ * Resolve the verify command for a loop. Bound implementation lanes run only
94
+ * in their assignment worktree; legacy/unbound loops retain the project cwd.
95
+ * Returns `unconfigured` when the loop opted out (no `protocol.verify`).
85
96
  */
86
- export function resolveVerifyCommand(thread, cwd) {
97
+ function assignmentWorktree(assignmentId, cwd) {
98
+ const assignment = loadAssignment(assignmentId, cwd);
99
+ const reservation = findReservationByAssignmentId(assignmentId, cwd);
100
+ const generation = reservation
101
+ ? resolveTurnGenerationChain(cwd ?? reservation.store_root, reservation.turn_id)?.latest_generation
102
+ : undefined;
103
+ return generation?.workspace_path ?? assignment?.worktree_path;
104
+ }
105
+ export function resolveVerifyCommand(thread, cwd, slotId) {
87
106
  const cfg = thread.protocol?.verify;
88
107
  if (!cfg)
89
108
  return { kind: 'unconfigured' };
109
+ let verifyCwd = path.resolve(cwd ?? process.cwd());
110
+ if (thread.kind === 'implementation') {
111
+ const selected = slotId ? thread.slots.find((slot) => slot.slot_id === slotId) : undefined;
112
+ if (slotId && !selected)
113
+ throw new Error(`verify: slot ${slotId} not found on loop ${thread.id}`);
114
+ const candidates = (selected ? [selected] : thread.slots)
115
+ .filter((slot) => slot.assignment_id)
116
+ .map((slot) => ({ slot, worktree_path: assignmentWorktree(slot.assignment_id, cwd) }))
117
+ .filter((entry) => entry.worktree_path);
118
+ if (!selected && candidates.length > 1) {
119
+ throw new Error(`verify: implementation loop ${thread.id} has multiple bound worktrees; pass slot_id to verify one lane deterministically`);
120
+ }
121
+ if (!selected && thread.slots.some((slot) => slot.lane) && candidates.length === 0) {
122
+ throw new Error(`verify: implementation loop ${thread.id} has bound lanes but no assignment worktree; dispatch and settle the execute turn first`);
123
+ }
124
+ if (selected?.lane && !selected.assignment_id) {
125
+ throw new Error(`verify: slot ${selected.slot_id} is bound to lane ${selected.lane} but has no assignment worktree; dispatch and settle the execute turn first`);
126
+ }
127
+ const candidate = candidates[0];
128
+ if (selected?.assignment_id && !candidate?.worktree_path) {
129
+ throw new Error(`verify: slot ${selected.slot_id} assignment ${selected.assignment_id} has no worktree_path`);
130
+ }
131
+ if (candidate?.worktree_path)
132
+ verifyCwd = path.resolve(candidate.worktree_path);
133
+ }
90
134
  return {
91
135
  kind: 'ok',
92
136
  config: {
93
137
  command: cfg.command,
94
- cwd: path.resolve(cwd ?? process.cwd()),
138
+ cwd: verifyCwd,
95
139
  timeout_ms: cfg.timeout_ms ?? VERIFY_DEFAULT_TIMEOUT_MS,
96
140
  },
97
141
  };
@@ -133,8 +177,19 @@ export function buildVerifyReportBody(config, result, bindings) {
133
177
  });
134
178
  }
135
179
  /** True when an authoritative, still-fresh engine report exists for this iteration. */
136
- function hasVerifyReportForIteration(thread, iteration) {
137
- const reports = artifactsInIteration(thread, iteration).filter((artifact) => artifact.type === 'verify_report');
180
+ function hasVerifyReportForIteration(thread, iteration, lane) {
181
+ const reports = artifactsInIteration(thread, iteration).filter((artifact) => {
182
+ if (artifact.type !== 'verify_report')
183
+ return false;
184
+ if (!lane)
185
+ return true;
186
+ try {
187
+ return JSON.parse(artifact.body ?? '{}').lane === lane;
188
+ }
189
+ catch {
190
+ return false;
191
+ }
192
+ });
138
193
  return eligibleArtifactsForPurpose(thread, reports, 'command_green').eligible.length > 0;
139
194
  }
140
195
  /**
@@ -158,16 +213,23 @@ export function runVerify(input, cwd) {
158
213
  const thread = getLoop(input.loop_id, cwd);
159
214
  if (!thread)
160
215
  throw new Error(`loop ${input.loop_id} not found`);
161
- const resolved = resolveVerifyCommand(thread, cwd);
216
+ const inferredSlots = thread.kind === 'implementation' && !input.slot_id
217
+ ? thread.slots.filter((slot) => slot.assignment_id && assignmentWorktree(slot.assignment_id, cwd))
218
+ : [];
219
+ const selectedSlot = input.slot_id
220
+ ? thread.slots.find((slot) => slot.slot_id === input.slot_id)
221
+ : inferredSlots.length === 1 ? inferredSlots[0] : undefined;
222
+ const lane = selectedSlot?.lane;
223
+ const resolved = resolveVerifyCommand(thread, cwd, selectedSlot?.slot_id ?? input.slot_id);
162
224
  if (resolved.kind === 'unconfigured')
163
225
  return { state: 'unconfigured', thread };
164
226
  const iteration = thread.iteration_count;
165
- if (hasVerifyReportForIteration(thread, iteration))
227
+ if (hasVerifyReportForIteration(thread, iteration, lane))
166
228
  return { state: 'deduped', thread };
167
229
  // Snapshot the iteration + phase we are about to verify. The command tests THIS
168
230
  // iteration's working tree; the report must be attributed to it even if a
169
231
  // concurrent advance bumps the loop's iteration while we spawn (review F1).
170
- return { state: 'run', thread, config: resolved.config, iteration, phase: thread.current_phase };
232
+ return { state: 'run', thread, config: resolved.config, iteration, phase: thread.current_phase, lane };
171
233
  },
172
234
  });
173
235
  if (snapshot.state === 'unconfigured')
@@ -175,13 +237,13 @@ export function runVerify(input, cwd) {
175
237
  if (snapshot.state === 'deduped')
176
238
  return { thread: snapshot.thread, deduped: true };
177
239
  // --- OUT OF LOCK: run the command (may take minutes). ---
178
- const { config, iteration, phase } = snapshot;
240
+ const { config, iteration, phase, lane } = snapshot;
179
241
  const command_digest = evidenceDigest({ command: config.command });
180
242
  const workspaceBefore = captureWorkspaceDigest(config.cwd);
181
243
  const runResult = runner(config);
182
244
  const workspaceAfter = captureWorkspaceDigest(config.cwd);
183
245
  const workspace_stable = workspaceBefore === workspaceAfter;
184
- const reportAfterRun = buildVerifyReportBody(config, { ...runResult, passed: runResult.passed && workspace_stable }, { command_digest, workspace_digest: workspaceAfter, workspace_stable });
246
+ const reportAfterRun = buildVerifyReportBody(config, { ...runResult, passed: runResult.passed && workspace_stable }, { command_digest, workspace_digest: workspaceAfter, workspace_stable, lane });
185
247
  // --- Lock scope 2: re-check idempotency (by SNAPSHOT iteration), then append. ---
186
248
  return withLoopLock({
187
249
  cwd,
@@ -195,7 +257,7 @@ export function runVerify(input, cwd) {
195
257
  // Dedup on the SNAPSHOT iteration — a report for the iteration we verified already
196
258
  // landed (a concurrent verify won). Checking the snapshot (not the current)
197
259
  // iteration is what makes this correct after a concurrent advance (review F1).
198
- if (hasVerifyReportForIteration(thread, iteration)) {
260
+ if (hasVerifyReportForIteration(thread, iteration, lane)) {
199
261
  return { thread, report: reportAfterRun, deduped: true };
200
262
  }
201
263
  // Close the final out-of-lock race: the bytes verified above must still be the
@@ -203,7 +265,7 @@ export function runVerify(input, cwd) {
203
265
  // repeats this freshness check so a post-commit mutation also fails closed.
204
266
  const workspaceAtCommit = captureWorkspaceDigest(config.cwd);
205
267
  const commitStable = workspace_stable && workspaceAtCommit === workspaceAfter;
206
- const report = buildVerifyReportBody(config, { ...runResult, passed: runResult.passed && commitStable }, { command_digest, workspace_digest: workspaceAtCommit, workspace_stable: commitStable });
268
+ const report = buildVerifyReportBody(config, { ...runResult, passed: runResult.passed && commitStable }, { command_digest, workspace_digest: workspaceAtCommit, workspace_stable: commitStable, lane });
207
269
  const updated = addArtifactWithEvidence({
208
270
  id: input.loop_id,
209
271
  actor: input.actor,
@@ -1091,6 +1091,11 @@ export const LaneResultSchema = z.object({
1091
1091
  * reconcile this to its phase's required artifact type.
1092
1092
  */
1093
1093
  artifact_type: z.string().min(1).optional(),
1094
+ /** Synthesis-only executable acceptance policy for the downstream implementation loop. */
1095
+ implementation_verify: z.object({
1096
+ command: z.array(z.string().min(1)).min(1),
1097
+ timeout_ms: z.number().int().positive().max(15 * 60 * 1000).optional(),
1098
+ }).optional(),
1094
1099
  /**
1095
1100
  * pln#628 Focus 4B — review-loop verdict. A worker running a review-loop turn
1096
1101
  * sets this to signal whether the change is good to merge (`approve`) or needs
package/dist/facts.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // Generated by scripts/emit-site-facts.mjs at build time. Do not edit manually.
2
- // Source: brainclaw v1.27.0 on 2026-08-23T22:12:22.907Z
2
+ // Source: brainclaw v1.28.0 on 2026-08-24T07:09:07.814Z
3
3
  export const FACTS = {
4
- "version": "1.27.0",
5
- "generated_at": "2026-08-23T22:12:22.907Z",
4
+ "version": "1.28.0",
5
+ "generated_at": "2026-08-24T07:09:07.814Z",
6
6
  "tools": {
7
7
  "count": 70,
8
8
  "published_count": 68,
@@ -478,7 +478,7 @@ export const FACTS = {
478
478
  },
479
479
  "bench": {
480
480
  "schema": "brainclaw.bench.v1",
481
- "generated_at": "2026-08-23T22:12:20.808Z",
481
+ "generated_at": "2026-08-24T07:09:05.728Z",
482
482
  "node_version": "v24.19.0",
483
483
  "platform": "linux-x64",
484
484
  "repeats": 3,
@@ -487,7 +487,7 @@ export const FACTS = {
487
487
  "name": "cold_onboard",
488
488
  "volume": "empty",
489
489
  "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
490
- "duration_ms_median": 76,
490
+ "duration_ms_median": 81,
491
491
  "payload_chars_median": 1640,
492
492
  "payload_tokens_est_median": 410
493
493
  },
@@ -495,7 +495,7 @@ export const FACTS = {
495
495
  "name": "warm_work",
496
496
  "volume": "medium",
497
497
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
498
- "duration_ms_median": 123,
498
+ "duration_ms_median": 120,
499
499
  "payload_chars_median": 2626,
500
500
  "payload_tokens_est_median": 657
501
501
  },
@@ -503,7 +503,7 @@ export const FACTS = {
503
503
  "name": "first_edit",
504
504
  "volume": "medium",
505
505
  "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
506
- "duration_ms_median": 14,
506
+ "duration_ms_median": 13,
507
507
  "payload_chars_median": 1305,
508
508
  "payload_tokens_est_median": 326
509
509
  }
package/dist/facts.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.27.0",
3
- "generated_at": "2026-08-23T22:12:22.907Z",
2
+ "version": "1.28.0",
3
+ "generated_at": "2026-08-24T07:09:07.814Z",
4
4
  "tools": {
5
5
  "count": 70,
6
6
  "published_count": 68,
@@ -476,7 +476,7 @@
476
476
  },
477
477
  "bench": {
478
478
  "schema": "brainclaw.bench.v1",
479
- "generated_at": "2026-08-23T22:12:20.808Z",
479
+ "generated_at": "2026-08-24T07:09:05.728Z",
480
480
  "node_version": "v24.19.0",
481
481
  "platform": "linux-x64",
482
482
  "repeats": 3,
@@ -485,7 +485,7 @@
485
485
  "name": "cold_onboard",
486
486
  "volume": "empty",
487
487
  "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
488
- "duration_ms_median": 76,
488
+ "duration_ms_median": 81,
489
489
  "payload_chars_median": 1640,
490
490
  "payload_tokens_est_median": 410
491
491
  },
@@ -493,7 +493,7 @@
493
493
  "name": "warm_work",
494
494
  "volume": "medium",
495
495
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
496
- "duration_ms_median": 123,
496
+ "duration_ms_median": 120,
497
497
  "payload_chars_median": 2626,
498
498
  "payload_tokens_est_median": 657
499
499
  },
@@ -501,7 +501,7 @@
501
501
  "name": "first_edit",
502
502
  "volume": "medium",
503
503
  "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
504
- "duration_ms_median": 14,
504
+ "duration_ms_median": 13,
505
505
  "payload_chars_median": 1305,
506
506
  "payload_tokens_est_median": 326
507
507
  }
@@ -19,6 +19,11 @@ validates the plan/sequence link and advances to `execute`. It never launches
19
19
  a worker. `execute ↔ verify` iterates until the verify command is green or
20
20
  the cycle cap is hit.
21
21
 
22
+ Binding validates the complete graph: every sequence item must reference a
23
+ linked, existing plan (and an existing step when `stepId` is present). Explicit
24
+ sequence lanes are paired deterministically with worker slots, one slot per
25
+ lane. Each slot then carries its lane, plan/step ids, and `scope_hint`.
26
+
22
27
  ## Default protocol
23
28
 
24
29
  ```
@@ -67,6 +72,11 @@ iteration** — this guards the narrated-verify anti-pattern where a slot
67
72
  claims it verified without actually running the command. `command_green` in
68
73
  the iteration engine reads the reports produced against this gate.
69
74
 
75
+ For bound lanes, call `bclaw_loop(intent='verify', slot_id=…)`. The command
76
+ runs in that slot assignment's worktree. Omitting `slot_id` when several lane
77
+ worktrees exist fails closed, and `command_green` requires a current-iteration
78
+ green report from every bound lane.
79
+
70
80
  ## Stop condition
71
81
 
72
82
  ```ts
@@ -96,6 +106,9 @@ the iteration engine reads the reports produced against this gate.
96
106
  scope claim created by `turn(dispatch=true)`, and the common driver runs in
97
107
  the worktree bound to that claim. `bind` creates neither claim nor assignment.
98
108
  `session_id` is observability-only.
109
+ Dispatch also uses the bound `scope_hint` to retrieve only path-related
110
+ decisions, constraints, traps and runtime context (while retaining unscoped
111
+ project-wide memory).
99
112
  [Attempt authority](../concepts/attempt-authority.md#ordered-dispatch)
100
113
  mints a deterministic `turn_id` from `(loop_id, slot_id, iteration)` on
101
114
  every dispatch, so a concurrent re-dispatch hits `reservation_exists` and
@@ -105,6 +118,13 @@ uses `(loop_id, slot_id, phase, iteration)` for a versioned successor logical
105
118
  turn. This is a Loop Engine rule shared by every kind, not implementation-loop
106
119
  special handling.
107
120
 
121
+ At `handoff_ready`, the facade emits a structured `next_actions` call for
122
+ `bclaw_coordinate(intent='review', open_loop=true)`. It remains explicit: the
123
+ engine does not invent a reviewer or silently mutate external state. The
124
+ created review loop persists `linked.source_loop_id`, while the implementation
125
+ loop received the same provenance from its ideation source, so `list/get`
126
+ surfaces the pipeline chain without a separate registry.
127
+
108
128
  ## Recovery
109
129
 
110
130
  - **Execute worker crashed mid-iteration.** Launch grant lease expires;
@@ -408,7 +408,11 @@ will still succeed. A follow-up PR will strip the dead handler code.
408
408
  changelog records the published MCP surface fingerprint. When a tool
409
409
  name, tier, category, or input schema changes, the test fails until
410
410
  this section is updated.
411
- - MCP public surface fingerprint: `sha256:81243f3d507c274e`
411
+ - MCP public surface fingerprint: `sha256:681c47cba85b79c3`
412
+ (`LoopSlotInput` gains optional `lane`, `scope_hint`, `plan_ids`, and
413
+ `step_ids` fields so implementation-loop lane scope and provenance survive
414
+ through the public facade. Existing callers remain valid.)
415
+ Previous: `sha256:81243f3d507c274e`
412
416
  (updated 2026-08-23 for the common Loop Engine worker driver: `turn` exposes
413
417
  real dispatch/model/candidate controls and `complete_turn` exposes the full
414
418
  AttemptAuthority fence; bind remains engine-only with compatibility inputs.)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainclaw",
3
- "version": "1.27.0",
3
+ "version": "1.28.0",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "repository": {