brainclaw 1.26.2 → 1.27.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 (86) 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 +87 -14
  8. package/dist/commands/mcp-catalog.js +42 -18
  9. package/dist/commands/mcp-schemas.generated.js +44 -0
  10. package/dist/commands/mcp-write-claims.js +128 -1
  11. package/dist/commands/mcp-write-coordination.js +146 -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 +160 -14
  25. package/dist/core/execution-contract.js +345 -0
  26. package/dist/core/execution.js +130 -16
  27. package/dist/core/harness-adapters/base.js +150 -0
  28. package/dist/core/harness-adapters/claude.js +39 -0
  29. package/dist/core/harness-adapters/codex.js +57 -0
  30. package/dist/core/harness-adapters/harvest.js +109 -0
  31. package/dist/core/harness-adapters/index.js +8 -0
  32. package/dist/core/harness-adapters/prompt-only.js +13 -0
  33. package/dist/core/harness-adapters/registry.js +48 -0
  34. package/dist/core/harness-adapters/result.js +33 -0
  35. package/dist/core/harness-adapters/types.js +2 -0
  36. package/dist/core/ideation-loop-close.js +25 -2
  37. package/dist/core/instruction-templates.js +3 -2
  38. package/dist/core/loop-turn-dispatch.js +207 -0
  39. package/dist/core/loops/artifact-contract.js +11 -0
  40. package/dist/core/loops/attempt-authority.js +476 -0
  41. package/dist/core/loops/attempt-generations.js +509 -0
  42. package/dist/core/loops/attempt-reservation.js +197 -35
  43. package/dist/core/loops/attempt-rollout.js +404 -0
  44. package/dist/core/loops/attempt-takeover.js +155 -0
  45. package/dist/core/loops/bootstrap-acquire.js +7 -3
  46. package/dist/core/loops/evidence.js +187 -0
  47. package/dist/core/loops/facade-schema.js +41 -10
  48. package/dist/core/loops/gate-policy.js +485 -0
  49. package/dist/core/loops/impl-bind.js +37 -79
  50. package/dist/core/loops/index.js +9 -0
  51. package/dist/core/loops/iteration-engine.js +31 -19
  52. package/dist/core/loops/kind-policies.js +90 -0
  53. package/dist/core/loops/lock.js +71 -13
  54. package/dist/core/loops/reconcile-turn.js +235 -18
  55. package/dist/core/loops/result-reducers.js +99 -10
  56. package/dist/core/loops/store.js +30 -3
  57. package/dist/core/loops/turn-execution.js +480 -0
  58. package/dist/core/loops/types.js +113 -2
  59. package/dist/core/loops/verbs.js +332 -99
  60. package/dist/core/loops/verify-command.js +31 -8
  61. package/dist/core/loops/workspace-digest.js +54 -0
  62. package/dist/core/review-loop-close.js +25 -3
  63. package/dist/core/review-loop-turn-dispatch.js +210 -161
  64. package/dist/core/runtime-signals.js +62 -25
  65. package/dist/core/schema.js +35 -0
  66. package/dist/core/spawn-check.js +3 -2
  67. package/dist/core/upgrades/backup.js +27 -4
  68. package/dist/facts.js +7 -6
  69. package/dist/facts.json +6 -5
  70. package/docs/cli.md +49 -1
  71. package/docs/concepts/attempt-authority.md +407 -0
  72. package/docs/concepts/evidence-attestations.md +135 -0
  73. package/docs/concepts/execution-contract.md +166 -0
  74. package/docs/concepts/harness-adapters.md +166 -0
  75. package/docs/concepts/ideation-loop.md +5 -4
  76. package/docs/concepts/loop-engine.md +302 -113
  77. package/docs/index.md +4 -1
  78. package/docs/integrations/codex.md +3 -3
  79. package/docs/integrations/mcp.md +59 -5
  80. package/docs/loops/debug.md +144 -0
  81. package/docs/loops/ideation.md +158 -0
  82. package/docs/loops/implementation.md +154 -0
  83. package/docs/loops/research.md +136 -0
  84. package/docs/loops/review.md +200 -0
  85. package/docs/mcp-schema-changelog.md +14 -5
  86. 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,66 @@ 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
+ },
389
+ }, cwd);
214
390
  }
215
391
  catch { /* an extra artifact failing must not abort convergence */ }
216
392
  }
217
- complete_turn({
393
+ completeTurnWithEvidence({
218
394
  id: loop.id,
219
395
  slot_id: slot.slot_id,
220
396
  actor,
397
+ evidence_context: {
398
+ channel: 'reconcile_turn',
399
+ producer_kind: 'slot',
400
+ producer_id: reservation.agent,
401
+ agent_id: reservation.agent_id,
402
+ slot_id: slot.slot_id,
403
+ slot_role: slot.role,
404
+ turn_id,
405
+ assignment_id: reservation.child_ids.assignment_id,
406
+ claim_id: reservation.claim_id,
407
+ run_id: acceptedRunId,
408
+ nonce: acceptedNonce,
409
+ attempt_epoch: acceptedEpoch,
410
+ execution_contract_hash: acceptedContractHash,
411
+ workspace_digest: acceptedWorkspaceDigest,
412
+ },
221
413
  outcome: reduced.slot_outcome,
222
414
  failure_reason: reduced.failure_reason,
223
- ...(primary ? { artifact: { phase: primary.phase, type: primary.type, body: primary.body } } : {}),
415
+ ...(primary ? {
416
+ artifact: {
417
+ phase: primary.phase,
418
+ type: primary.type,
419
+ body: primary.body,
420
+ addresses_critique: primary.addresses_critique,
421
+ },
422
+ } : {}),
224
423
  }, cwd);
225
424
  }
226
425
  catch (err) {
@@ -235,7 +434,9 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
235
434
  text: `reconcileTurn: harvested turn ${turn_id} on slot ${slot.slot_id} → loop ${loop.id} (${slot_outcome}, ${artifacts_added} artifact(s), phase ${reservation.phase})`,
236
435
  tags: ['loops', 'reconcile', 'harvest', 'turn-attempt'],
237
436
  assignment_id: reservation.child_ids.assignment_id,
238
- run_id: reservation.child_ids.run_id,
437
+ run_id: acceptedRunId,
438
+ attempt_epoch: acceptedGeneration?.attempt_epoch,
439
+ workspace_digest: acceptedGeneration?.workspace_digest,
239
440
  status_reason: `harvested_${slot_outcome}`,
240
441
  }, cwd);
241
442
  }
@@ -248,7 +449,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
248
449
  // can RETAIN the claim/worktree. Run + assignment settle unconditionally on both paths
249
450
  // (a crash that recorded the turn but not the settle still converges on replay; the
250
451
  // fix-cycle re-dispatch mints a fresh run/assignment, so completing the old is correct). ──
251
- settleRunCompleted(reservation.child_ids.run_id, actor, cwd);
452
+ settleRunCompleted(acceptedRunId, actor, cwd);
252
453
  settleAssignment(reservation.child_ids.assignment_id, actor, cwd);
253
454
  // ── Advance / stop decision. On a `done` outcome we either drive a deterministic stop
254
455
  // (reviewer_green / gate → close), continue a symmetric fix cycle (bump the round + retain
@@ -276,7 +477,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
276
477
  loop.protocol?.review_mode === 'symmetric';
277
478
  if (symmetricRC) {
278
479
  // 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
480
+ // resolveTurnId(loop, slot, phase, iteration), so a DOUBLE bump would mint two turn_ids and
280
481
  // the launch fence would spawn BOTH rounds. Bump only when this turn's round is still
281
482
  // current; a re-reconcile after the bump takes the else-branch (no re-bump, no re-emit).
282
483
  if (loop.iteration_count === reservation.iteration) {
@@ -308,7 +509,14 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
308
509
  // (reserved_never_launched — crash between arm and consume + the expiry sweep) or an
309
510
  // absent reservation is a STRAND (dec#149 R1): re-emit to self-heal. The re-dispatch's
310
511
  // 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);
512
+ const currentSlot = cur.slots.find((candidate) => candidate.slot_id === slot.slot_id);
513
+ const bumpedTurnId = resolveTurnId({
514
+ loop_id: loop.id,
515
+ slot_id: slot.slot_id,
516
+ phase: cur.current_phase,
517
+ iteration: cur.iteration_count,
518
+ current_turn_id: currentSlot?.current_turn_id,
519
+ }, cwd);
312
520
  const bumpedGrant = launchGrant(bumpedTurnId, cwd);
313
521
  const bumpedLive = getReservation(bumpedTurnId, cwd) !== undefined &&
314
522
  (bumpedGrant?.status === 'armed' || bumpedGrant?.status === 'crossed');
@@ -321,7 +529,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
321
529
  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
530
  tags: ['loops', 'reconcile', 'turn-owned', 'strand-recovery'],
323
531
  assignment_id: reservation.child_ids.assignment_id,
324
- run_id: reservation.child_ids.run_id,
532
+ run_id: acceptedRunId,
325
533
  status_reason: 'fix_cycle_strand_reemit',
326
534
  }, cwd);
327
535
  }
@@ -366,6 +574,15 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
366
574
  ...(next_turn ? { next_turn } : {}),
367
575
  };
368
576
  }
577
+ function normalizedWorkspace(value) {
578
+ try {
579
+ const resolved = fs.realpathSync.native(value);
580
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
581
+ }
582
+ catch {
583
+ return undefined;
584
+ }
585
+ }
369
586
  /**
370
587
  * Converge a TURN-OWNED lane whose worker died at the TRANSPORT level (no lane
371
588
  * 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,38 @@ 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
+ return {
89
+ artifacts: [{ phase, type: artifactType, body: capBody(body), produced_by: attempt.agent, addresses_critique: uniqueAddresses }],
90
+ slot_outcome: 'done',
91
+ };
92
+ }
93
+ return { artifacts: [{ phase, type: artifactType, body: capBody(body), produced_by: attempt.agent }], slot_outcome: 'done' };
94
+ }
95
+ if (lane.artifact_type !== 'critique') {
96
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: "ideation critique requires artifact_type 'critique'" };
97
+ }
52
98
  if (!critiques || critiques.length === 0) {
53
- return { artifacts: [], slot_outcome: 'failed', failure_reason: 'ideation lane produced no critiques (bare summary) — gate stays shut' };
99
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: 'ideation critique lane produced no critiques (bare summary) — gate stays shut' };
54
100
  }
55
101
  return {
56
102
  artifacts: critiques.map((c) => ({
@@ -61,11 +107,9 @@ export const ideationReducer = (input, attempt) => {
61
107
  };
62
108
  };
63
109
  /**
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.
110
+ * Explicit legacy helper retained for callers/tests that intentionally want a
111
+ * generic lane_result. The exhaustive LoopKind registry below never falls back
112
+ * to it: every shipped kind has a phase-aware reducer.
69
113
  */
70
114
  export const defaultReducer = (input, attempt) => {
71
115
  const { lane, phase } = input;
@@ -77,12 +121,57 @@ export const defaultReducer = (input, attempt) => {
77
121
  slot_outcome: 'done',
78
122
  };
79
123
  };
80
- const RESULT_REDUCERS = {
124
+ function typedPhaseReducer(kind, artifactByPhase) {
125
+ return (input, attempt) => {
126
+ const { lane, phase } = input;
127
+ if (lane.status !== 'completed') {
128
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: `${kind} lane status is ${lane.status}, not completed` };
129
+ }
130
+ const expectedType = artifactByPhase[phase];
131
+ if (!expectedType) {
132
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: `${kind} phase '${phase}' has no worker-result contract` };
133
+ }
134
+ if (lane.artifact_type !== expectedType) {
135
+ return {
136
+ artifacts: [],
137
+ slot_outcome: 'failed',
138
+ failure_reason: `${kind} phase '${phase}' expected artifact_type '${expectedType}', got '${lane.artifact_type}'`,
139
+ };
140
+ }
141
+ // Every worker artifact is explicitly attested. In particular a narrative
142
+ // summary can never masquerade as a gate-driving repro or verify report.
143
+ const body = (lane.body ?? lane.summary).trim();
144
+ if (!body) {
145
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: `${kind} phase '${phase}' produced no artifact body` };
146
+ }
147
+ return {
148
+ artifacts: [{ phase, type: expectedType, body: capBody(body), produced_by: attempt.agent }],
149
+ slot_outcome: 'done',
150
+ };
151
+ };
152
+ }
153
+ export const implementationReducer = typedPhaseReducer('implementation', {
154
+ execute: 'execute_report',
155
+ });
156
+ export const researchReducer = typedPhaseReducer('research', {
157
+ investigate: 'finding',
158
+ synthesize: 'synthesis',
159
+ });
160
+ export const debugReducer = typedPhaseReducer('debug', {
161
+ reproduce: 'repro',
162
+ hypothesize: 'hypothesis',
163
+ isolate: 'isolation_report',
164
+ fix: 'verify_report',
165
+ });
166
+ export const RESULT_REDUCERS = {
81
167
  review: reviewReducer,
82
168
  ideation: ideationReducer,
169
+ implementation: implementationReducer,
170
+ research: researchReducer,
171
+ debug: debugReducer,
83
172
  };
84
- /** The reducer for a loop kind a specialized one when registered, else the default. */
173
+ /** The reducer for a loop kind. Exhaustive by construction: no silent fallback. */
85
174
  export function reducerForKind(kind) {
86
- return RESULT_REDUCERS[kind] ?? defaultReducer;
175
+ return RESULT_REDUCERS[kind];
87
176
  }
88
177
  //# 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');
@@ -166,6 +167,7 @@ export function openLoop(input, cwd) {
166
167
  open_questions: [],
167
168
  linked: input.linked,
168
169
  stop_condition: input.stop_condition ?? protocolDefaults.stop_condition,
170
+ evidence_policy: evidencePolicyForNewLoop(),
169
171
  created_at: now,
170
172
  updated_at: now,
171
173
  created_by: input.created_by,
@@ -193,7 +195,13 @@ export function getLoop(id, cwd) {
193
195
  // the on-disk thread, return the reconstructed consistent view. The mutation
194
196
  // is durable in the intent; persistence catches up at the next lock-entry
195
197
  // recovery (never a write on this read path — cf. trp_fdf3e590 / dec#137).
196
- return reconstructConsistentThread(id, onDisk, cwd);
198
+ const thread = reconstructConsistentThread(id, onDisk, cwd);
199
+ if (thread) {
200
+ const diagnostics = validateThreadEvidence(thread);
201
+ if (diagnostics.length > 0)
202
+ logger.warn(`loop ${id}: rejected evidence envelopes ${JSON.stringify(diagnostics)}`);
203
+ }
204
+ return thread;
197
205
  }
198
206
  export function listLoops(filters = {}, cwd) {
199
207
  const dir = threadsDir(cwd);
@@ -206,6 +214,9 @@ export function listLoops(filters = {}, cwd) {
206
214
  try {
207
215
  const raw = fs.readFileSync(path.join(dir, file), 'utf8');
208
216
  const loop = LoopThreadSchema.parse(JSON.parse(raw));
217
+ const diagnostics = validateThreadEvidence(loop);
218
+ if (diagnostics.length > 0)
219
+ logger.warn(`loop ${loop.id}: rejected evidence envelopes ${JSON.stringify(diagnostics)}`);
209
220
  if (filters.kind && loop.kind !== filters.kind)
210
221
  continue;
211
222
  if (filters.status && loop.status !== filters.status)
@@ -361,6 +372,22 @@ export function closeLoop(input, cwd) {
361
372
  project_md_final_id,
362
373
  now: pauseNow,
363
374
  });
375
+ const persistedDiffArtifact = (current.evidence_policy !== undefined || evidenceWriterEnabled())
376
+ ? LoopArtifactSchema.parse(sealArtifactEvidence(current, writeResult.diff_artifact, {
377
+ channel: 'system_hook',
378
+ producer_kind: 'engine',
379
+ producer_id: 'brainclaw:bootstrap-write',
380
+ }))
381
+ : writeResult.diff_artifact;
382
+ const persistedQuestionArtifact = (current.evidence_policy !== undefined || evidenceWriterEnabled())
383
+ ? LoopArtifactSchema.parse(sealArtifactEvidence(current, questionArtifact, {
384
+ channel: 'system_hook',
385
+ producer_kind: 'engine',
386
+ producer_id: 'brainclaw:bootstrap-write',
387
+ slot_id: slot.slot_id,
388
+ slot_role: slot.role,
389
+ }))
390
+ : questionArtifact;
364
391
  const pausedThread = {
365
392
  ...current,
366
393
  version: pauseVersion,
@@ -370,9 +397,9 @@ export function closeLoop(input, cwd) {
370
397
  pending_file_apply: {
371
398
  artifact_id: project_md_final_id,
372
399
  target_path: writeResult.target_path,
373
- diff_artifact_id: writeResult.diff_artifact.artifact_id,
400
+ diff_artifact_id: persistedDiffArtifact.artifact_id,
374
401
  },
375
- artifacts: [...current.artifacts, writeResult.diff_artifact, questionArtifact],
402
+ artifacts: [...current.artifacts, persistedDiffArtifact, persistedQuestionArtifact],
376
403
  open_questions: [...current.open_questions, question_id],
377
404
  updated_at: pauseNow,
378
405
  };