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
@@ -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
@@ -21,4 +21,5 @@ export { deriveWorkerReplyContract, renderWorkerReplyProse, workerReplyNextActio
21
21
  export { abortAttempt, inspectAttempt, matchEvidence, prepareAttempt, projectAndCross, revokeAttempt, } from './attempt-authority.js';
22
22
  export { LOOP_KIND_POLICIES, assertLoopKindPoliciesComplete, isWorkerPhase, phasePolicy, policyForKind, } from './kind-policies.js';
23
23
  export { ensureTurnExecutionProjections, prepareTurnExecution, } from './turn-execution.js';
24
+ export { CONTINUATION_POLICY_VERSION, ContinuationDecisionSchema, ContinuationRecordSchema, ContinuationStateSchema, attachContinuationActionRequired, denyContinuation, ensureContinuation, evaluateContinuation, listContinuations, loadContinuation, resumeApprovedContinuation, } from './continuation.js';
24
25
  //# sourceMappingURL=index.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,14 @@ 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(),
28
+ /** Exact upstream artifact that authorized this continuation. */
29
+ source_artifact_id: z.string().regex(/^art_[0-9a-z]+$/).optional(),
30
+ /** Sealed digest of source_artifact_id at continuation evaluation time. */
31
+ source_artifact_digest: z.string().regex(/^[a-f0-9]{64}$/).optional(),
32
+ /** Durable, deterministic identity of the policy decision that created this loop. */
33
+ continuation_key: z.string().regex(/^[a-f0-9]{64}$/).optional(),
26
34
  });
27
35
  /**
28
36
  * Memory categories a loop phase can request via `context_filter` (pln#492).
@@ -155,6 +163,13 @@ export const LoopSlotSchema = z.object({
155
163
  assignment_id: z.string().optional(),
156
164
  claim_id: z.string().optional(),
157
165
  phase: z.string().optional(),
166
+ /** Implementation-loop lane bound from the linked sequence at bind time. */
167
+ lane: z.string().optional(),
168
+ /** File/path scope carried by the bound sequence lane. */
169
+ scope_hint: z.string().optional(),
170
+ /** Plans and steps executed by this lane (derived, never worker-authored). */
171
+ plan_ids: z.array(z.string().min(1)).optional(),
172
+ step_ids: z.array(z.string().min(1)).optional(),
158
173
  status: z.enum(SLOT_STATUSES),
159
174
  /**
160
175
  * pln#630 PR2b-a (§13 R1) — pointer to the immutable turn-attempt record for
@@ -418,6 +433,8 @@ export const VerifyReportBodySchema = z.object({
418
433
  command_digest: z.string().regex(/^[0-9a-f]{64}$/).optional(),
419
434
  workspace_digest: z.string().regex(/^[0-9a-f]{64}$/).optional(),
420
435
  workspace_stable: z.boolean().optional(),
436
+ /** Implementation lane whose worktree was verified. */
437
+ lane: z.string().optional(),
421
438
  });
422
439
  export const KNOWN_ARTIFACT_BODY_SCHEMAS = {
423
440
  // inline JSON body: body = JSON.stringify({ ...fields per OperatorQuestionBodySchema })
@@ -457,6 +474,8 @@ export const LoopArtifactSchema = z
457
474
  * critique artifact) is deferred to v1.1 per the plan.
458
475
  */
459
476
  addresses_critique: z.array(z.string().min(1)).optional(),
477
+ /** Executable acceptance command carried from synthesis into implementation. */
478
+ implementation_verify: LoopVerifyConfigSchema.optional(),
460
479
  /**
461
480
  * pln#492 phase 2.b — iteration window the artifact was produced in.
462
481
  * 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,
@@ -0,0 +1,39 @@
1
+ import { listAgentIdentities } from './agent-registry.js';
2
+ import { resolveExecutionCandidate } from './execution-contract.js';
3
+ export const REVIEWER_SELECTION_POLICY_VERSION = 'reviewer-selection-v1';
4
+ /**
5
+ * Select a concrete review worker from project-registered identities.
6
+ *
7
+ * The shared execution-contract resolver supplies capability checks and stable
8
+ * ordering. The policy additionally enforces reviewer independence by
9
+ * excluding every identity frozen onto an implementation slot.
10
+ */
11
+ export function selectImplementationReviewer(source, cwd) {
12
+ if (source.kind !== 'implementation') {
13
+ throw new Error(`reviewer_selection_source_invalid: loop ${source.id} is ${source.kind}`);
14
+ }
15
+ const excludedImplementers = source.slots
16
+ .filter((slot) => Boolean(slot.agent))
17
+ .map((slot) => ({ agent: slot.agent, ...(slot.agent_id ? { agent_id: slot.agent_id } : {}) }));
18
+ const excludedNames = new Set(excludedImplementers.map((identity) => identity.agent.normalize('NFC')));
19
+ const excludedIds = new Set(excludedImplementers.flatMap((identity) => identity.agent_id ? [identity.agent_id.normalize('NFC')] : []));
20
+ const identities = listAgentIdentities(cwd)
21
+ .filter((identity) => identity.kind !== 'human')
22
+ .filter((identity) => !excludedNames.has(identity.agent_name.normalize('NFC')) && !excludedIds.has(identity.agent_id.normalize('NFC')))
23
+ .map((identity) => ({ agent: identity.agent_name, agent_id: identity.agent_id }));
24
+ const resolution = resolveExecutionCandidate(identities, { roles: ['review'], required_surfaces: ['cli_spawn'], execution_surfaces: [], required_tools: [] });
25
+ if (resolution.kind !== 'selected') {
26
+ const reasons = resolution.evaluated
27
+ .map((candidate) => `${candidate.agent}:${candidate.snapshot.reasons.map((reason) => reason.code).join('+') || 'excluded'}`)
28
+ .join(', ');
29
+ throw new Error(`continuation_reviewer_unavailable: no independent spawnable reviewer${reasons ? ` (${reasons})` : ''}`);
30
+ }
31
+ return {
32
+ policy_version: REVIEWER_SELECTION_POLICY_VERSION,
33
+ agent: resolution.selected.agent,
34
+ agent_id: resolution.selected.agent_id,
35
+ evaluated: resolution.evaluated,
36
+ excluded_implementers: excludedImplementers,
37
+ };
38
+ }
39
+ //# sourceMappingURL=reviewer-policy.js.map
@@ -923,11 +923,18 @@ export const ActionRequiredResponseSchema = z.object({
923
923
  responded_by_id: z.string().optional(),
924
924
  responded_at: z.string(),
925
925
  });
926
+ export const ActionRequiredTargetSchema = z.discriminatedUnion('kind', [
927
+ z.object({ kind: z.literal('assignment'), assignment_id: z.string() }),
928
+ z.object({ kind: z.literal('continuation'), continuation_id: z.string().regex(/^ctn_[a-f0-9]{24}$/) }),
929
+ ]);
926
930
  export const ActionRequiredSchema = z.object({
927
931
  schema_version: z.number().int().positive().optional(),
928
932
  id: z.string(),
929
933
  short_label: z.string().optional(),
930
- assignment_id: z.string(),
934
+ /** Legacy top-level assignment link; retained for v1 records. */
935
+ assignment_id: z.string().optional(),
936
+ /** Discriminated approval target. New records always persist this field. */
937
+ target: ActionRequiredTargetSchema.optional(),
931
938
  run_id: z.string().optional(),
932
939
  claim_id: z.string().optional(),
933
940
  message_id: z.string().optional(),
@@ -949,6 +956,14 @@ export const ActionRequiredSchema = z.object({
949
956
  resolved_at: z.string().optional(),
950
957
  response: ActionRequiredResponseSchema.optional(),
951
958
  tags: TagsWithDefaultSchema,
959
+ }).superRefine((action, ctx) => {
960
+ const target = action.target ?? (action.assignment_id ? { kind: 'assignment', assignment_id: action.assignment_id } : undefined);
961
+ if (!target) {
962
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['target'], message: 'ActionRequired requires an assignment or continuation target' });
963
+ }
964
+ if (target?.kind === 'assignment' && action.assignment_id && target.assignment_id !== action.assignment_id) {
965
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['target'], message: 'assignment target must match assignment_id' });
966
+ }
952
967
  });
953
968
  // --- Runtime notes schemas ---
954
969
  export const RuntimeNoteTypeSchema = z.enum(['observation', 'session_start', 'session_end']);
@@ -1091,6 +1106,11 @@ export const LaneResultSchema = z.object({
1091
1106
  * reconcile this to its phase's required artifact type.
1092
1107
  */
1093
1108
  artifact_type: z.string().min(1).optional(),
1109
+ /** Synthesis-only executable acceptance policy for the downstream implementation loop. */
1110
+ implementation_verify: z.object({
1111
+ command: z.array(z.string().min(1)).min(1),
1112
+ timeout_ms: z.number().int().positive().max(15 * 60 * 1000).optional(),
1113
+ }).optional(),
1094
1114
  /**
1095
1115
  * pln#628 Focus 4B — review-loop verdict. A worker running a review-loop turn
1096
1116
  * 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.1 on 2026-08-24T17:40:07.204Z
3
3
  export const FACTS = {
4
- "version": "1.27.0",
5
- "generated_at": "2026-08-23T22:12:22.907Z",
4
+ "version": "1.28.1",
5
+ "generated_at": "2026-08-24T17:40:07.204Z",
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-24T17:40:05.036Z",
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": 85,
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": 128,
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": 11,
507
507
  "payload_chars_median": 1305,
508
508
  "payload_tokens_est_median": 326
509
509
  }