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
@@ -4,8 +4,13 @@ import { convergeAssignmentToTerminal } from '../assignments.js';
4
4
  import { writeProjectMdSafe } from './hooks/bootstrap-write.js';
5
5
  import { appendEvent, closeLoop, generateMutationId, getLoop, listLoopEvents, writeThreadFile, } from './store.js';
6
6
  import { commitViaIntent } from './commit-intent.js';
7
+ import { evidenceWriterEnabled, sealArtifactEvidence, } from './evidence.js';
8
+ import { evidenceMatchesAttempt, findReservationByAssignmentId, getReservation, } from './attempt-reservation.js';
9
+ import { resolveTurnGenerationChain, } from './attempt-generations.js';
10
+ import { evaluateCommandGreen, evaluateCriticSignal, evaluateGateCondition, evaluateNoNewCritique, } from './gate-policy.js';
7
11
  import { LoopArtifactSchema, PAUSE_REASONS, } from './types.js';
8
12
  import { decideNextPhase, } from './iteration-engine.js';
13
+ import { withLoopLock } from './lock.js';
9
14
  function nextSeq(loopId, cwd) {
10
15
  const events = listLoopEvents(loopId, cwd);
11
16
  return (events[events.length - 1]?.seq ?? 0) + 1;
@@ -33,12 +38,6 @@ function convergeSlotAssignmentsForClosedLoop(thread, finalStatus, cwd) {
33
38
  }
34
39
  }
35
40
  /* ========================= Stop-condition evaluator ======================= */
36
- function isVerdictAccepted(artifact) {
37
- if (artifact.type !== 'verdict')
38
- return false;
39
- const body = (artifact.body ?? '').trim().toLowerCase();
40
- return /^accepted(?:\b|[:\s])/.test(body);
41
- }
42
41
  /**
43
42
  * pln#639 BUG-1 — does this artifact carry anything a reader could USE?
44
43
  *
@@ -64,84 +63,22 @@ function hasUsableContent(artifact) {
64
63
  return true;
65
64
  return artifact.ref !== undefined;
66
65
  }
67
- export function evaluateStopCondition(thread, condition) {
68
- if (!condition)
69
- return false;
70
- switch (condition.kind) {
71
- case 'phase_reached':
72
- return thread.current_phase === condition.phase;
73
- case 'reviewer_green':
74
- return thread.artifacts.some(isVerdictAccepted);
75
- case 'max_iterations':
76
- return thread.iteration_count >= condition.n;
77
- case 'min_iterations':
78
- return thread.iteration_count >= condition.n;
79
- case 'artifact_produced':
80
- return thread.artifacts.some((artifact) => artifact.phase === condition.phase && artifact.type === condition.type);
81
- case 'min_artifacts_by_type': {
82
- // pln#492 — count artifacts of `type` in the requested scope.
83
- // Phase scope counts artifacts whose phase matches the thread's
84
- // current_phase. When the thread is iterating (iteration_count > 0
85
- // OR any artifact carries an iteration field), phase scope is
86
- // refined to the current iteration window — that's what makes
87
- // "≥3 critiques in current critique round" work without the
88
- // previous round leaking in. loop scope counts across all phases
89
- // and all iterations.
90
- const matches = thread.artifacts.filter((artifact) => {
91
- if (artifact.type !== condition.type)
92
- return false;
93
- // pln#639 BUG-1 — an artifact with no usable content never counts.
94
- if (!hasUsableContent(artifact))
95
- return false;
96
- if (condition.scope === 'phase') {
97
- if (artifact.phase !== thread.current_phase)
98
- return false;
99
- // pln#492 phase 2.b — iteration-window awareness. If either the
100
- // thread or the artifact carries iteration info, only count the
101
- // artifacts produced in the thread's current iteration. Legacy
102
- // loops without iteration tracking are unaffected (both fields
103
- // default to 0).
104
- if (thread.iteration_count > 0 ||
105
- thread.artifacts.some((a) => a.iteration !== undefined)) {
106
- const artifactIteration = artifact.iteration ?? 0;
107
- if (artifactIteration !== thread.iteration_count)
108
- return false;
109
- }
110
- return true;
111
- }
112
- return true;
113
- });
114
- return matches.length >= condition.n;
115
- }
116
- case 'no_open_questions':
117
- // pln#511 step 1 — bootstrap preset clarify-phase primitive. Mirrors
118
- // the persisted `thread.open_questions` set (kept in sync by
119
- // request_input / provide_input — see reconcileOpenQuestions).
120
- return thread.open_questions.length === 0;
121
- case 'manual':
122
- return false;
123
- case 'any':
124
- return condition.conditions.some((c) => evaluateStopCondition(thread, c));
125
- case 'all':
126
- return condition.conditions.every((c) => evaluateStopCondition(thread, c));
127
- default: {
128
- const exhaustive = condition;
129
- void exhaustive;
130
- return false;
131
- }
132
- }
66
+ export function evaluateStopCondition(thread, condition, cwd) {
67
+ return evaluateGateCondition(thread, condition, cwd).passed;
133
68
  }
134
- export function evaluatePhaseAdvanceGate(thread, gate) {
69
+ export function evaluatePhaseAdvanceGate(thread, gate, cwd) {
135
70
  if (!gate)
136
71
  return { advance: true };
137
- if (evaluateStopCondition(thread, gate))
138
- return { advance: true };
72
+ const gate_decision = evaluateGateCondition(thread, gate, cwd);
73
+ if (gate_decision.passed)
74
+ return { advance: true, gate_decision };
139
75
  return {
140
76
  advance: false,
141
- gate_reason: describeUnmetGate(thread, gate),
77
+ gate_reason: describeUnmetGate(thread, gate, gate_decision, cwd),
78
+ gate_decision,
142
79
  };
143
80
  }
144
- function describeUnmetGate(thread, gate) {
81
+ function describeUnmetGate(thread, gate, decision, cwd) {
145
82
  switch (gate.kind) {
146
83
  case 'min_artifacts_by_type': {
147
84
  // Mirror the iteration-aware filter used in evaluateStopCondition so
@@ -176,7 +113,13 @@ function describeUnmetGate(thread, gate) {
176
113
  const emptyNote = emptyOfType > 0
177
114
  ? ` (${emptyOfType} artifact(s) of this type carry no usable content and do not count)`
178
115
  : '';
179
- return `min_artifacts_by_type unmet: ${gate.scope}-scope count of type "${gate.type}" = ${matches.length} < n=${gate.n}${emptyNote}`;
116
+ const eligibleCount = decision && decision.mode !== 'legacy'
117
+ ? decision.accepted_evidence_ids.length
118
+ : matches.length;
119
+ const policyNote = decision && decision.rejected.length > 0
120
+ ? ` (${decision.rejected.length} rejected by evidence policy: ${[...new Set(decision.rejected.map((item) => item.reason))].join(', ')})`
121
+ : '';
122
+ return `min_artifacts_by_type unmet: ${gate.scope}-scope count of type "${gate.type}" = ${eligibleCount} < n=${gate.n}${emptyNote}${policyNote}`;
180
123
  }
181
124
  case 'phase_reached':
182
125
  return `phase_reached unmet: current_phase="${thread.current_phase}" expected="${gate.phase}"`;
@@ -199,9 +142,9 @@ function describeUnmetGate(thread, gate) {
199
142
  // one of N failed" tells the operator nothing actionable. Find the
200
143
  // first sub-condition that evaluates false and report its reason so
201
144
  // the journal records what is actually blocking the advance.
202
- const failing = gate.conditions.find((c) => !evaluateStopCondition(thread, c));
145
+ const failing = gate.conditions.find((c) => !evaluateStopCondition(thread, c, cwd));
203
146
  if (failing)
204
- return describeUnmetGate(thread, failing);
147
+ return describeUnmetGate(thread, failing, evaluateGateCondition(thread, failing, cwd), cwd);
205
148
  return `all-of unmet: at least one of ${gate.conditions.length} sub-conditions failed`;
206
149
  }
207
150
  default: {
@@ -236,10 +179,22 @@ export function advance(input, cwd) {
236
179
  // is forcing an explicit to_phase or passing { force: true }. On block,
237
180
  // emit a `phase_advance_blocked` system event into the journal and throw
238
181
  // an actionable error (not a silent hang — mitigates trp#160 wiring class).
239
- if (input.to_phase === undefined && input.force !== true) {
182
+ let phaseGateDecision;
183
+ let earlyIterationDecision;
184
+ if (input.to_phase === undefined &&
185
+ current.protocol?.iteration?.exit_when === 'no_new_critique_artifacts' &&
186
+ current.protocol.iteration.cycle[0] === current.current_phase &&
187
+ current.iteration_count > 0) {
188
+ const candidate = decideNextPhase(current, { phases: current.phases, iteration: current.protocol.iteration }, cwd);
189
+ if (candidate.kind === 'exit_cycle' && candidate.reason === 'no_new_critique_artifacts') {
190
+ earlyIterationDecision = candidate;
191
+ }
192
+ }
193
+ if (input.to_phase === undefined && input.force !== true && !earlyIterationDecision) {
240
194
  const currentPhaseDef = current.phases[currentIndex];
241
195
  const gate = currentPhaseDef?.advance_gate;
242
- const gateOutcome = evaluatePhaseAdvanceGate(current, gate);
196
+ const gateOutcome = evaluatePhaseAdvanceGate(current, gate, cwd);
197
+ phaseGateDecision = gateOutcome.gate_decision;
243
198
  if (!gateOutcome.advance) {
244
199
  const blockSeq = nextSeq(current.id, cwd);
245
200
  const blockMutation = generateMutationId();
@@ -253,6 +208,7 @@ export function advance(input, cwd) {
253
208
  kind: 'phase_advance_blocked',
254
209
  phase: current.current_phase,
255
210
  gate_reason: gateOutcome.gate_reason ?? 'gate evaluation returned no reason',
211
+ gate_decision: gateOutcome.gate_decision,
256
212
  }, cwd);
257
213
  throw new Error(`advance: phase_advance_blocked on "${current.current_phase}" — ${gateOutcome.gate_reason}`);
258
214
  }
@@ -264,11 +220,12 @@ export function advance(input, cwd) {
264
220
  // satisfied AT the last phase (the bootstrap preset's converge case after
265
221
  // project_md_final lands), the right behavior is auto-close, not throw.
266
222
  // Field-observed during pln#514 v1.1 validation (run_79f8443a).
267
- if (input.to_phase === undefined && evaluateStopCondition(current, current.stop_condition)) {
223
+ const preAdvanceStopDecision = evaluateGateCondition(current, current.stop_condition, cwd);
224
+ if (input.to_phase === undefined && preAdvanceStopDecision.passed) {
268
225
  const finalStatus = stopHitsMaxIterations(current, current.stop_condition)
269
226
  ? 'blocked'
270
227
  : 'completed';
271
- const closed = commitClosedTransition(current, finalStatus, input.actor, input.reason, cwd);
228
+ const closed = commitClosedTransition(current, finalStatus, input.actor, input.reason, cwd, preAdvanceStopDecision);
272
229
  return { loop: closed, auto_closed: true };
273
230
  }
274
231
  // Decide the next state. If the caller specified a to_phase, honour it
@@ -297,15 +254,23 @@ export function advance(input, cwd) {
297
254
  phases: current.phases,
298
255
  iteration: current.protocol?.iteration,
299
256
  };
300
- iterationDecision = decideNextPhase(current, protocol);
257
+ iterationDecision = earlyIterationDecision ?? decideNextPhase(current, protocol, cwd);
301
258
  to_phase = iterationDecision.target;
302
259
  iteration_count = iterationDecision.iteration;
260
+ if (!phaseGateDecision && iterationDecision.kind === 'exit_cycle') {
261
+ phaseGateDecision = iterationDecision.reason === 'command_green'
262
+ ? evaluateCommandGreen(current, current.iteration_count, cwd)
263
+ : iterationDecision.reason === 'critic_signal'
264
+ ? evaluateCriticSignal(current, current.iteration_count, cwd)
265
+ : evaluateNoNewCritique(current, current.iteration_count, cwd);
266
+ }
303
267
  }
304
- if (evaluateStopCondition(current, current.stop_condition)) {
268
+ const stopDecision = evaluateGateCondition(current, current.stop_condition, cwd);
269
+ if (stopDecision.passed) {
305
270
  const finalStatus = stopHitsMaxIterations(current, current.stop_condition)
306
271
  ? 'blocked'
307
272
  : 'completed';
308
- const closed = commitClosedTransition(current, finalStatus, input.actor, input.reason, cwd);
273
+ const closed = commitClosedTransition(current, finalStatus, input.actor, input.reason, cwd, stopDecision);
309
274
  return { loop: closed, auto_closed: true };
310
275
  }
311
276
  const now = nowISO();
@@ -350,14 +315,15 @@ export function advance(input, cwd) {
350
315
  to_phase,
351
316
  iteration: iteration_count,
352
317
  reason: input.reason,
318
+ gate_decision: phaseGateDecision,
353
319
  }, cwd);
354
320
  writeThreadFile(next, cwd);
355
- const postAdvance = evaluateStopCondition(next, next.stop_condition);
356
- if (postAdvance) {
321
+ const postAdvanceDecision = evaluateGateCondition(next, next.stop_condition, cwd);
322
+ if (postAdvanceDecision.passed) {
357
323
  const finalStatus = stopHitsMaxIterations(next, next.stop_condition)
358
324
  ? 'blocked'
359
325
  : 'completed';
360
- const closed = commitClosedTransition(next, finalStatus, input.actor, input.reason, cwd);
326
+ const closed = commitClosedTransition(next, finalStatus, input.actor, input.reason, cwd, postAdvanceDecision);
361
327
  return { loop: closed, auto_closed: true };
362
328
  }
363
329
  return { loop: next, auto_closed: false };
@@ -375,7 +341,7 @@ function stopHitsMaxIterations(thread, condition) {
375
341
  }
376
342
  return false;
377
343
  }
378
- function commitClosedTransition(thread, final_status, actor, reason, cwd) {
344
+ function commitClosedTransition(thread, final_status, actor, reason, cwd, gate_decision) {
379
345
  // pln#514 post-validation fix (can_27ebf1a0, run_79f8443a) — when the
380
346
  // auto-close path completes a bootstrap-preset loop, delegate to
381
347
  // closeLoop() so its writeProjectMdSafe pre-hook runs. Without this
@@ -419,6 +385,7 @@ function commitClosedTransition(thread, final_status, actor, reason, cwd) {
419
385
  kind: 'closed',
420
386
  final_status,
421
387
  reason,
388
+ gate_decision,
422
389
  }, cwd);
423
390
  writeThreadFile(next, cwd);
424
391
  convergeSlotAssignmentsForClosedLoop(next, final_status, cwd);
@@ -482,13 +449,156 @@ export function turn(input, cwd) {
482
449
  writeThreadFile(next, cwd);
483
450
  return next;
484
451
  }
485
- export function complete_turn(input, cwd) {
452
+ export class TurnProjectionConflictError extends Error {
453
+ loopId;
454
+ slotId;
455
+ constructor(loopId, slotId, detail) {
456
+ super(`Turn projection conflict for ${loopId}/${slotId}: ${detail}`);
457
+ this.loopId = loopId;
458
+ this.slotId = slotId;
459
+ this.name = 'TurnProjectionConflictError';
460
+ }
461
+ }
462
+ const ACTIVE_PROJECTED_SLOT_STATUSES = new Set([
463
+ 'assigned', 'working', 'waiting_input',
464
+ ]);
465
+ /**
466
+ * Idempotently bind the deterministic turn/assignment/claim tuple to a loop slot.
467
+ *
468
+ * This entry point owns the loop lock because the dispatch path is not already inside
469
+ * bclaw_loop's lock wrapper. The legacy `turn()` API deliberately keeps its existing
470
+ * semantics for callers that already hold that lock.
471
+ */
472
+ export function bindTurnProjection(input, cwd) {
473
+ return withLoopLock({
474
+ cwd,
475
+ intent: 'bind_turn_projection',
476
+ agentId: input.actor,
477
+ scope: { kind: 'loop', loopId: input.id },
478
+ work: () => {
479
+ const current = loadLoopOrThrow(input.id, cwd);
480
+ assertMutable(current, 'bind_turn_projection');
481
+ const slot = resolveTurnSlot(current, input);
482
+ const identical = slot.current_turn_id === input.turn_id
483
+ && slot.assignment_id === input.assignment_id
484
+ && slot.claim_id === input.claim_id;
485
+ if (identical)
486
+ return current;
487
+ if (slot.current_turn_id === input.turn_id) {
488
+ throw new TurnProjectionConflictError(input.id, input.slot_id, `turn ${input.turn_id} is already bound to assignment=${slot.assignment_id ?? 'none'} claim=${slot.claim_id ?? 'none'}`);
489
+ }
490
+ if (slot.current_turn_id !== undefined
491
+ && slot.current_turn_id !== input.turn_id
492
+ && ACTIVE_PROJECTED_SLOT_STATUSES.has(slot.status)) {
493
+ throw new TurnProjectionConflictError(input.id, input.slot_id, `active turn ${slot.current_turn_id} cannot be replaced by ${input.turn_id}`);
494
+ }
495
+ return turn(input, cwd);
496
+ },
497
+ });
498
+ }
499
+ const COMPLETE_TURN_FENCE_FIELDS = [
500
+ 'assignment_id',
501
+ 'turn_id',
502
+ 'run_id',
503
+ 'nonce',
504
+ 'attempt_epoch',
505
+ 'execution_contract_hash',
506
+ 'workspace_digest',
507
+ ];
508
+ function readCompleteTurnFence(source) {
509
+ const values = COMPLETE_TURN_FENCE_FIELDS.map((field) => source[field]);
510
+ const supplied = values.filter((value) => value !== undefined).length;
511
+ if (supplied !== COMPLETE_TURN_FENCE_FIELDS.length)
512
+ return { supplied };
513
+ return {
514
+ supplied,
515
+ fence: {
516
+ assignment_id: source.assignment_id,
517
+ turn_id: source.turn_id,
518
+ run_id: source.run_id,
519
+ nonce: source.nonce,
520
+ attempt_epoch: source.attempt_epoch,
521
+ execution_contract_hash: source.execution_contract_hash,
522
+ workspace_digest: source.workspace_digest,
523
+ },
524
+ };
525
+ }
526
+ function reservationForSlot(slot, cwd) {
527
+ if (slot.assignment_id) {
528
+ const byAssignment = findReservationByAssignmentId(slot.assignment_id, cwd);
529
+ if (byAssignment)
530
+ return byAssignment;
531
+ }
532
+ return slot.current_turn_id ? getReservation(slot.current_turn_id, cwd) : undefined;
533
+ }
534
+ /**
535
+ * Resolve and validate completion authority before any mutation is materialized.
536
+ * Legacy turns deliberately retain the old claim/agent authorization path. A
537
+ * v2 generation, however, is fail-closed and accepts only its latest full fence.
538
+ */
539
+ function authorizeCompleteTurnAttempt(input, slot, cwd) {
540
+ const direct = readCompleteTurnFence(input);
541
+ if (direct.supplied > 0 && !direct.fence) {
542
+ throw new Error('attempt_fence_incomplete: complete_turn requires the full generation fence');
543
+ }
544
+ const reservation = reservationForSlot(slot, cwd);
545
+ const turnId = reservation?.turn_id ?? slot.current_turn_id ?? direct.fence?.turn_id;
546
+ const authorityRoot = reservation?.store_root ?? cwd ?? process.cwd();
547
+ const chain = turnId ? resolveTurnGenerationChain(authorityRoot, turnId) : undefined;
548
+ // Explicit compatibility seam: no immutable v2 generation means the legacy
549
+ // claim/agent slot authorization remains authoritative.
550
+ if (!chain)
551
+ return undefined;
552
+ if (!reservation) {
553
+ throw new Error('attempt_fence_authority_missing: v2 generation has no committed reservation');
554
+ }
555
+ const internal = input.evidence_context
556
+ ? readCompleteTurnFence(input.evidence_context)
557
+ : { supplied: 0 };
558
+ if (internal.supplied > 0 && !internal.fence) {
559
+ throw new Error('attempt_fence_incomplete: trusted completion context lacks the full generation fence');
560
+ }
561
+ const submitted = direct.fence ?? internal.fence;
562
+ if (!submitted) {
563
+ throw new Error('attempt_fence_required: AttemptAuthority v2 complete_turn requires a full generation fence');
564
+ }
565
+ if (!evidenceMatchesAttempt(reservation, {
566
+ assignment_id: submitted.assignment_id,
567
+ turn_id: submitted.turn_id,
568
+ run_id: submitted.run_id,
569
+ nonce: submitted.nonce,
570
+ attempt_epoch: submitted.attempt_epoch,
571
+ contract_hash: submitted.execution_contract_hash,
572
+ workspace_digest: submitted.workspace_digest,
573
+ })) {
574
+ throw new Error('attempt_fence_stale: complete_turn fence does not match the current generation');
575
+ }
576
+ const generation = chain.latest_generation;
577
+ return {
578
+ channel: input.evidence_context?.channel ?? 'complete_turn',
579
+ producer_kind: 'slot',
580
+ producer_id: input.evidence_context?.producer_id ?? slot.slot_id,
581
+ agent_id: input.evidence_context?.agent_id ?? slot.agent_id,
582
+ slot_id: slot.slot_id,
583
+ slot_role: slot.role,
584
+ turn_id: generation.turn_id,
585
+ assignment_id: generation.assignment_id,
586
+ claim_id: reservation.claim_id ?? slot.claim_id,
587
+ run_id: generation.run_id,
588
+ nonce: generation.launch_nonce,
589
+ attempt_epoch: generation.attempt_epoch,
590
+ execution_contract_hash: generation.contract_hash,
591
+ workspace_digest: generation.workspace_digest,
592
+ };
593
+ }
594
+ function completeTurnCommit(input, cwd) {
486
595
  const current = loadLoopOrThrow(input.id, cwd);
487
596
  assertMutable(current, 'complete_turn');
488
597
  const slot = current.slots.find((s) => s.slot_id === input.slot_id);
489
598
  if (!slot)
490
599
  throw new Error(`complete_turn: slot_id "${input.slot_id}" not in loop`);
491
600
  // Slot-bound auth. Only enforced when caller_agent_id is supplied (MCP entry path).
601
+ let slotOwnerAuthorized = false;
492
602
  if (input.caller_agent_id !== undefined && !input.admin_override) {
493
603
  // Instance binding (pln#562 step 4): a claim-bound slot is owned by the
494
604
  // INSTANCE holding that claim. When both sides carry claim info, claim
@@ -501,20 +611,48 @@ export function complete_turn(input, cwd) {
501
611
  if (!ownerMatches && !creatorMatches) {
502
612
  throw new Error('unauthorized_slot_write');
503
613
  }
614
+ slotOwnerAuthorized = ownerMatches;
504
615
  }
616
+ // Must precede timestamps, mutation ids, artifact sealing, or any durable
617
+ // commit: a stale worker is observationally equivalent to a rejected read.
618
+ const authorizedAttemptEvidence = authorizeCompleteTurnAttempt(input, slot, cwd);
505
619
  const now = nowISO();
506
620
  const mutation_id = generateMutationId();
507
621
  const version = current.version + 1;
508
622
  const outcome = input.outcome ?? 'done';
509
623
  let nextArtifacts = current.artifacts;
510
624
  let artifactId;
625
+ let committedArtifact;
511
626
  if (input.artifact) {
512
- const newArtifact = LoopArtifactSchema.parse({
627
+ const evidenceContext = authorizedAttemptEvidence ?? input.evidence_context ?? (slotOwnerAuthorized
628
+ ? {
629
+ channel: 'complete_turn',
630
+ producer_kind: 'slot',
631
+ producer_id: slot.slot_id,
632
+ agent_id: slot.agent_id,
633
+ slot_id: slot.slot_id,
634
+ slot_role: slot.role,
635
+ turn_id: slot.current_turn_id,
636
+ assignment_id: slot.assignment_id,
637
+ claim_id: slot.claim_id,
638
+ }
639
+ : {
640
+ channel: 'complete_turn',
641
+ producer_kind: 'coordinator',
642
+ producer_id: input.actor,
643
+ });
644
+ const parsedArtifact = LoopArtifactSchema.parse({
513
645
  ...input.artifact,
514
646
  artifact_id: `art_${crypto.randomBytes(6).toString('hex')}`,
515
- produced_by: slot.slot_id,
647
+ produced_by: evidenceContext.producer_id,
516
648
  produced_at: now,
649
+ iteration: input.artifact.iteration ?? current.iteration_count,
650
+ evidence: undefined,
517
651
  });
652
+ const newArtifact = (current.evidence_policy !== undefined || evidenceWriterEnabled())
653
+ ? LoopArtifactSchema.parse(sealArtifactEvidence(current, parsedArtifact, evidenceContext))
654
+ : parsedArtifact;
655
+ committedArtifact = newArtifact;
518
656
  artifactId = newArtifact.artifact_id;
519
657
  nextArtifacts = [...nextArtifacts, newArtifact];
520
658
  }
@@ -522,6 +660,37 @@ export function complete_turn(input, cwd) {
522
660
  // distinguish done/failed/cancelled without replaying the event journal.
523
661
  const terminalStatus = outcome;
524
662
  const updatedSlots = current.slots.map((s) => s.slot_id === slot.slot_id ? { ...s, status: terminalStatus } : s);
663
+ // A negative convergence signal is only valid after the critic window has
664
+ // causally closed. The last trusted critic completion records that boundary;
665
+ // mere absence of critique artifacts while a turn is open is never enough.
666
+ let critiqueWindowClosedArtifact;
667
+ const critiqueSlots = updatedSlots.filter((candidate) => candidate.phase === 'critique');
668
+ const trustedCompletion = slotOwnerAuthorized || input.evidence_context !== undefined;
669
+ const critiqueWindowClosed = slot.phase === 'critique'
670
+ && trustedCompletion
671
+ && critiqueSlots.length > 0
672
+ && critiqueSlots.every((candidate) => candidate.status === 'done');
673
+ const alreadyClosed = nextArtifacts.some((artifact) => artifact.type === 'critique_window_closed'
674
+ && (artifact.iteration ?? 0) === current.iteration_count);
675
+ if (critiqueWindowClosed && !alreadyClosed) {
676
+ const marker = LoopArtifactSchema.parse({
677
+ artifact_id: `art_${crypto.randomBytes(6).toString('hex')}`,
678
+ phase: 'critique',
679
+ type: 'critique_window_closed',
680
+ body: JSON.stringify({ iteration: current.iteration_count }),
681
+ produced_by: 'brainclaw:critique-window',
682
+ produced_at: now,
683
+ iteration: current.iteration_count,
684
+ });
685
+ critiqueWindowClosedArtifact = (current.evidence_policy !== undefined || evidenceWriterEnabled())
686
+ ? LoopArtifactSchema.parse(sealArtifactEvidence(current, marker, {
687
+ channel: 'system_hook',
688
+ producer_kind: 'engine',
689
+ producer_id: 'brainclaw:critique-window',
690
+ }))
691
+ : marker;
692
+ nextArtifacts = [...nextArtifacts, critiqueWindowClosedArtifact];
693
+ }
525
694
  const next = {
526
695
  ...current,
527
696
  version,
@@ -543,7 +712,24 @@ export function complete_turn(input, cwd) {
543
712
  artifact_id: artifactId,
544
713
  phase: input.artifact.phase,
545
714
  type: input.artifact.type,
546
- produced_by: slot.slot_id,
715
+ produced_by: committedArtifact?.produced_by,
716
+ evidence_id: committedArtifact?.evidence?.evidence_id,
717
+ });
718
+ }
719
+ if (critiqueWindowClosedArtifact) {
720
+ events.push({
721
+ event_id: crypto.randomUUID(),
722
+ loop_id: current.id,
723
+ seq: nextSeq(current.id, cwd) + events.length,
724
+ at: now,
725
+ by: 'brainclaw:critique-window',
726
+ mutation_id,
727
+ kind: 'artifact_added',
728
+ artifact_id: critiqueWindowClosedArtifact.artifact_id,
729
+ phase: critiqueWindowClosedArtifact.phase,
730
+ type: critiqueWindowClosedArtifact.type,
731
+ produced_by: critiqueWindowClosedArtifact.produced_by,
732
+ evidence_id: critiqueWindowClosedArtifact.evidence?.evidence_id,
547
733
  });
548
734
  }
549
735
  events.push({
@@ -569,7 +755,15 @@ export function complete_turn(input, cwd) {
569
755
  commitViaIntent({ loop_id: current.id, base_version: current.version, events, thread_snapshot: next }, cwd);
570
756
  return next;
571
757
  }
572
- export function add_artifact(input, cwd) {
758
+ /** Public completion path: caller-controlled objects cannot inject engine provenance. */
759
+ export function complete_turn(input, cwd) {
760
+ return completeTurnCommit({ ...input, evidence_context: undefined }, cwd);
761
+ }
762
+ /** Internal convergence seam. Deliberately omitted from the public loops barrel. */
763
+ export function completeTurnWithEvidence(input, cwd) {
764
+ return completeTurnCommit(input, cwd);
765
+ }
766
+ function addArtifactCommit(input, cwd) {
573
767
  const current = loadLoopOrThrow(input.id, cwd);
574
768
  assertMutable(current, 'add_artifact');
575
769
  const now = nowISO();
@@ -581,12 +775,22 @@ export function add_artifact(input, cwd) {
581
775
  // accurate per-iteration counts without callers having to track the
582
776
  // index; non-iterating loops are unaffected because iteration_count
583
777
  // stays at 0.
584
- const newArtifact = LoopArtifactSchema.parse({
778
+ const evidenceContext = input.evidence_context ?? {
779
+ channel: 'add_artifact',
780
+ producer_kind: 'coordinator',
781
+ producer_id: input.actor,
782
+ };
783
+ const parsedArtifact = LoopArtifactSchema.parse({
585
784
  ...input.artifact,
586
785
  artifact_id: `art_${crypto.randomBytes(6).toString('hex')}`,
587
786
  produced_at: now,
787
+ produced_by: evidenceContext.producer_id,
588
788
  iteration: input.artifact.iteration ?? current.iteration_count,
789
+ evidence: undefined,
589
790
  });
791
+ const newArtifact = (current.evidence_policy !== undefined || evidenceWriterEnabled())
792
+ ? LoopArtifactSchema.parse(sealArtifactEvidence(current, parsedArtifact, evidenceContext))
793
+ : parsedArtifact;
590
794
  const next = {
591
795
  ...current,
592
796
  version,
@@ -606,10 +810,19 @@ export function add_artifact(input, cwd) {
606
810
  phase: newArtifact.phase,
607
811
  type: newArtifact.type,
608
812
  produced_by: newArtifact.produced_by,
813
+ evidence_id: newArtifact.evidence?.evidence_id,
609
814
  }, cwd);
610
815
  writeThreadFile(next, cwd);
611
816
  return next;
612
817
  }
818
+ /** Public audit-only artifact path; engine provenance is always discarded. */
819
+ export function add_artifact(input, cwd) {
820
+ return addArtifactCommit({ ...input, evidence_context: undefined }, cwd);
821
+ }
822
+ /** Internal engine/reconciler seam. Deliberately omitted from the public loops barrel. */
823
+ export function addArtifactWithEvidence(input, cwd) {
824
+ return addArtifactCommit(input, cwd);
825
+ }
613
826
  /**
614
827
  * pln#508 step 3 (Phase 0 spec §5, INVARIANT 2) — coerce a freeform `reason`
615
828
  * string into a structured PauseReason when it matches the known enum.
@@ -762,7 +975,7 @@ export function requestInput(input, cwd) {
762
975
  // type='operator_question' via KNOWN_ARTIFACT_BODY_SCHEMAS — so any
763
976
  // invariant violation (empty evidence, options size, on_timeout vs
764
977
  // suggested_default) surfaces here.
765
- const newArtifact = LoopArtifactSchema.parse({
978
+ const parsedArtifact = LoopArtifactSchema.parse({
766
979
  artifact_id: `art_${crypto.randomBytes(6).toString('hex')}`,
767
980
  phase: input.phase,
768
981
  type: 'operator_question',
@@ -771,6 +984,19 @@ export function requestInput(input, cwd) {
771
984
  produced_at: now,
772
985
  iteration: current.iteration_count,
773
986
  });
987
+ const newArtifact = (current.evidence_policy !== undefined || evidenceWriterEnabled())
988
+ ? LoopArtifactSchema.parse(sealArtifactEvidence(current, parsedArtifact, {
989
+ channel: 'operator_input',
990
+ producer_kind: 'slot',
991
+ producer_id: slot.agent_id ?? slot.agent ?? input.actor,
992
+ agent_id: slot.agent_id,
993
+ slot_id: slot.slot_id,
994
+ slot_role: slot.role,
995
+ turn_id: slot.current_turn_id,
996
+ assignment_id: slot.assignment_id,
997
+ claim_id: slot.claim_id,
998
+ }))
999
+ : parsedArtifact;
774
1000
  let nextStatus = current.status;
775
1001
  let nextPauseReason = current.pause_reason;
776
1002
  let nextSlots = current.slots;
@@ -922,7 +1148,7 @@ export function provideInput(input, cwd) {
922
1148
  const now = nowISO();
923
1149
  const mutation_id = generateMutationId();
924
1150
  const version = current.version + 1;
925
- const newArtifact = LoopArtifactSchema.parse({
1151
+ const parsedArtifact = LoopArtifactSchema.parse({
926
1152
  artifact_id: `art_${crypto.randomBytes(6).toString('hex')}`,
927
1153
  phase: sourceQuestion.phase,
928
1154
  type: 'operator_answer',
@@ -931,6 +1157,13 @@ export function provideInput(input, cwd) {
931
1157
  produced_at: now,
932
1158
  iteration: current.iteration_count,
933
1159
  });
1160
+ const newArtifact = (current.evidence_policy !== undefined || evidenceWriterEnabled())
1161
+ ? LoopArtifactSchema.parse(sealArtifactEvidence(current, parsedArtifact, {
1162
+ channel: 'operator_input',
1163
+ producer_kind: by === 'system' ? 'engine' : 'operator',
1164
+ producer_id: by === 'system' ? 'brainclaw:pause-timeout' : input.actor,
1165
+ }))
1166
+ : parsedArtifact;
934
1167
  const nextOpenQuestions = current.open_questions.filter((q) => q !== input.replies_to);
935
1168
  // Widen to LoopStatus — the assertMutable check above narrowed current.status
936
1169
  // to 'open' | 'paused', but the file_apply post-hook below can transition