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
@@ -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();
@@ -318,6 +283,9 @@ export function advance(input, cwd) {
318
283
  mutation_id,
319
284
  current_phase: to_phase,
320
285
  iteration_count,
286
+ slots: input.slot_bindings
287
+ ? current.slots.map((slot) => ({ ...slot, ...(input.slot_bindings?.[slot.slot_id] ?? {}) }))
288
+ : current.slots,
321
289
  updated_at: now,
322
290
  };
323
291
  // pln#492 phase 2.b — when the iteration engine forces the cycle out
@@ -350,14 +318,15 @@ export function advance(input, cwd) {
350
318
  to_phase,
351
319
  iteration: iteration_count,
352
320
  reason: input.reason,
321
+ gate_decision: phaseGateDecision,
353
322
  }, cwd);
354
323
  writeThreadFile(next, cwd);
355
- const postAdvance = evaluateStopCondition(next, next.stop_condition);
356
- if (postAdvance) {
324
+ const postAdvanceDecision = evaluateGateCondition(next, next.stop_condition, cwd);
325
+ if (postAdvanceDecision.passed) {
357
326
  const finalStatus = stopHitsMaxIterations(next, next.stop_condition)
358
327
  ? 'blocked'
359
328
  : 'completed';
360
- const closed = commitClosedTransition(next, finalStatus, input.actor, input.reason, cwd);
329
+ const closed = commitClosedTransition(next, finalStatus, input.actor, input.reason, cwd, postAdvanceDecision);
361
330
  return { loop: closed, auto_closed: true };
362
331
  }
363
332
  return { loop: next, auto_closed: false };
@@ -375,7 +344,7 @@ function stopHitsMaxIterations(thread, condition) {
375
344
  }
376
345
  return false;
377
346
  }
378
- function commitClosedTransition(thread, final_status, actor, reason, cwd) {
347
+ function commitClosedTransition(thread, final_status, actor, reason, cwd, gate_decision) {
379
348
  // pln#514 post-validation fix (can_27ebf1a0, run_79f8443a) — when the
380
349
  // auto-close path completes a bootstrap-preset loop, delegate to
381
350
  // closeLoop() so its writeProjectMdSafe pre-hook runs. Without this
@@ -419,6 +388,7 @@ function commitClosedTransition(thread, final_status, actor, reason, cwd) {
419
388
  kind: 'closed',
420
389
  final_status,
421
390
  reason,
391
+ gate_decision,
422
392
  }, cwd);
423
393
  writeThreadFile(next, cwd);
424
394
  convergeSlotAssignmentsForClosedLoop(next, final_status, cwd);
@@ -482,13 +452,156 @@ export function turn(input, cwd) {
482
452
  writeThreadFile(next, cwd);
483
453
  return next;
484
454
  }
485
- export function complete_turn(input, cwd) {
455
+ export class TurnProjectionConflictError extends Error {
456
+ loopId;
457
+ slotId;
458
+ constructor(loopId, slotId, detail) {
459
+ super(`Turn projection conflict for ${loopId}/${slotId}: ${detail}`);
460
+ this.loopId = loopId;
461
+ this.slotId = slotId;
462
+ this.name = 'TurnProjectionConflictError';
463
+ }
464
+ }
465
+ const ACTIVE_PROJECTED_SLOT_STATUSES = new Set([
466
+ 'assigned', 'working', 'waiting_input',
467
+ ]);
468
+ /**
469
+ * Idempotently bind the deterministic turn/assignment/claim tuple to a loop slot.
470
+ *
471
+ * This entry point owns the loop lock because the dispatch path is not already inside
472
+ * bclaw_loop's lock wrapper. The legacy `turn()` API deliberately keeps its existing
473
+ * semantics for callers that already hold that lock.
474
+ */
475
+ export function bindTurnProjection(input, cwd) {
476
+ return withLoopLock({
477
+ cwd,
478
+ intent: 'bind_turn_projection',
479
+ agentId: input.actor,
480
+ scope: { kind: 'loop', loopId: input.id },
481
+ work: () => {
482
+ const current = loadLoopOrThrow(input.id, cwd);
483
+ assertMutable(current, 'bind_turn_projection');
484
+ const slot = resolveTurnSlot(current, input);
485
+ const identical = slot.current_turn_id === input.turn_id
486
+ && slot.assignment_id === input.assignment_id
487
+ && slot.claim_id === input.claim_id;
488
+ if (identical)
489
+ return current;
490
+ if (slot.current_turn_id === input.turn_id) {
491
+ 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'}`);
492
+ }
493
+ if (slot.current_turn_id !== undefined
494
+ && slot.current_turn_id !== input.turn_id
495
+ && ACTIVE_PROJECTED_SLOT_STATUSES.has(slot.status)) {
496
+ throw new TurnProjectionConflictError(input.id, input.slot_id, `active turn ${slot.current_turn_id} cannot be replaced by ${input.turn_id}`);
497
+ }
498
+ return turn(input, cwd);
499
+ },
500
+ });
501
+ }
502
+ const COMPLETE_TURN_FENCE_FIELDS = [
503
+ 'assignment_id',
504
+ 'turn_id',
505
+ 'run_id',
506
+ 'nonce',
507
+ 'attempt_epoch',
508
+ 'execution_contract_hash',
509
+ 'workspace_digest',
510
+ ];
511
+ function readCompleteTurnFence(source) {
512
+ const values = COMPLETE_TURN_FENCE_FIELDS.map((field) => source[field]);
513
+ const supplied = values.filter((value) => value !== undefined).length;
514
+ if (supplied !== COMPLETE_TURN_FENCE_FIELDS.length)
515
+ return { supplied };
516
+ return {
517
+ supplied,
518
+ fence: {
519
+ assignment_id: source.assignment_id,
520
+ turn_id: source.turn_id,
521
+ run_id: source.run_id,
522
+ nonce: source.nonce,
523
+ attempt_epoch: source.attempt_epoch,
524
+ execution_contract_hash: source.execution_contract_hash,
525
+ workspace_digest: source.workspace_digest,
526
+ },
527
+ };
528
+ }
529
+ function reservationForSlot(slot, cwd) {
530
+ if (slot.assignment_id) {
531
+ const byAssignment = findReservationByAssignmentId(slot.assignment_id, cwd);
532
+ if (byAssignment)
533
+ return byAssignment;
534
+ }
535
+ return slot.current_turn_id ? getReservation(slot.current_turn_id, cwd) : undefined;
536
+ }
537
+ /**
538
+ * Resolve and validate completion authority before any mutation is materialized.
539
+ * Legacy turns deliberately retain the old claim/agent authorization path. A
540
+ * v2 generation, however, is fail-closed and accepts only its latest full fence.
541
+ */
542
+ function authorizeCompleteTurnAttempt(input, slot, cwd) {
543
+ const direct = readCompleteTurnFence(input);
544
+ if (direct.supplied > 0 && !direct.fence) {
545
+ throw new Error('attempt_fence_incomplete: complete_turn requires the full generation fence');
546
+ }
547
+ const reservation = reservationForSlot(slot, cwd);
548
+ const turnId = reservation?.turn_id ?? slot.current_turn_id ?? direct.fence?.turn_id;
549
+ const authorityRoot = reservation?.store_root ?? cwd ?? process.cwd();
550
+ const chain = turnId ? resolveTurnGenerationChain(authorityRoot, turnId) : undefined;
551
+ // Explicit compatibility seam: no immutable v2 generation means the legacy
552
+ // claim/agent slot authorization remains authoritative.
553
+ if (!chain)
554
+ return undefined;
555
+ if (!reservation) {
556
+ throw new Error('attempt_fence_authority_missing: v2 generation has no committed reservation');
557
+ }
558
+ const internal = input.evidence_context
559
+ ? readCompleteTurnFence(input.evidence_context)
560
+ : { supplied: 0 };
561
+ if (internal.supplied > 0 && !internal.fence) {
562
+ throw new Error('attempt_fence_incomplete: trusted completion context lacks the full generation fence');
563
+ }
564
+ const submitted = direct.fence ?? internal.fence;
565
+ if (!submitted) {
566
+ throw new Error('attempt_fence_required: AttemptAuthority v2 complete_turn requires a full generation fence');
567
+ }
568
+ if (!evidenceMatchesAttempt(reservation, {
569
+ assignment_id: submitted.assignment_id,
570
+ turn_id: submitted.turn_id,
571
+ run_id: submitted.run_id,
572
+ nonce: submitted.nonce,
573
+ attempt_epoch: submitted.attempt_epoch,
574
+ contract_hash: submitted.execution_contract_hash,
575
+ workspace_digest: submitted.workspace_digest,
576
+ })) {
577
+ throw new Error('attempt_fence_stale: complete_turn fence does not match the current generation');
578
+ }
579
+ const generation = chain.latest_generation;
580
+ return {
581
+ channel: input.evidence_context?.channel ?? 'complete_turn',
582
+ producer_kind: 'slot',
583
+ producer_id: input.evidence_context?.producer_id ?? slot.slot_id,
584
+ agent_id: input.evidence_context?.agent_id ?? slot.agent_id,
585
+ slot_id: slot.slot_id,
586
+ slot_role: slot.role,
587
+ turn_id: generation.turn_id,
588
+ assignment_id: generation.assignment_id,
589
+ claim_id: reservation.claim_id ?? slot.claim_id,
590
+ run_id: generation.run_id,
591
+ nonce: generation.launch_nonce,
592
+ attempt_epoch: generation.attempt_epoch,
593
+ execution_contract_hash: generation.contract_hash,
594
+ workspace_digest: generation.workspace_digest,
595
+ };
596
+ }
597
+ function completeTurnCommit(input, cwd) {
486
598
  const current = loadLoopOrThrow(input.id, cwd);
487
599
  assertMutable(current, 'complete_turn');
488
600
  const slot = current.slots.find((s) => s.slot_id === input.slot_id);
489
601
  if (!slot)
490
602
  throw new Error(`complete_turn: slot_id "${input.slot_id}" not in loop`);
491
603
  // Slot-bound auth. Only enforced when caller_agent_id is supplied (MCP entry path).
604
+ let slotOwnerAuthorized = false;
492
605
  if (input.caller_agent_id !== undefined && !input.admin_override) {
493
606
  // Instance binding (pln#562 step 4): a claim-bound slot is owned by the
494
607
  // INSTANCE holding that claim. When both sides carry claim info, claim
@@ -501,20 +614,48 @@ export function complete_turn(input, cwd) {
501
614
  if (!ownerMatches && !creatorMatches) {
502
615
  throw new Error('unauthorized_slot_write');
503
616
  }
617
+ slotOwnerAuthorized = ownerMatches;
504
618
  }
619
+ // Must precede timestamps, mutation ids, artifact sealing, or any durable
620
+ // commit: a stale worker is observationally equivalent to a rejected read.
621
+ const authorizedAttemptEvidence = authorizeCompleteTurnAttempt(input, slot, cwd);
505
622
  const now = nowISO();
506
623
  const mutation_id = generateMutationId();
507
624
  const version = current.version + 1;
508
625
  const outcome = input.outcome ?? 'done';
509
626
  let nextArtifacts = current.artifacts;
510
627
  let artifactId;
628
+ let committedArtifact;
511
629
  if (input.artifact) {
512
- const newArtifact = LoopArtifactSchema.parse({
630
+ const evidenceContext = authorizedAttemptEvidence ?? input.evidence_context ?? (slotOwnerAuthorized
631
+ ? {
632
+ channel: 'complete_turn',
633
+ producer_kind: 'slot',
634
+ producer_id: slot.slot_id,
635
+ agent_id: slot.agent_id,
636
+ slot_id: slot.slot_id,
637
+ slot_role: slot.role,
638
+ turn_id: slot.current_turn_id,
639
+ assignment_id: slot.assignment_id,
640
+ claim_id: slot.claim_id,
641
+ }
642
+ : {
643
+ channel: 'complete_turn',
644
+ producer_kind: 'coordinator',
645
+ producer_id: input.actor,
646
+ });
647
+ const parsedArtifact = LoopArtifactSchema.parse({
513
648
  ...input.artifact,
514
649
  artifact_id: `art_${crypto.randomBytes(6).toString('hex')}`,
515
- produced_by: slot.slot_id,
650
+ produced_by: evidenceContext.producer_id,
516
651
  produced_at: now,
652
+ iteration: input.artifact.iteration ?? current.iteration_count,
653
+ evidence: undefined,
517
654
  });
655
+ const newArtifact = (current.evidence_policy !== undefined || evidenceWriterEnabled())
656
+ ? LoopArtifactSchema.parse(sealArtifactEvidence(current, parsedArtifact, evidenceContext))
657
+ : parsedArtifact;
658
+ committedArtifact = newArtifact;
518
659
  artifactId = newArtifact.artifact_id;
519
660
  nextArtifacts = [...nextArtifacts, newArtifact];
520
661
  }
@@ -522,6 +663,37 @@ export function complete_turn(input, cwd) {
522
663
  // distinguish done/failed/cancelled without replaying the event journal.
523
664
  const terminalStatus = outcome;
524
665
  const updatedSlots = current.slots.map((s) => s.slot_id === slot.slot_id ? { ...s, status: terminalStatus } : s);
666
+ // A negative convergence signal is only valid after the critic window has
667
+ // causally closed. The last trusted critic completion records that boundary;
668
+ // mere absence of critique artifacts while a turn is open is never enough.
669
+ let critiqueWindowClosedArtifact;
670
+ const critiqueSlots = updatedSlots.filter((candidate) => candidate.phase === 'critique');
671
+ const trustedCompletion = slotOwnerAuthorized || input.evidence_context !== undefined;
672
+ const critiqueWindowClosed = slot.phase === 'critique'
673
+ && trustedCompletion
674
+ && critiqueSlots.length > 0
675
+ && critiqueSlots.every((candidate) => candidate.status === 'done');
676
+ const alreadyClosed = nextArtifacts.some((artifact) => artifact.type === 'critique_window_closed'
677
+ && (artifact.iteration ?? 0) === current.iteration_count);
678
+ if (critiqueWindowClosed && !alreadyClosed) {
679
+ const marker = LoopArtifactSchema.parse({
680
+ artifact_id: `art_${crypto.randomBytes(6).toString('hex')}`,
681
+ phase: 'critique',
682
+ type: 'critique_window_closed',
683
+ body: JSON.stringify({ iteration: current.iteration_count }),
684
+ produced_by: 'brainclaw:critique-window',
685
+ produced_at: now,
686
+ iteration: current.iteration_count,
687
+ });
688
+ critiqueWindowClosedArtifact = (current.evidence_policy !== undefined || evidenceWriterEnabled())
689
+ ? LoopArtifactSchema.parse(sealArtifactEvidence(current, marker, {
690
+ channel: 'system_hook',
691
+ producer_kind: 'engine',
692
+ producer_id: 'brainclaw:critique-window',
693
+ }))
694
+ : marker;
695
+ nextArtifacts = [...nextArtifacts, critiqueWindowClosedArtifact];
696
+ }
525
697
  const next = {
526
698
  ...current,
527
699
  version,
@@ -543,7 +715,24 @@ export function complete_turn(input, cwd) {
543
715
  artifact_id: artifactId,
544
716
  phase: input.artifact.phase,
545
717
  type: input.artifact.type,
546
- produced_by: slot.slot_id,
718
+ produced_by: committedArtifact?.produced_by,
719
+ evidence_id: committedArtifact?.evidence?.evidence_id,
720
+ });
721
+ }
722
+ if (critiqueWindowClosedArtifact) {
723
+ events.push({
724
+ event_id: crypto.randomUUID(),
725
+ loop_id: current.id,
726
+ seq: nextSeq(current.id, cwd) + events.length,
727
+ at: now,
728
+ by: 'brainclaw:critique-window',
729
+ mutation_id,
730
+ kind: 'artifact_added',
731
+ artifact_id: critiqueWindowClosedArtifact.artifact_id,
732
+ phase: critiqueWindowClosedArtifact.phase,
733
+ type: critiqueWindowClosedArtifact.type,
734
+ produced_by: critiqueWindowClosedArtifact.produced_by,
735
+ evidence_id: critiqueWindowClosedArtifact.evidence?.evidence_id,
547
736
  });
548
737
  }
549
738
  events.push({
@@ -569,7 +758,15 @@ export function complete_turn(input, cwd) {
569
758
  commitViaIntent({ loop_id: current.id, base_version: current.version, events, thread_snapshot: next }, cwd);
570
759
  return next;
571
760
  }
572
- export function add_artifact(input, cwd) {
761
+ /** Public completion path: caller-controlled objects cannot inject engine provenance. */
762
+ export function complete_turn(input, cwd) {
763
+ return completeTurnCommit({ ...input, evidence_context: undefined }, cwd);
764
+ }
765
+ /** Internal convergence seam. Deliberately omitted from the public loops barrel. */
766
+ export function completeTurnWithEvidence(input, cwd) {
767
+ return completeTurnCommit(input, cwd);
768
+ }
769
+ function addArtifactCommit(input, cwd) {
573
770
  const current = loadLoopOrThrow(input.id, cwd);
574
771
  assertMutable(current, 'add_artifact');
575
772
  const now = nowISO();
@@ -581,12 +778,22 @@ export function add_artifact(input, cwd) {
581
778
  // accurate per-iteration counts without callers having to track the
582
779
  // index; non-iterating loops are unaffected because iteration_count
583
780
  // stays at 0.
584
- const newArtifact = LoopArtifactSchema.parse({
781
+ const evidenceContext = input.evidence_context ?? {
782
+ channel: 'add_artifact',
783
+ producer_kind: 'coordinator',
784
+ producer_id: input.actor,
785
+ };
786
+ const parsedArtifact = LoopArtifactSchema.parse({
585
787
  ...input.artifact,
586
788
  artifact_id: `art_${crypto.randomBytes(6).toString('hex')}`,
587
789
  produced_at: now,
790
+ produced_by: evidenceContext.producer_id,
588
791
  iteration: input.artifact.iteration ?? current.iteration_count,
792
+ evidence: undefined,
589
793
  });
794
+ const newArtifact = (current.evidence_policy !== undefined || evidenceWriterEnabled())
795
+ ? LoopArtifactSchema.parse(sealArtifactEvidence(current, parsedArtifact, evidenceContext))
796
+ : parsedArtifact;
590
797
  const next = {
591
798
  ...current,
592
799
  version,
@@ -606,10 +813,19 @@ export function add_artifact(input, cwd) {
606
813
  phase: newArtifact.phase,
607
814
  type: newArtifact.type,
608
815
  produced_by: newArtifact.produced_by,
816
+ evidence_id: newArtifact.evidence?.evidence_id,
609
817
  }, cwd);
610
818
  writeThreadFile(next, cwd);
611
819
  return next;
612
820
  }
821
+ /** Public audit-only artifact path; engine provenance is always discarded. */
822
+ export function add_artifact(input, cwd) {
823
+ return addArtifactCommit({ ...input, evidence_context: undefined }, cwd);
824
+ }
825
+ /** Internal engine/reconciler seam. Deliberately omitted from the public loops barrel. */
826
+ export function addArtifactWithEvidence(input, cwd) {
827
+ return addArtifactCommit(input, cwd);
828
+ }
613
829
  /**
614
830
  * pln#508 step 3 (Phase 0 spec §5, INVARIANT 2) — coerce a freeform `reason`
615
831
  * string into a structured PauseReason when it matches the known enum.
@@ -762,7 +978,7 @@ export function requestInput(input, cwd) {
762
978
  // type='operator_question' via KNOWN_ARTIFACT_BODY_SCHEMAS — so any
763
979
  // invariant violation (empty evidence, options size, on_timeout vs
764
980
  // suggested_default) surfaces here.
765
- const newArtifact = LoopArtifactSchema.parse({
981
+ const parsedArtifact = LoopArtifactSchema.parse({
766
982
  artifact_id: `art_${crypto.randomBytes(6).toString('hex')}`,
767
983
  phase: input.phase,
768
984
  type: 'operator_question',
@@ -771,6 +987,19 @@ export function requestInput(input, cwd) {
771
987
  produced_at: now,
772
988
  iteration: current.iteration_count,
773
989
  });
990
+ const newArtifact = (current.evidence_policy !== undefined || evidenceWriterEnabled())
991
+ ? LoopArtifactSchema.parse(sealArtifactEvidence(current, parsedArtifact, {
992
+ channel: 'operator_input',
993
+ producer_kind: 'slot',
994
+ producer_id: slot.agent_id ?? slot.agent ?? input.actor,
995
+ agent_id: slot.agent_id,
996
+ slot_id: slot.slot_id,
997
+ slot_role: slot.role,
998
+ turn_id: slot.current_turn_id,
999
+ assignment_id: slot.assignment_id,
1000
+ claim_id: slot.claim_id,
1001
+ }))
1002
+ : parsedArtifact;
774
1003
  let nextStatus = current.status;
775
1004
  let nextPauseReason = current.pause_reason;
776
1005
  let nextSlots = current.slots;
@@ -922,7 +1151,7 @@ export function provideInput(input, cwd) {
922
1151
  const now = nowISO();
923
1152
  const mutation_id = generateMutationId();
924
1153
  const version = current.version + 1;
925
- const newArtifact = LoopArtifactSchema.parse({
1154
+ const parsedArtifact = LoopArtifactSchema.parse({
926
1155
  artifact_id: `art_${crypto.randomBytes(6).toString('hex')}`,
927
1156
  phase: sourceQuestion.phase,
928
1157
  type: 'operator_answer',
@@ -931,6 +1160,13 @@ export function provideInput(input, cwd) {
931
1160
  produced_at: now,
932
1161
  iteration: current.iteration_count,
933
1162
  });
1163
+ const newArtifact = (current.evidence_policy !== undefined || evidenceWriterEnabled())
1164
+ ? LoopArtifactSchema.parse(sealArtifactEvidence(current, parsedArtifact, {
1165
+ channel: 'operator_input',
1166
+ producer_kind: by === 'system' ? 'engine' : 'operator',
1167
+ producer_id: by === 'system' ? 'brainclaw:pause-timeout' : input.actor,
1168
+ }))
1169
+ : parsedArtifact;
934
1170
  const nextOpenQuestions = current.open_questions.filter((q) => q !== input.replies_to);
935
1171
  // Widen to LoopStatus — the assertMutable check above narrowed current.status
936
1172
  // to 'open' | 'paused', but the file_apply post-hook below can transition