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
@@ -1,15 +1,22 @@
1
+ import fs from 'node:fs';
1
2
  import path from 'node:path';
2
- import { getReservation, evidenceMatchesAttempt, currentNonce, deriveTurnId, launchGrant } from './attempt-reservation.js';
3
+ import { getReservation, evidenceMatchesAttempt, currentNonce, launchGrant, resolveTurnId } from './attempt-reservation.js';
3
4
  import { getLoop } from './store.js';
4
- import { complete_turn, add_artifact, advance } from './verbs.js';
5
+ import { completeTurnWithEvidence, addArtifactWithEvidence, complete_turn, advance } from './verbs.js';
5
6
  import { reducerForKind } from './result-reducers.js';
6
- import { loadAgentRun, transitionAgentRun } from '../agentruns.js';
7
+ import { loadAgentRun, recordExecutionContractAnomaly, transitionAgentRun } from '../agentruns.js';
7
8
  import { loadAssignment, transitionAssignment } from '../assignments.js';
8
9
  import { loadClaim, releaseClaim, releaseClaimIfActive } from '../claims.js';
9
10
  import { createRuntimeEvent } from '../events.js';
10
- import { readCompletionSignals } from '../runtime-signals.js';
11
+ import { readCompletionSignals, readContractAck } from '../runtime-signals.js';
11
12
  import { buildFixCycleTask } from '../review-loop-close.js';
12
13
  import { withLoopLock, LockTimeoutError, LockLostError } from './lock.js';
14
+ import { validateWorkerContractAcceptance } from '../execution-contract.js';
15
+ import { evidenceDigest } from './evidence.js';
16
+ import { executionContractForGeneration, settleActiveAttemptGenerationV2 } from './attempt-authority.js';
17
+ import { fenceForGeneration, resolveTurnGenerationChain } from './attempt-generations.js';
18
+ import { readLocalAuthorityHome } from './attempt-rollout.js';
19
+ import { LaneResultSchema } from '../schema.js';
13
20
  // The terminal loop statuses (LOOP_STATUSES = open|paused|completed|blocked|cancelled).
14
21
  // 'blocked' is LOAD-BEARING (pln#630 PR3b): the iteration cap closes a fix cycle to
15
22
  // `blocked`, and a blocked loop must be treated as terminal both by the idempotent
@@ -88,9 +95,35 @@ export function reconcileTurn(input) {
88
95
  if (!sameStore) {
89
96
  return { reconciled: false, reason: `containment: reservation store_root ${reservation.store_root} != operating store ${operatingRoot}` };
90
97
  }
98
+ const resolvedGeneration = resolveTurnGenerationChain(cwd ?? reservation.store_root, reservation.turn_id);
99
+ const activeGeneration = resolvedGeneration && (resolvedGeneration.status === 'active' || resolvedGeneration.status === 'settled')
100
+ ? resolvedGeneration.latest_generation
101
+ : undefined;
102
+ const activeRunId = activeGeneration?.run_id ?? reservation.child_ids.run_id;
103
+ const activeContractRef = activeGeneration
104
+ ? executionContractForGeneration(reservation, activeGeneration).ref
105
+ : reservation.execution_contract_ref;
106
+ const activeLaunchStatus = activeGeneration ? 'crossed' : reservation.launch?.status;
107
+ const owningRun = loadAgentRun(activeRunId, cwd);
108
+ if (owningRun?.execution_contract_anomaly) {
109
+ return {
110
+ reconciled: false,
111
+ contract_anomaly: true,
112
+ respawn: false,
113
+ reason: `persisted post-crossing execution-contract anomaly (${owningRun.execution_contract_anomaly.source}) — convergence withheld; respawn=false`,
114
+ };
115
+ }
91
116
  // ── §2 read-strict evidence gate: the LANE must be turn-keyed to THIS attempt's
92
117
  // current launch generation. A stale/mismatched result never converges the loop. ──
93
- if (!evidenceMatchesAttempt(reservation, { turn_id: lane.turn_id, run_id: lane.run_id, nonce: lane.nonce })) {
118
+ if (!evidenceMatchesAttempt(reservation, {
119
+ assignment_id: lane.assignment_id,
120
+ turn_id: lane.turn_id,
121
+ run_id: lane.run_id,
122
+ nonce: lane.nonce,
123
+ attempt_epoch: lane.attempt_epoch,
124
+ contract_hash: lane.execution_contract_hash,
125
+ workspace_digest: lane.workspace_digest,
126
+ })) {
94
127
  const nonce = currentNonce(reservation);
95
128
  return {
96
129
  reconciled: false,
@@ -99,6 +132,65 @@ export function reconcileTurn(input) {
99
132
  : `lane evidence (turn=${lane.turn_id} run=${lane.run_id} nonce=${lane.nonce}) does not match attempt ${turn_id}`,
100
133
  };
101
134
  }
135
+ if (activeContractRef) {
136
+ const completion = readCompletionSignals(cwd ?? process.cwd(), reservation.child_ids.assignment_id, activeGeneration?.run_id).completed;
137
+ const bootstrapAck = readContractAck(cwd ?? process.cwd(), reservation.child_ids.assignment_id, activeGeneration?.run_id);
138
+ const accepted = {
139
+ contract_hash: lane.execution_contract_hash ?? completion?.contract_hash ?? '',
140
+ capability_snapshot_hash: lane.capability_snapshot_hash ?? completion?.capability_snapshot_hash ?? '',
141
+ };
142
+ const bootstrapVerdict = bootstrapAck?.status === 'accepted'
143
+ && bootstrapAck.turn_id === reservation.turn_id
144
+ && bootstrapAck.run_id === activeRunId
145
+ && bootstrapAck.nonce === (activeGeneration?.launch_nonce ?? reservation.launch?.token)
146
+ && (!activeGeneration
147
+ || bootstrapAck.cwd === normalizedWorkspace(activeGeneration.workspace_path))
148
+ && (!activeGeneration || (bootstrapAck.attempt_epoch === activeGeneration.attempt_epoch
149
+ && bootstrapAck.workspace_digest === activeGeneration.workspace_digest))
150
+ ? validateWorkerContractAcceptance(activeContractRef, {
151
+ contract_hash: bootstrapAck.contract_hash,
152
+ capability_snapshot_hash: bootstrapAck.capability_snapshot_hash,
153
+ }, activeLaunchStatus)
154
+ : undefined;
155
+ const terminalVerdict = validateWorkerContractAcceptance(activeContractRef, accepted, activeLaunchStatus);
156
+ if (bootstrapVerdict?.kind !== 'accepted' || terminalVerdict.kind !== 'accepted') {
157
+ try {
158
+ recordExecutionContractAnomaly(activeRunId, {
159
+ source: bootstrapVerdict?.kind !== 'accepted'
160
+ ? 'bootstrap_ack'
161
+ : lane.execution_contract_hash ? 'lane_result' : 'completion_signal',
162
+ reason: bootstrapVerdict?.kind !== 'accepted'
163
+ ? 'bootstrap did not accept the immutable execution contract'
164
+ : 'terminal evidence did not match the immutable execution contract',
165
+ accepted_contract_hash: bootstrapVerdict?.kind !== 'accepted'
166
+ ? bootstrapAck?.contract_hash
167
+ : accepted.contract_hash,
168
+ accepted_capability_snapshot_hash: bootstrapVerdict?.kind !== 'accepted'
169
+ ? bootstrapAck?.capability_snapshot_hash
170
+ : accepted.capability_snapshot_hash,
171
+ }, cwd);
172
+ }
173
+ catch { /* ack/sentinel remains a durable fallback fence */ }
174
+ try {
175
+ createRuntimeEvent({
176
+ agent: actor,
177
+ event_type: 'run_blocked',
178
+ text: `reconcileTurn: post-crossing execution-contract acceptance anomaly for ${turn_id}; convergence WITHHELD and respawn=false`,
179
+ tags: ['loops', 'reconcile', 'contract-anomaly', 'turn-attempt'],
180
+ assignment_id: reservation.child_ids.assignment_id,
181
+ run_id: activeRunId,
182
+ status_reason: 'execution_contract_acceptance_mismatch',
183
+ }, cwd);
184
+ }
185
+ catch { /* anomaly journal best-effort */ }
186
+ return {
187
+ reconciled: false,
188
+ contract_anomaly: true,
189
+ respawn: false,
190
+ reason: 'post-crossing execution-contract acceptance mismatch or missing hash — convergence withheld; respawn=false',
191
+ };
192
+ }
193
+ }
102
194
  // ── §13 R4 contradiction: a turn-keyed FAILED sentinel present alongside a
103
195
  // completed result (the lane or a completed sentinel) → WITHHOLD convergence,
104
196
  // journal a conflict (never silently accept). Compared against the LANE's
@@ -106,9 +198,15 @@ export function reconcileTurn(input) {
106
198
  // LANE-RESULT then exited non-zero (turn-keyed failed sentinel) is a conflict,
107
199
  // not a clean close. ──
108
200
  try {
109
- const bodies = readCompletionSignals(cwd ?? process.cwd(), reservation.child_ids.assignment_id);
110
- const matchedCompleted = bodies.completed?.status === 'completed' && evidenceMatchesAttempt(reservation, bodies.completed);
111
- const matchedFailed = bodies.failed?.status === 'failed' && evidenceMatchesAttempt(reservation, bodies.failed);
201
+ const bodies = readCompletionSignals(cwd ?? process.cwd(), reservation.child_ids.assignment_id, activeGeneration?.run_id);
202
+ const matchedCompleted = bodies.completed?.status === 'completed' && evidenceMatchesAttempt(reservation, {
203
+ assignment_id: reservation.child_ids.assignment_id,
204
+ ...bodies.completed,
205
+ });
206
+ const matchedFailed = bodies.failed?.status === 'failed' && evidenceMatchesAttempt(reservation, {
207
+ assignment_id: reservation.child_ids.assignment_id,
208
+ ...bodies.failed,
209
+ });
112
210
  if (matchedFailed && (matchedCompleted || lane.status === 'completed')) {
113
211
  try {
114
212
  createRuntimeEvent({
@@ -117,7 +215,7 @@ export function reconcileTurn(input) {
117
215
  text: `reconcileTurn: turn ${turn_id} has a completed(lane/sentinel)+failed(sentinel) contradiction — auto-stop WITHHELD (§13 R4), escalating to human`,
118
216
  tags: ['loops', 'reconcile', 'conflict', 'turn-attempt'],
119
217
  assignment_id: reservation.child_ids.assignment_id,
120
- run_id: reservation.child_ids.run_id,
218
+ run_id: activeRunId,
121
219
  status_reason: 'turn_evidence_contradiction',
122
220
  }, cwd);
123
221
  }
@@ -156,7 +254,8 @@ export function reconcileTurn(input) {
156
254
  * version (plus the terminal-early-return claim release, review Finding 2).
157
255
  */
158
256
  function convergeLockedTurn(reservation, input, actor, cwd) {
159
- const { turn_id, lane } = input;
257
+ const { turn_id } = input;
258
+ let lane = input.lane;
160
259
  const loop = getLoop(reservation.loop_id, cwd);
161
260
  if (!loop)
162
261
  return { reconciled: false, reason: `loop ${reservation.loop_id} not found` };
@@ -175,6 +274,57 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
175
274
  if (slot.current_turn_id !== undefined && slot.current_turn_id !== turn_id) {
176
275
  return { reconciled: false, reason: `turn ${turn_id} superseded by current turn ${slot.current_turn_id} on slot ${slot.slot_id}` };
177
276
  }
277
+ // AttemptAuthority v2 TOCTOU closure: repeat the FULL evidence/fence check
278
+ // while holding the loop lock, then let settlement and takeover contend on
279
+ // the same immutable close(epoch) cell. If takeover won after the optimistic
280
+ // pre-check, settlement observes it here and performs no loop mutation.
281
+ const generationState = resolveTurnGenerationChain(cwd ?? reservation.store_root, reservation.turn_id);
282
+ if (generationState) {
283
+ if (!evidenceMatchesAttempt(reservation, {
284
+ assignment_id: lane.assignment_id,
285
+ turn_id: lane.turn_id,
286
+ run_id: lane.run_id,
287
+ nonce: lane.nonce,
288
+ attempt_epoch: lane.attempt_epoch,
289
+ contract_hash: lane.execution_contract_hash,
290
+ workspace_digest: lane.workspace_digest,
291
+ })) {
292
+ return { reconciled: false, reason: 'attempt generation changed before commit — stale evidence fenced' };
293
+ }
294
+ const generation = generationState.latest_generation;
295
+ const localAuthorityHome = readLocalAuthorityHome(cwd ?? reservation.store_root);
296
+ if (!localAuthorityHome) {
297
+ return { reconciled: false, reason: 'AttemptAuthority v2 settlement requires the activated local authority_home' };
298
+ }
299
+ const settlement = settleActiveAttemptGenerationV2(turn_id, fenceForGeneration(generation), lane, localAuthorityHome, actor, loop.created_by, cwd ?? reservation.store_root);
300
+ if (!settlement || settlement.cell.decision !== 'settled') {
301
+ return {
302
+ reconciled: false,
303
+ reason: `settlement lost close(${generation.attempt_epoch}) to ${settlement?.cell.decision ?? 'unknown'} — evidence is audit-only`,
304
+ };
305
+ }
306
+ lane = LaneResultSchema.parse(settlement.evidence.result);
307
+ }
308
+ const acceptedGeneration = generationState?.latest_generation;
309
+ const acceptedRunId = acceptedGeneration?.run_id ?? reservation.child_ids.run_id;
310
+ const acceptedNonce = acceptedGeneration?.launch_nonce ?? reservation.launch?.token;
311
+ const acceptedEpoch = acceptedGeneration?.attempt_epoch ?? reservation.epoch;
312
+ const acceptedContractHash = acceptedGeneration?.contract_hash
313
+ ?? reservation.execution_contract_ref?.hash
314
+ ?? evidenceDigest({
315
+ version: 'legacy-uncontracted-reservation-v1',
316
+ turn_id: reservation.turn_id,
317
+ run_id: reservation.child_ids.run_id,
318
+ epoch: reservation.epoch,
319
+ phase: reservation.phase,
320
+ iteration: reservation.iteration,
321
+ cwd: reservation.cwd,
322
+ });
323
+ const acceptedWorkspaceDigest = acceptedGeneration?.workspace_digest ?? evidenceDigest({
324
+ workspace_policy: reservation.execution_contract?.workspace_policy,
325
+ cwd: reservation.cwd,
326
+ store_root: reservation.store_root,
327
+ });
178
328
  // A terminal loop already converged → idempotent no-op (any trigger may fire us). Still
179
329
  // release the claim (review Finding 2): a cap-blocked loop that crashed AFTER the block
180
330
  // transition but BEFORE its own deferred release would otherwise leak the retained claim
@@ -210,17 +360,68 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
210
360
  const [primary, ...extras] = reduced.artifacts;
211
361
  for (const a of extras) {
212
362
  try {
213
- add_artifact({ id: loop.id, actor, artifact: { phase: a.phase, type: a.type, body: a.body, produced_by: a.produced_by } }, cwd);
363
+ addArtifactWithEvidence({
364
+ id: loop.id,
365
+ actor,
366
+ evidence_context: {
367
+ channel: 'reconcile_turn',
368
+ producer_kind: 'slot',
369
+ producer_id: reservation.agent,
370
+ agent_id: reservation.agent_id,
371
+ slot_id: slot.slot_id,
372
+ slot_role: slot.role,
373
+ turn_id,
374
+ assignment_id: reservation.child_ids.assignment_id,
375
+ claim_id: reservation.claim_id,
376
+ run_id: acceptedRunId,
377
+ nonce: acceptedNonce,
378
+ attempt_epoch: acceptedEpoch,
379
+ execution_contract_hash: acceptedContractHash,
380
+ workspace_digest: acceptedWorkspaceDigest,
381
+ },
382
+ artifact: {
383
+ phase: a.phase,
384
+ type: a.type,
385
+ body: a.body,
386
+ produced_by: a.produced_by,
387
+ addresses_critique: a.addresses_critique,
388
+ implementation_verify: a.implementation_verify,
389
+ },
390
+ }, cwd);
214
391
  }
215
392
  catch { /* an extra artifact failing must not abort convergence */ }
216
393
  }
217
- complete_turn({
394
+ completeTurnWithEvidence({
218
395
  id: loop.id,
219
396
  slot_id: slot.slot_id,
220
397
  actor,
398
+ evidence_context: {
399
+ channel: 'reconcile_turn',
400
+ producer_kind: 'slot',
401
+ producer_id: reservation.agent,
402
+ agent_id: reservation.agent_id,
403
+ slot_id: slot.slot_id,
404
+ slot_role: slot.role,
405
+ turn_id,
406
+ assignment_id: reservation.child_ids.assignment_id,
407
+ claim_id: reservation.claim_id,
408
+ run_id: acceptedRunId,
409
+ nonce: acceptedNonce,
410
+ attempt_epoch: acceptedEpoch,
411
+ execution_contract_hash: acceptedContractHash,
412
+ workspace_digest: acceptedWorkspaceDigest,
413
+ },
221
414
  outcome: reduced.slot_outcome,
222
415
  failure_reason: reduced.failure_reason,
223
- ...(primary ? { artifact: { phase: primary.phase, type: primary.type, body: primary.body } } : {}),
416
+ ...(primary ? {
417
+ artifact: {
418
+ phase: primary.phase,
419
+ type: primary.type,
420
+ body: primary.body,
421
+ addresses_critique: primary.addresses_critique,
422
+ implementation_verify: primary.implementation_verify,
423
+ },
424
+ } : {}),
224
425
  }, cwd);
225
426
  }
226
427
  catch (err) {
@@ -235,7 +436,9 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
235
436
  text: `reconcileTurn: harvested turn ${turn_id} on slot ${slot.slot_id} → loop ${loop.id} (${slot_outcome}, ${artifacts_added} artifact(s), phase ${reservation.phase})`,
236
437
  tags: ['loops', 'reconcile', 'harvest', 'turn-attempt'],
237
438
  assignment_id: reservation.child_ids.assignment_id,
238
- run_id: reservation.child_ids.run_id,
439
+ run_id: acceptedRunId,
440
+ attempt_epoch: acceptedGeneration?.attempt_epoch,
441
+ workspace_digest: acceptedGeneration?.workspace_digest,
239
442
  status_reason: `harvested_${slot_outcome}`,
240
443
  }, cwd);
241
444
  }
@@ -248,7 +451,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
248
451
  // can RETAIN the claim/worktree. Run + assignment settle unconditionally on both paths
249
452
  // (a crash that recorded the turn but not the settle still converges on replay; the
250
453
  // fix-cycle re-dispatch mints a fresh run/assignment, so completing the old is correct). ──
251
- settleRunCompleted(reservation.child_ids.run_id, actor, cwd);
454
+ settleRunCompleted(acceptedRunId, actor, cwd);
252
455
  settleAssignment(reservation.child_ids.assignment_id, actor, cwd);
253
456
  // ── Advance / stop decision. On a `done` outcome we either drive a deterministic stop
254
457
  // (reviewer_green / gate → close), continue a symmetric fix cycle (bump the round + retain
@@ -276,7 +479,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
276
479
  loop.protocol?.review_mode === 'symmetric';
277
480
  if (symmetricRC) {
278
481
  // EXACTLY-ONCE bump (the one non-negotiable safety guard): each bump changes
279
- // deriveTurnId(loop, slot, iteration), so a DOUBLE bump would mint two turn_ids and
482
+ // resolveTurnId(loop, slot, phase, iteration), so a DOUBLE bump would mint two turn_ids and
280
483
  // the launch fence would spawn BOTH rounds. Bump only when this turn's round is still
281
484
  // current; a re-reconcile after the bump takes the else-branch (no re-bump, no re-emit).
282
485
  if (loop.iteration_count === reservation.iteration) {
@@ -308,7 +511,14 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
308
511
  // (reserved_never_launched — crash between arm and consume + the expiry sweep) or an
309
512
  // absent reservation is a STRAND (dec#149 R1): re-emit to self-heal. The re-dispatch's
310
513
  // prepare re-arms a revoked grant at a higher epoch, so this round can actually relaunch.
311
- const bumpedTurnId = deriveTurnId(loop.id, slot.slot_id, cur.iteration_count);
514
+ const currentSlot = cur.slots.find((candidate) => candidate.slot_id === slot.slot_id);
515
+ const bumpedTurnId = resolveTurnId({
516
+ loop_id: loop.id,
517
+ slot_id: slot.slot_id,
518
+ phase: cur.current_phase,
519
+ iteration: cur.iteration_count,
520
+ current_turn_id: currentSlot?.current_turn_id,
521
+ }, cwd);
312
522
  const bumpedGrant = launchGrant(bumpedTurnId, cwd);
313
523
  const bumpedLive = getReservation(bumpedTurnId, cwd) !== undefined &&
314
524
  (bumpedGrant?.status === 'armed' || bumpedGrant?.status === 'crossed');
@@ -321,7 +531,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
321
531
  text: `reconcileTurn: fix-cycle round ${cur.iteration_count} of loop ${loop.id} was bumped but never dispatched (turn ${turn_id} strand) — re-emitting next_turn to self-heal`,
322
532
  tags: ['loops', 'reconcile', 'turn-owned', 'strand-recovery'],
323
533
  assignment_id: reservation.child_ids.assignment_id,
324
- run_id: reservation.child_ids.run_id,
534
+ run_id: acceptedRunId,
325
535
  status_reason: 'fix_cycle_strand_reemit',
326
536
  }, cwd);
327
537
  }
@@ -366,6 +576,15 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
366
576
  ...(next_turn ? { next_turn } : {}),
367
577
  };
368
578
  }
579
+ function normalizedWorkspace(value) {
580
+ try {
581
+ const resolved = fs.realpathSync.native(value);
582
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
583
+ }
584
+ catch {
585
+ return undefined;
586
+ }
587
+ }
369
588
  /**
370
589
  * Converge a TURN-OWNED lane whose worker died at the TRANSPORT level (no lane
371
590
  * result will ever arrive). dec#151, operator-decided option (b): the lane's
@@ -26,6 +26,22 @@ export const reviewReducer = (input, attempt) => {
26
26
  if (lane.status !== 'completed') {
27
27
  return { artifacts: [], slot_outcome: 'failed', failure_reason: `review lane status is ${lane.status}, not completed` };
28
28
  }
29
+ if (phase === 'author_response') {
30
+ if (lane.artifact_type !== 'author_response') {
31
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: "review author_response requires artifact_type 'author_response'" };
32
+ }
33
+ const response = (lane.body ?? '').trim();
34
+ if (!response) {
35
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: 'review author_response produced no body' };
36
+ }
37
+ return {
38
+ artifacts: [{ phase, type: 'author_response', body: capBody(response), produced_by: attempt.agent }],
39
+ slot_outcome: 'done',
40
+ };
41
+ }
42
+ if (phase !== 'findings' && phase !== 'followup_review') {
43
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: `review phase '${phase}' has no worker-result contract` };
44
+ }
29
45
  if (!lane.review_verdict) {
30
46
  return { artifacts: [], slot_outcome: 'failed', failure_reason: 'review lane completed without a review_verdict — cannot converge the loop' };
31
47
  }
@@ -39,7 +55,7 @@ export const reviewReducer = (input, attempt) => {
39
55
  };
40
56
  };
41
57
  /**
42
- * ideation reducer (§6). A `critique_batch`N `critique` artifacts (so
58
+ * ideation reducer (§6). An explicitly typed critique → `critique` artifacts (so
43
59
  * `min_artifacts_by_type` can open the next phase). A bare summary with no
44
60
  * critique body → slot `failed`, gate stays shut (correct: no fake progress from
45
61
  * a lane that produced no critiques).
@@ -49,8 +65,52 @@ export const ideationReducer = (input, attempt) => {
49
65
  if (lane.status !== 'completed') {
50
66
  return { artifacts: [], slot_outcome: 'failed', failure_reason: `ideation lane status is ${lane.status}, not completed` };
51
67
  }
68
+ if (phase !== 'critique') {
69
+ const artifactType = phase === 'proposal' ? 'proposal' : phase === 'revision' ? 'revision' : phase === 'synthesis' ? 'plan_draft' : undefined;
70
+ if (!artifactType) {
71
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: `ideation phase '${phase}' has no result contract` };
72
+ }
73
+ if (lane.artifact_type !== artifactType) {
74
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: `ideation phase '${phase}' expected artifact_type '${artifactType}', got '${lane.artifact_type}'` };
75
+ }
76
+ const body = (lane.body ?? lane.summary).trim();
77
+ if (!body)
78
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: `ideation ${phase} produced no body` };
79
+ if (artifactType === 'plan_draft') {
80
+ const addresses = [
81
+ ...(lane.artifacts ?? []).filter((id) => /^art_[0-9a-z]+$/.test(id)),
82
+ ...(critiques ?? []).flatMap((c) => c.addresses_critique ?? []),
83
+ ];
84
+ const uniqueAddresses = [...new Set(addresses)];
85
+ if (uniqueAddresses.length === 0) {
86
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: 'ideation synthesis must cite critique artifact ids in lane.artifacts' };
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
+ }
95
+ return {
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
+ }],
104
+ slot_outcome: 'done',
105
+ };
106
+ }
107
+ return { artifacts: [{ phase, type: artifactType, body: capBody(body), produced_by: attempt.agent }], slot_outcome: 'done' };
108
+ }
109
+ if (lane.artifact_type !== 'critique') {
110
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: "ideation critique requires artifact_type 'critique'" };
111
+ }
52
112
  if (!critiques || critiques.length === 0) {
53
- return { artifacts: [], slot_outcome: 'failed', failure_reason: 'ideation lane produced no critiques (bare summary) — gate stays shut' };
113
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: 'ideation critique lane produced no critiques (bare summary) — gate stays shut' };
54
114
  }
55
115
  return {
56
116
  artifacts: critiques.map((c) => ({
@@ -61,11 +121,9 @@ export const ideationReducer = (input, attempt) => {
61
121
  };
62
122
  };
63
123
  /**
64
- * Default reducer for loop kinds without a specialized one (implementation /
65
- * research / debug / bootstrap): a completed lane one generic `lane_result`
66
- * artifact carrying the summary; a non-completed lane → slot failed. Keeps
67
- * convergence sensible without fabricating structured artifacts a kind never
68
- * declared.
124
+ * Explicit legacy helper retained for callers/tests that intentionally want a
125
+ * generic lane_result. The exhaustive LoopKind registry below never falls back
126
+ * to it: every shipped kind has a phase-aware reducer.
69
127
  */
70
128
  export const defaultReducer = (input, attempt) => {
71
129
  const { lane, phase } = input;
@@ -77,12 +135,57 @@ export const defaultReducer = (input, attempt) => {
77
135
  slot_outcome: 'done',
78
136
  };
79
137
  };
80
- const RESULT_REDUCERS = {
138
+ function typedPhaseReducer(kind, artifactByPhase) {
139
+ return (input, attempt) => {
140
+ const { lane, phase } = input;
141
+ if (lane.status !== 'completed') {
142
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: `${kind} lane status is ${lane.status}, not completed` };
143
+ }
144
+ const expectedType = artifactByPhase[phase];
145
+ if (!expectedType) {
146
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: `${kind} phase '${phase}' has no worker-result contract` };
147
+ }
148
+ if (lane.artifact_type !== expectedType) {
149
+ return {
150
+ artifacts: [],
151
+ slot_outcome: 'failed',
152
+ failure_reason: `${kind} phase '${phase}' expected artifact_type '${expectedType}', got '${lane.artifact_type}'`,
153
+ };
154
+ }
155
+ // Every worker artifact is explicitly attested. In particular a narrative
156
+ // summary can never masquerade as a gate-driving repro or verify report.
157
+ const body = (lane.body ?? lane.summary).trim();
158
+ if (!body) {
159
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: `${kind} phase '${phase}' produced no artifact body` };
160
+ }
161
+ return {
162
+ artifacts: [{ phase, type: expectedType, body: capBody(body), produced_by: attempt.agent }],
163
+ slot_outcome: 'done',
164
+ };
165
+ };
166
+ }
167
+ export const implementationReducer = typedPhaseReducer('implementation', {
168
+ execute: 'execute_report',
169
+ });
170
+ export const researchReducer = typedPhaseReducer('research', {
171
+ investigate: 'finding',
172
+ synthesize: 'synthesis',
173
+ });
174
+ export const debugReducer = typedPhaseReducer('debug', {
175
+ reproduce: 'repro',
176
+ hypothesize: 'hypothesis',
177
+ isolate: 'isolation_report',
178
+ fix: 'verify_report',
179
+ });
180
+ export const RESULT_REDUCERS = {
81
181
  review: reviewReducer,
82
182
  ideation: ideationReducer,
183
+ implementation: implementationReducer,
184
+ research: researchReducer,
185
+ debug: debugReducer,
83
186
  };
84
- /** The reducer for a loop kind a specialized one when registered, else the default. */
187
+ /** The reducer for a loop kind. Exhaustive by construction: no silent fallback. */
85
188
  export function reducerForKind(kind) {
86
- return RESULT_REDUCERS[kind] ?? defaultReducer;
189
+ return RESULT_REDUCERS[kind];
87
190
  }
88
191
  //# sourceMappingURL=result-reducers.js.map
@@ -10,6 +10,7 @@ import { gcWorktreeIfHarvested } from '../worktree.js';
10
10
  import { writeProjectMdSafe } from './hooks/bootstrap-write.js';
11
11
  import { notifyOperatorOnInputRequested } from './hooks/notify-operator.js';
12
12
  import { reconstructConsistentThread } from './commit-intent.js';
13
+ import { evidencePolicyForNewLoop, evidenceWriterEnabled, sealArtifactEvidence, validateThreadEvidence } from './evidence.js';
13
14
  import { DEFAULT_PROTOCOLS, LoopArtifactSchema, LoopEventSchema, LoopThreadSchema, } from './types.js';
14
15
  function loopsDir(cwd) {
15
16
  return path.join(memoryDir(cwd ?? process.cwd()), 'loops');
@@ -70,6 +71,10 @@ function buildSlot(partial) {
70
71
  assignment_id: partial.assignment_id,
71
72
  claim_id: partial.claim_id,
72
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,
73
78
  status: partial.status ?? 'open',
74
79
  };
75
80
  }
@@ -166,6 +171,7 @@ export function openLoop(input, cwd) {
166
171
  open_questions: [],
167
172
  linked: input.linked,
168
173
  stop_condition: input.stop_condition ?? protocolDefaults.stop_condition,
174
+ evidence_policy: evidencePolicyForNewLoop(),
169
175
  created_at: now,
170
176
  updated_at: now,
171
177
  created_by: input.created_by,
@@ -193,7 +199,13 @@ export function getLoop(id, cwd) {
193
199
  // the on-disk thread, return the reconstructed consistent view. The mutation
194
200
  // is durable in the intent; persistence catches up at the next lock-entry
195
201
  // recovery (never a write on this read path — cf. trp_fdf3e590 / dec#137).
196
- return reconstructConsistentThread(id, onDisk, cwd);
202
+ const thread = reconstructConsistentThread(id, onDisk, cwd);
203
+ if (thread) {
204
+ const diagnostics = validateThreadEvidence(thread);
205
+ if (diagnostics.length > 0)
206
+ logger.warn(`loop ${id}: rejected evidence envelopes ${JSON.stringify(diagnostics)}`);
207
+ }
208
+ return thread;
197
209
  }
198
210
  export function listLoops(filters = {}, cwd) {
199
211
  const dir = threadsDir(cwd);
@@ -206,6 +218,9 @@ export function listLoops(filters = {}, cwd) {
206
218
  try {
207
219
  const raw = fs.readFileSync(path.join(dir, file), 'utf8');
208
220
  const loop = LoopThreadSchema.parse(JSON.parse(raw));
221
+ const diagnostics = validateThreadEvidence(loop);
222
+ if (diagnostics.length > 0)
223
+ logger.warn(`loop ${loop.id}: rejected evidence envelopes ${JSON.stringify(diagnostics)}`);
209
224
  if (filters.kind && loop.kind !== filters.kind)
210
225
  continue;
211
226
  if (filters.status && loop.status !== filters.status)
@@ -361,6 +376,22 @@ export function closeLoop(input, cwd) {
361
376
  project_md_final_id,
362
377
  now: pauseNow,
363
378
  });
379
+ const persistedDiffArtifact = (current.evidence_policy !== undefined || evidenceWriterEnabled())
380
+ ? LoopArtifactSchema.parse(sealArtifactEvidence(current, writeResult.diff_artifact, {
381
+ channel: 'system_hook',
382
+ producer_kind: 'engine',
383
+ producer_id: 'brainclaw:bootstrap-write',
384
+ }))
385
+ : writeResult.diff_artifact;
386
+ const persistedQuestionArtifact = (current.evidence_policy !== undefined || evidenceWriterEnabled())
387
+ ? LoopArtifactSchema.parse(sealArtifactEvidence(current, questionArtifact, {
388
+ channel: 'system_hook',
389
+ producer_kind: 'engine',
390
+ producer_id: 'brainclaw:bootstrap-write',
391
+ slot_id: slot.slot_id,
392
+ slot_role: slot.role,
393
+ }))
394
+ : questionArtifact;
364
395
  const pausedThread = {
365
396
  ...current,
366
397
  version: pauseVersion,
@@ -370,9 +401,9 @@ export function closeLoop(input, cwd) {
370
401
  pending_file_apply: {
371
402
  artifact_id: project_md_final_id,
372
403
  target_path: writeResult.target_path,
373
- diff_artifact_id: writeResult.diff_artifact.artifact_id,
404
+ diff_artifact_id: persistedDiffArtifact.artifact_id,
374
405
  },
375
- artifacts: [...current.artifacts, writeResult.diff_artifact, questionArtifact],
406
+ artifacts: [...current.artifacts, persistedDiffArtifact, persistedQuestionArtifact],
376
407
  open_questions: [...current.open_questions, question_id],
377
408
  updated_at: pauseNow,
378
409
  };