brainclaw 1.26.2 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +13 -0
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-coordination.js +65 -1
  4. package/dist/commands/attempt-authority.js +80 -0
  5. package/dist/commands/harvest.js +140 -61
  6. package/dist/commands/loop.js +34 -0
  7. package/dist/commands/loops-handlers.js +143 -15
  8. package/dist/commands/mcp-catalog.js +52 -18
  9. package/dist/commands/mcp-schemas.generated.js +64 -0
  10. package/dist/commands/mcp-write-claims.js +128 -1
  11. package/dist/commands/mcp-write-coordination.js +149 -76
  12. package/dist/core/agent-capability.js +1 -1
  13. package/dist/core/agentrun-reconciler.js +148 -22
  14. package/dist/core/agentruns.js +254 -29
  15. package/dist/core/assignment-request-schema.js +7 -0
  16. package/dist/core/assignment-sweeper.js +5 -3
  17. package/dist/core/assignments.js +131 -33
  18. package/dist/core/claim-request-schema.js +7 -0
  19. package/dist/core/claims.js +53 -2
  20. package/dist/core/dispatch-status.js +16 -6
  21. package/dist/core/dispatcher.js +51 -51
  22. package/dist/core/entity-operations.js +20 -0
  23. package/dist/core/events.js +4 -0
  24. package/dist/core/execution-adapters.js +189 -14
  25. package/dist/core/execution-contract.js +345 -0
  26. package/dist/core/execution.js +130 -16
  27. package/dist/core/facade-schema.js +3 -0
  28. package/dist/core/harness-adapters/base.js +150 -0
  29. package/dist/core/harness-adapters/claude.js +39 -0
  30. package/dist/core/harness-adapters/codex.js +57 -0
  31. package/dist/core/harness-adapters/harvest.js +109 -0
  32. package/dist/core/harness-adapters/index.js +8 -0
  33. package/dist/core/harness-adapters/prompt-only.js +13 -0
  34. package/dist/core/harness-adapters/registry.js +48 -0
  35. package/dist/core/harness-adapters/result.js +33 -0
  36. package/dist/core/harness-adapters/types.js +2 -0
  37. package/dist/core/ideation-loop-close.js +25 -2
  38. package/dist/core/instruction-templates.js +3 -2
  39. package/dist/core/loop-turn-dispatch.js +235 -0
  40. package/dist/core/loops/artifact-contract.js +11 -0
  41. package/dist/core/loops/attempt-authority.js +496 -0
  42. package/dist/core/loops/attempt-generations.js +509 -0
  43. package/dist/core/loops/attempt-reservation.js +197 -35
  44. package/dist/core/loops/attempt-rollout.js +404 -0
  45. package/dist/core/loops/attempt-takeover.js +155 -0
  46. package/dist/core/loops/bootstrap-acquire.js +7 -3
  47. package/dist/core/loops/brief-assembly.js +21 -4
  48. package/dist/core/loops/evidence.js +188 -0
  49. package/dist/core/loops/facade-schema.js +75 -11
  50. package/dist/core/loops/gate-policy.js +533 -0
  51. package/dist/core/loops/impl-bind.js +91 -81
  52. package/dist/core/loops/index.js +9 -0
  53. package/dist/core/loops/iteration-engine.js +31 -19
  54. package/dist/core/loops/kind-policies.js +90 -0
  55. package/dist/core/loops/lock.js +71 -13
  56. package/dist/core/loops/reconcile-turn.js +237 -18
  57. package/dist/core/loops/result-reducers.js +113 -10
  58. package/dist/core/loops/store.js +34 -3
  59. package/dist/core/loops/turn-execution.js +480 -0
  60. package/dist/core/loops/types.js +127 -3
  61. package/dist/core/loops/verbs.js +335 -99
  62. package/dist/core/loops/verify-command.js +105 -20
  63. package/dist/core/loops/workspace-digest.js +54 -0
  64. package/dist/core/review-loop-close.js +25 -3
  65. package/dist/core/review-loop-turn-dispatch.js +210 -161
  66. package/dist/core/runtime-signals.js +62 -25
  67. package/dist/core/schema.js +40 -0
  68. package/dist/core/spawn-check.js +3 -2
  69. package/dist/core/upgrades/backup.js +27 -4
  70. package/dist/facts.js +9 -8
  71. package/dist/facts.json +8 -7
  72. package/docs/cli.md +49 -1
  73. package/docs/concepts/attempt-authority.md +407 -0
  74. package/docs/concepts/evidence-attestations.md +135 -0
  75. package/docs/concepts/execution-contract.md +166 -0
  76. package/docs/concepts/harness-adapters.md +166 -0
  77. package/docs/concepts/ideation-loop.md +5 -4
  78. package/docs/concepts/loop-engine.md +302 -113
  79. package/docs/index.md +4 -1
  80. package/docs/integrations/codex.md +3 -3
  81. package/docs/integrations/mcp.md +59 -5
  82. package/docs/loops/debug.md +144 -0
  83. package/docs/loops/ideation.md +158 -0
  84. package/docs/loops/implementation.md +174 -0
  85. package/docs/loops/research.md +136 -0
  86. package/docs/loops/review.md +200 -0
  87. package/docs/mcp-schema-changelog.md +18 -5
  88. package/package.json +1 -1
@@ -18,33 +18,38 @@
18
18
  * command-level orchestrator that owns both and calls this to spawn. Nothing
19
19
  * here imports harvest or review-loop-close, so no import cycle is introduced.
20
20
  */
21
- import { createCoordinatorClaim, attachAssignmentMessageToClaim, linkClaimToAssignment } from './claims.js';
22
- import { createAssignment, transitionAssignment, generateAssignmentId, patchAssignmentMessageId, loadAssignment } from './assignments.js';
21
+ import { createCoordinatorClaim, attachAssignmentMessageToClaim, ensureClaimAssignmentBinding, linkClaimToAssignment, loadClaim } from './claims.js';
22
+ import { createAssignment, loadAssignment, transitionAssignment, generateAssignmentId, patchAssignmentMessageId } from './assignments.js';
23
23
  import { turn } from './loops/verbs.js';
24
24
  import { getLoop } from './loops/store.js';
25
25
  import { generateDispatchBrief } from './dispatcher.js';
26
26
  import { sendMessage } from './messaging.js';
27
- import { buildInvokeCommand, resolveModel } from './agent-capability.js';
27
+ import { resolveModel } from './agent-capability.js';
28
+ import { buildHarnessInvocation, resolveHarnessBinding } from './harness-adapters/index.js';
28
29
  import { attemptExecution } from './execution.js';
29
- import { createAgentRun, loadAgentRun, transitionAgentRun } from './agentruns.js';
30
- import { reserve, commitReservation, armLaunch, consumeLaunchGrant, launchGrant, deriveTurnId, deriveChildIds, ReservationStateError, LaunchFenceError, } from './loops/attempt-reservation.js';
31
- /**
32
- * pln#630 gate for the turn-owned (exactly-once) review dispatch path.
33
- *
34
- * NOW DEFAULT ON (the shipped default): the finalization (PR3a), autonomous fix-cycle (PR3b),
35
- * strand self-heal (PR4), and revoked-grant recovery (R1) are all merged; the §9 conformance
36
- * harness + a live end-to-end (real spawn turn-keyed sentinel → harvest → reconcileTurn →
37
- * close-on-approve) validated the path. `BRAINCLAW_TURN_OWNED_REVIEW=0` is the explicit
38
- * KILL-SWITCH that reverts to the legacy closer (byte-identical) if a problem surfaces in prod.
39
- * Note the routing is doubly-gated: even ON, harvest only reconcile-turns a lane that OWNS a
40
- * turn reservation — a legacy-dispatched lane (no reservation) still uses the legacy path.
41
- */
30
+ import { transitionAgentRun } from './agentruns.js';
31
+ import { prepareTurnExecution } from './loops/turn-execution.js';
32
+ import { phasePolicy } from './loops/kind-policies.js';
33
+ import { deriveChildIds } from './loops/attempt-reservation.js';
34
+ import { readHeartbeat, signalExists } from './runtime-signals.js';
35
+ export function turnOwnedLoopMode() {
36
+ const configured = (process.env.BRAINCLAW_TURN_OWNED_LOOPS ?? '').trim().toLowerCase();
37
+ if (configured === 'off' || configured === 'review' || configured === 'all')
38
+ return configured;
39
+ // Backward-compatible alias: an explicitly configured legacy switch controls
40
+ // review only; an absent legacy switch adopts the new all-kinds default.
41
+ if (process.env.BRAINCLAW_TURN_OWNED_REVIEW !== undefined) {
42
+ const legacy = process.env.BRAINCLAW_TURN_OWNED_REVIEW.trim().toLowerCase();
43
+ return ['0', 'false', 'off', 'no'].includes(legacy) ? 'off' : 'review';
44
+ }
45
+ return 'all';
46
+ }
47
+ export function turnOwnedLoopEnabled(kind) {
48
+ const mode = turnOwnedLoopMode();
49
+ return mode === 'all' || (mode === 'review' && kind === 'review');
50
+ }
42
51
  export function turnOwnedReviewEnabled() {
43
- // Normalized kill-switch (review Finding 4): any of 0/false/off/no (case/space-insensitive)
44
- // disables — so an operator reaching for it under pressure can't mis-set it. Anything else
45
- // (including unset) keeps the shipped default ON.
46
- const v = (process.env.BRAINCLAW_TURN_OWNED_REVIEW ?? '').trim().toLowerCase();
47
- return !['0', 'false', 'off', 'no'].includes(v);
52
+ return turnOwnedLoopEnabled('review');
48
53
  }
49
54
  /** GRANT lease: bounds ONE launch generation's reserve→arm→consume→spawn→run-`running`
50
55
  * window. Long enough that a genuinely launching worker never expires under the PR2c-lease
@@ -59,6 +64,12 @@ const TURN_OWNED_LEASE_MS = 10 * 60_000;
59
64
  * (with a single shared lease, the grant is revoked exactly when the dispatch lease is already
60
65
  * expired, so re-arm was always denied). */
61
66
  const TURN_OWNED_DISPATCH_LEASE_MS = 30 * 60_000;
67
+ function turnProjectionFaultPoint(input, point) {
68
+ input.faultInjector?.(point);
69
+ if (process.env.BRAINCLAW_FAULT_POINT === `turn_projection_${point}`) {
70
+ throw new Error(`fault-injection: turn_projection_${point}`);
71
+ }
72
+ }
62
73
  /**
63
74
  * Prepare a turn-owned review dispatch (pln#630 PR2c-b, design dec#144). Runs the
64
75
  * exactly-once machine INLINE in the coordinator (which has store access, unlike
@@ -73,149 +84,118 @@ const TURN_OWNED_DISPATCH_LEASE_MS = 30 * 60_000;
73
84
  export function prepareTurnOwnedReviewDispatch(input) {
74
85
  const { loopId, slotId, claimId, cwd } = input;
75
86
  // ── Snapshot the loop BEFORE turn() bumps its version (dec#139 item 3). ──
76
- let iteration;
77
- let version;
87
+ let thread;
78
88
  try {
79
- const thread = getLoop(loopId, cwd);
80
- if (!thread)
81
- return { kind: 'legacy' }; // loop not found — pre-identity, safe to degrade
82
- iteration = thread.iteration_count;
83
- version = thread.version;
89
+ thread = getLoop(loopId, cwd);
84
90
  }
85
- catch {
86
- return { kind: 'legacy' };
91
+ catch (error) {
92
+ return { kind: 'denied', reason: `launch_denied: indeterminate loop read; fail-closed (${error instanceof Error ? error.message : String(error)})` };
87
93
  }
88
- const turnId = deriveTurnId(loopId, slotId, iteration);
89
- const { assignment_id: assignmentId, run_id: runId } = deriveChildIds(turnId);
90
- // Decoupled leases (dec#149 R1): the reservation dispatch lease is longer than each grant's
91
- // lease, giving a revoked (reserved_never_launched) round a window to re-arm. reserve() adopts
92
- // on a re-dispatch, so the ORIGINAL (longer) dispatch lease governs re-arm eligibility.
93
- const dispatchLease = new Date(Date.now() + TURN_OWNED_DISPATCH_LEASE_MS).toISOString();
94
- const grantLease = new Date(Date.now() + TURN_OWNED_LEASE_MS).toISOString();
95
- // ── Phase 1: claim identity. Fail-OPEN allowed ONLY here (nothing reserved yet). ──
94
+ if (!thread)
95
+ return { kind: 'legacy' }; // proven absent before identity: safe legacy path
96
+ if (thread.kind !== 'review')
97
+ return { kind: 'denied', reason: `launch_denied: loop ${loopId} is ${thread.kind}, not review` };
98
+ if (thread.status !== 'open')
99
+ return { kind: 'denied', reason: `launch_denied: loop ${loopId} is ${thread.status}, not open` };
100
+ if (thread.current_phase !== input.phase) {
101
+ return { kind: 'denied', reason: `launch_denied: phase mismatch (${thread.current_phase} != ${input.phase})` };
102
+ }
103
+ const execution = phasePolicy('review', input.phase);
104
+ if (!execution || execution.execution !== 'worker') {
105
+ return { kind: 'denied', reason: `launch_denied: review.${input.phase} is ${execution?.execution ?? 'unknown'}, not a worker phase` };
106
+ }
107
+ const slot = thread.slots.find((candidate) => candidate.slot_id === slotId);
108
+ if (!slot)
109
+ return { kind: 'denied', reason: `launch_denied: slot ${slotId} not found` };
110
+ if (slot.agent !== undefined && slot.agent !== input.agent) {
111
+ return { kind: 'denied', reason: `launch_denied: slot agent mismatch (${slot.agent} != ${input.agent})` };
112
+ }
113
+ if (slot.agent_id !== undefined && slot.agent_id !== input.agentId) {
114
+ return { kind: 'denied', reason: `launch_denied: slot agent_id mismatch (${slot.agent_id} != ${input.agentId ?? 'none'})` };
115
+ }
116
+ if (slot.claim_id !== undefined && slot.claim_id !== claimId) {
117
+ return { kind: 'denied', reason: `launch_denied: slot claim mismatch (${slot.claim_id} != ${claimId})` };
118
+ }
119
+ let claim;
96
120
  try {
97
- reserve({
98
- turn_id: turnId,
121
+ claim = loadClaim(claimId, cwd);
122
+ }
123
+ catch (error) {
124
+ return { kind: 'denied', reason: `launch_denied: indeterminate claim read; fail-closed (${error instanceof Error ? error.message : String(error)})` };
125
+ }
126
+ if (!claim)
127
+ return { kind: 'denied', reason: `launch_denied: claim ${claimId} not found` };
128
+ if (claim.status !== 'active' || claim.agent !== input.agent || claim.scope !== input.scope) {
129
+ return { kind: 'denied', reason: `launch_denied: claim ${claimId} is incompatible with slot ${slotId}` };
130
+ }
131
+ // From the common adapter onward identity may exist, so every error is
132
+ // fail-closed. Review is a protocol adapter over the same contract and fence
133
+ // used by ideation, implementation, research and debug.
134
+ // Only the loop snapshot above can safely choose the legacy path.
135
+ try {
136
+ const harnessBinding = resolveHarnessBinding(input.agent, input.model);
137
+ const prepared = prepareTurnExecution({
138
+ kind: 'review',
99
139
  loop_id: loopId,
100
140
  slot_id: slotId,
101
- target_slot_generation: iteration, // LoopSlot has no generation field — observational proxy (dec#144 #8)
102
- loop_version_at_reserve: version,
141
+ phase: input.phase,
103
142
  agent: input.agent,
104
143
  agent_id: input.agentId,
105
144
  claim_id: claimId,
106
- phase: input.phase,
107
- iteration,
108
- store_root: cwd,
145
+ dispatcher_agent: input.dispatcherAgent,
146
+ dispatcher_agent_id: input.dispatcherAgentId,
147
+ dispatcher_session_id: input.sessionId,
148
+ scope: input.scope,
149
+ description: input.description,
150
+ task: input.task,
109
151
  cwd,
110
- lease_deadline: dispatchLease,
111
- }, cwd);
112
- }
113
- catch (err) {
114
- if (err instanceof ReservationStateError && err.code === 'reservation_exists') {
115
- // A concurrent dispatch already OWNS this turn_id adopt it and fall
116
- // through to the fail-closed consume path (we may still legitimately win
117
- // the fence if the owner reserved-but-never-crossed; otherwise denied).
118
- }
119
- else {
120
- // FAIL-CLOSED (review Finding 1): any other reserve outcome is
121
- // INDETERMINATE a lock timeout (a live holder mid-critical-section),
122
- // lock-lost, or unknown error does NOT prove that no identity was claimed.
123
- // Degrading to `legacy` here would spawn an ungated worker beside a
124
- // reservation that may well exist — the exact concurrent-dispatch
125
- // double-spawn MUST-FIX 1 closes. (A definitively pre-identity error is
126
- // unreachable here: the lease we build is always parseable, and
127
- // loop-not-found is handled before reserve.)
128
- return { kind: 'denied', reason: `reserve indeterminate — fail-closed (no legacy fallback): ${err instanceof Error ? err.message : String(err)}` };
129
- }
130
- }
131
- // ── Phase 2: FAIL-CLOSED. Identity is reserved; never legacy-spawn from here. ──
132
- try {
133
- commitReservation(turnId, cwd);
134
- // Arm-or-adopt the launch grant. Arm when none exists OR when a prior generation was
135
- // REVOKED (reserved_never_launched — a crash between arm and consume, then the expiry
136
- // sweep): re-arm a FRESH generation at a strictly-higher epoch so a revoked round becomes
137
- // re-dispatchable (pln#630 dec#149 R1 strand recovery). armLaunch permits re-arming a
138
- // revoked grant and still enforces the dispatch lease, so a lease-expired reservation
139
- // refuses arm and stays correctly stranded (never a phantom-spawn-after-lease). A
140
- // concurrent arm surfaces as `already_armed` → adopt the incumbent grant.
141
- let grant = launchGrant(turnId, cwd);
142
- if (!grant || grant.status === 'revoked') {
143
- const priorEpoch = grant?.epoch ?? -1; // fresh → epoch 0 (unchanged); revoked → epoch+1
144
- try {
145
- armLaunch(turnId, { epoch: priorEpoch + 1, lease_deadline: grantLease }, cwd);
146
- }
147
- catch (err) {
148
- if (!(err instanceof LaunchFenceError && err.code === 'already_armed')) {
149
- // dispatch_lease_expired / lease_invalid / not_committed / epoch_mismatch → do-not-spawn.
150
- return { kind: 'denied', reason: `arm_refused: ${err instanceof Error ? err.message : String(err)}` };
151
- }
152
- }
153
- grant = launchGrant(turnId, cwd);
154
- }
155
- // A crossed grant = launch_attempted_unknown (worker already invoked, never re-spawn);
156
- // still-revoked = re-arm refused (lease expired) → never-launch; absent → all do-not-spawn.
157
- if (!grant || grant.status !== 'armed') {
158
- return { kind: 'denied', reason: `launch_denied: grant is ${grant?.status ?? 'absent'} (not armed)` };
159
- }
160
- // Consume the grant — the atomic exactly-once SPAWN authority.
161
- let wonTransition;
162
- try {
163
- ({ wonTransition } = consumeLaunchGrant(turnId, grant.token, grant.epoch, cwd));
164
- }
165
- catch (err) {
166
- return { kind: 'denied', reason: `launch_denied: consume refused (${err instanceof Error ? err.message : String(err)})` };
167
- }
168
- if (!wonTransition) {
169
- // Adopted — another invocation crossed the fence. MUST NOT spawn.
170
- return { kind: 'denied', reason: 'launch_denied: grant already crossed by a concurrent dispatch' };
171
- }
172
- // ── WON: this dispatch is the SOLE spawner. Bind slot + run to MY live claim
173
- // (claimId), NOT the reservation's first-reserver claim (dec#144 #3) — else a
174
- // recovery-winner would bind the slot to a dead claim and break complete_turn
175
- // auth. Mints are idempotent (save overwrites, so guard on load). ──
176
- if (!loadAssignment(assignmentId, cwd)) {
177
- createAssignment({
178
- id: assignmentId,
179
- short_label: assignmentId,
180
- claim_id: claimId,
181
- agent: input.agent,
182
- dispatcher_agent: input.dispatcherAgent,
183
- dispatcher_session_id: input.sessionId,
184
- scope: input.scope,
185
- description: input.description,
186
- tags: ['coordinate', 'review', 'loop', 'turn-owned', input.isReviewer ? 're-review' : 'author-fix'],
187
- }, cwd);
188
- }
189
- if (!loadAgentRun(runId, cwd)) {
190
- createAgentRun({
191
- id: runId,
192
- short_label: runId,
193
- assignment_id: assignmentId,
194
- claim_id: claimId,
195
- agent: input.agent,
196
- agent_id: input.agentId,
197
- transport: 'cli_spawn',
198
- status: 'created',
199
- scope: input.scope,
200
- description: input.description,
201
- worktree_path: input.worktreePath,
202
- tags: ['turn-owned', 'review', 'loop'],
203
- }, cwd);
152
+ worktree_path: input.worktreePath,
153
+ dispatch_lease_ms: TURN_OWNED_DISPATCH_LEASE_MS,
154
+ grant_lease_ms: TURN_OWNED_LEASE_MS,
155
+ model: input.model,
156
+ harness_binding: harnessBinding,
157
+ assignment_tags: ['coordinate', 'review', 'loop', 'turn-owned', input.isReviewer ? 're-review' : 'author-fix'],
158
+ run_tags: ['turn-owned', 'review', 'loop'],
159
+ on_authority_stage: (stage) => turnProjectionFaultPoint(input, stage === 'reserved'
160
+ ? 'after_reservation'
161
+ : stage === 'committed' ? 'after_commit' : 'after_arm'),
162
+ on_projection: (stage) => turnProjectionFaultPoint(input, stage === 'assignment' ? 'after_assignment'
163
+ : stage === 'run' ? 'after_run'
164
+ : stage === 'claim_binding' ? 'after_claim_binding'
165
+ : stage === 'slot_binding' ? 'after_slot_binding' : 'before_consume'),
166
+ });
167
+ if (prepared.kind !== 'won') {
168
+ const reason = /incompatible claim_id/.test(prepared.reason)
169
+ ? `claim mismatch: ${prepared.reason}`
170
+ : /lock_timeout/.test(prepared.reason)
171
+ ? `indeterminate reservation; fail-closed: ${prepared.reason}`
172
+ : prepared.reason;
173
+ return {
174
+ kind: 'denied',
175
+ reason: `launch_denied: ${reason}`,
176
+ turnId: prepared.turn_id,
177
+ alreadyCrossed: prepared.code === 'already_crossed',
178
+ };
204
179
  }
205
- turn({
206
- id: loopId,
207
- slot_id: slotId,
208
- actor: input.dispatcherAgentId ?? input.dispatcherAgent,
209
- input: input.task,
210
- turn_id: turnId,
211
- assignment_id: assignmentId,
212
- claim_id: claimId,
213
- }, cwd);
214
- return { kind: 'won', assignmentId, runId, turnId, nonce: grant.token };
180
+ turnProjectionFaultPoint(input, 'after_consume');
181
+ return {
182
+ kind: 'won',
183
+ assignmentId: prepared.assignment_id,
184
+ runId: prepared.run_id,
185
+ turnId: prepared.turn_id,
186
+ nonce: prepared.nonce,
187
+ attemptEpoch: prepared.attempt_epoch,
188
+ workspaceDigest: prepared.workspace_digest,
189
+ workspacePath: prepared.workspace_path,
190
+ executionContractRef: prepared.execution_contract_ref,
191
+ harnessBinding,
192
+ };
215
193
  }
216
194
  catch (err) {
217
195
  // FAIL-CLOSED: identity reserved; degrade to denied, NEVER legacy.
218
- return { kind: 'denied', reason: `turn-owned prep aborted after reserve: ${err instanceof Error ? err.message : String(err)}` };
196
+ const detail = err instanceof Error ? err.message : String(err);
197
+ const classified = /incompatible claim_id/.test(detail) ? `claim mismatch: ${detail}` : detail;
198
+ return { kind: 'denied', reason: `turn-owned prep fail-closed after reserve: ${classified}` };
219
199
  }
220
200
  }
221
201
  /**
@@ -228,6 +208,20 @@ export const REVIEW_VERDICT_BRIEF_SUFFIX = '\n\n## Review verdict (required —
228
208
  + '"approve" (change is good to merge) or "request_changes" (needs fixes), plus '
229
209
  + '"review_summary":"<one-line rationale>". The coordinator reads review_verdict '
230
210
  + 'to close the review loop on approve, or continue the fix cycle on request_changes.';
211
+ export const AUTHOR_RESPONSE_BRIEF_SUFFIX = '\n\n## Author response (required — integrated before loop convergence)\n'
212
+ + 'In your LANE-RESULT.json set "status":"completed", "artifact_type":"author_response", '
213
+ + 'and put the applied-fix summary and validation evidence in "body". List modified files in '
214
+ + '"files_changed". The coordinator integrates the worktree before accepting this turn.';
215
+ function hasTurnRuntimeSignal(cwd, turnId, worktreePath) {
216
+ const { assignment_id: assignmentId, run_id: runId } = deriveChildIds(turnId);
217
+ const signalPresent = (signal) => signalExists(cwd, assignmentId, signal, runId)
218
+ || signalExists(cwd, assignmentId, signal);
219
+ return signalPresent('ack')
220
+ || readHeartbeat(cwd, assignmentId, worktreePath, runId).exists
221
+ || readHeartbeat(cwd, assignmentId, worktreePath).exists
222
+ || signalPresent('completed')
223
+ || signalPresent('failed');
224
+ }
231
225
  /**
232
226
  * Dispatch a single review-loop turn: create the coordinator claim + assignment,
233
227
  * bind the slot to them (so harvest resolves the exact slot by assignment_id),
@@ -245,7 +239,11 @@ export async function dispatchReviewLoopTurn(input) {
245
239
  // caller always supplies one — default defensively for direct callers/tests.
246
240
  const cwd = input.cwd ?? process.cwd();
247
241
  const agent = slot.agent ?? '';
248
- const scope = `review-loop:${loopId}`;
242
+ // A claim owns one slot's execution lineage, not the whole loop. Using the
243
+ // loop-wide scope here made parallel reviewers reuse/conflict on one claim
244
+ // and its single assignment_id pointer. The suffix stays compatible with
245
+ // readers that parse the leading `review-loop:<loop_id>` namespace.
246
+ const scope = `review-loop:${loopId}:slot:${slot.slot_id}`;
249
247
  const isReviewer = slot.role === 'reviewer';
250
248
  const result = {
251
249
  loop_id: loopId,
@@ -269,7 +267,9 @@ export async function dispatchReviewLoopTurn(input) {
269
267
  result.worktree_path = claimResult.worktreePath;
270
268
  let assignmentId;
271
269
  let turnEcho;
270
+ let harnessBinding;
272
271
  let runLegacyProjection = true;
272
+ let executionWorktreePath = claimResult.worktreePath;
273
273
  // pln#630 — turn-owned (exactly-once) dispatch, now the DEFAULT + FAIL-CLOSED after
274
274
  // reserve. Kill-switch off (BRAINCLAW_TURN_OWNED_REVIEW=0) → runLegacyProjection stays
275
275
  // true and this branch is a byte-identical no-op (the legacy projection below runs).
@@ -288,8 +288,10 @@ export async function dispatchReviewLoopTurn(input) {
288
288
  dispatcherAgent: input.dispatcherAgent,
289
289
  dispatcherAgentId: input.dispatcherAgentId,
290
290
  sessionId: input.sessionId,
291
+ model: resolveModel(agent, { override: input.model }),
291
292
  isReviewer,
292
293
  cwd,
294
+ faultInjector: input.faultInjector,
293
295
  });
294
296
  if (prep.kind === 'denied') {
295
297
  // The exactly-once fence says this dispatch is NOT the spawner (adopted /
@@ -310,6 +312,12 @@ export async function dispatchReviewLoopTurn(input) {
310
312
  // sabotaging a live winner is high-harm and not self-healing. So we leave it.
311
313
  result.execution_status = 'inbox_only';
312
314
  result.error = prep.reason;
315
+ if (prep.alreadyCrossed
316
+ && prep.turnId
317
+ && !hasTurnRuntimeSignal(cwd, prep.turnId, claimResult.worktreePath)) {
318
+ result.authority_state = 'crossed_unknown';
319
+ result.needs_operator = true;
320
+ }
313
321
  return result;
314
322
  }
315
323
  if (prep.kind === 'won') {
@@ -318,7 +326,20 @@ export async function dispatchReviewLoopTurn(input) {
318
326
  // ack-wrapper writes a turn-keyed completion sentinel.
319
327
  assignmentId = prep.assignmentId;
320
328
  result.assignment_id = prep.assignmentId;
321
- turnEcho = { turn_id: prep.turnId, run_id: prep.runId, nonce: prep.nonce };
329
+ turnEcho = {
330
+ turn_id: prep.turnId,
331
+ run_id: prep.runId,
332
+ nonce: prep.nonce,
333
+ ...(prep.executionContractRef ? {
334
+ contract_hash: prep.executionContractRef.hash,
335
+ capability_snapshot_hash: prep.executionContractRef.snapshot_hash,
336
+ } : {}),
337
+ ...(prep.attemptEpoch !== undefined ? { attempt_epoch: prep.attemptEpoch } : {}),
338
+ ...(prep.workspaceDigest ? { workspace_digest: prep.workspaceDigest } : {}),
339
+ };
340
+ harnessBinding = prep.harnessBinding;
341
+ executionWorktreePath = prep.workspacePath;
342
+ result.worktree_path = executionWorktreePath;
322
343
  runLegacyProjection = false;
323
344
  }
324
345
  // prep.kind === 'legacy' (fail-open BEFORE identity) → fall through unchanged.
@@ -358,14 +379,29 @@ export async function dispatchReviewLoopTurn(input) {
358
379
  }
359
380
  // Reviewer turns must carry the verdict contract; author-fix turns must not
360
381
  // (an author lane has no verdict — it's mapped by scope+slot instead).
361
- const briefTask = isReviewer ? input.task + REVIEW_VERDICT_BRIEF_SUFFIX : input.task;
382
+ const briefTask = isReviewer
383
+ ? input.task + REVIEW_VERDICT_BRIEF_SUFFIX
384
+ : input.task + AUTHOR_RESPONSE_BRIEF_SUFFIX;
362
385
  const brief = generateDispatchBrief({
363
386
  task: briefTask,
364
387
  agent,
365
388
  claimId: claimResult.claimId,
366
389
  scope,
367
- worktreePath: claimResult.worktreePath,
390
+ worktreePath: executionWorktreePath,
368
391
  assignmentId,
392
+ executionContractRef: turnEcho?.contract_hash && turnEcho.capability_snapshot_hash ? {
393
+ version: 1,
394
+ hash: turnEcho.contract_hash,
395
+ snapshot_hash: turnEcho.capability_snapshot_hash,
396
+ turn_id: turnEcho.turn_id,
397
+ } : undefined,
398
+ attemptFence: turnEcho?.attempt_epoch !== undefined && turnEcho.workspace_digest ? {
399
+ turn_id: turnEcho.turn_id,
400
+ run_id: turnEcho.run_id,
401
+ nonce: turnEcho.nonce,
402
+ attempt_epoch: turnEcho.attempt_epoch,
403
+ workspace_digest: turnEcho.workspace_digest,
404
+ } : undefined,
369
405
  cwd, // pln#638 PR-6b — the context envelope reads the store
370
406
  });
371
407
  const msg = sendMessage({
@@ -389,29 +425,39 @@ export async function dispatchReviewLoopTurn(input) {
389
425
  scope,
390
426
  claim_id: claimResult.claimId,
391
427
  ...(assignmentId ? { assignment_id: assignmentId } : {}),
392
- worktree_path: claimResult.worktreePath,
428
+ worktree_path: executionWorktreePath,
393
429
  },
394
430
  }, cwd);
395
431
  result.message_id = msg.id;
396
432
  if (assignmentId) {
397
433
  try {
398
434
  attachAssignmentMessageToClaim(claimResult.claimId, msg.id, cwd);
399
- linkClaimToAssignment(claimResult.claimId, assignmentId, cwd);
400
- transitionAssignment(assignmentId, 'offered', { actor: input.dispatcherAgent }, cwd);
435
+ if (turnEcho)
436
+ ensureClaimAssignmentBinding(claimResult.claimId, assignmentId, cwd);
437
+ else
438
+ linkClaimToAssignment(claimResult.claimId, assignmentId, cwd);
439
+ const logicalAssignment = loadAssignment(assignmentId, cwd);
440
+ if (logicalAssignment?.status === 'created' || logicalAssignment?.status === 'retrying') {
441
+ transitionAssignment(assignmentId, 'offered', { actor: input.dispatcherAgent }, cwd);
442
+ }
443
+ else if (turnEcho?.attempt_epoch === undefined || logicalAssignment?.status !== 'started') {
444
+ transitionAssignment(assignmentId, 'offered', { actor: input.dispatcherAgent }, cwd);
445
+ }
401
446
  patchAssignmentMessageId(assignmentId, msg.id, cwd);
402
447
  }
403
448
  catch (linkErr) {
404
449
  result.error = `assignment linkage failed: ${linkErr instanceof Error ? linkErr.message : String(linkErr)}`;
405
450
  }
406
451
  }
407
- const invoke = buildInvokeCommand(agent, brief, {
452
+ const invoke = buildHarnessInvocation(agent, brief, {
408
453
  mode: 'worker',
409
454
  model: resolveModel(agent, { override: input.model }),
410
- });
411
- const execResult = await attemptExecution(invoke, {
455
+ binding: harnessBinding,
456
+ })?.invoke;
457
+ const execResult = await (input.executionAttempt ?? attemptExecution)(invoke, {
412
458
  agent,
413
459
  autoExecute: true,
414
- worktreePath: claimResult.worktreePath,
460
+ worktreePath: executionWorktreePath,
415
461
  claimId: claimResult.claimId,
416
462
  assignmentId,
417
463
  dispatcherAgent: input.dispatcherAgent,
@@ -425,6 +471,9 @@ export async function dispatchReviewLoopTurn(input) {
425
471
  result.shell = execResult.shell;
426
472
  if (execResult.error && !result.error)
427
473
  result.error = execResult.error;
474
+ if (execResult.execution_status === 'delivered_and_started') {
475
+ turnProjectionFaultPoint(input, 'after_spawn');
476
+ }
428
477
  // pln#630 PR2c-b — a turn-owned run was preallocated `created`; once the
429
478
  // worker actually spawned, move it to `running` so it leaves the PR2c-lease
430
479
  // pre-run lease scope (created/launching) and is governed by the heartbeat