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.
- package/README.md +13 -0
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-coordination.js +65 -1
- package/dist/commands/attempt-authority.js +80 -0
- package/dist/commands/harvest.js +140 -61
- package/dist/commands/loop.js +34 -0
- package/dist/commands/loops-handlers.js +87 -14
- package/dist/commands/mcp-catalog.js +42 -18
- package/dist/commands/mcp-schemas.generated.js +44 -0
- package/dist/commands/mcp-write-claims.js +128 -1
- package/dist/commands/mcp-write-coordination.js +146 -76
- package/dist/core/agent-capability.js +1 -1
- package/dist/core/agentrun-reconciler.js +148 -22
- package/dist/core/agentruns.js +254 -29
- package/dist/core/assignment-request-schema.js +7 -0
- package/dist/core/assignment-sweeper.js +5 -3
- package/dist/core/assignments.js +131 -33
- package/dist/core/claim-request-schema.js +7 -0
- package/dist/core/claims.js +53 -2
- package/dist/core/dispatch-status.js +16 -6
- package/dist/core/dispatcher.js +51 -51
- package/dist/core/entity-operations.js +20 -0
- package/dist/core/events.js +4 -0
- package/dist/core/execution-adapters.js +160 -14
- package/dist/core/execution-contract.js +345 -0
- package/dist/core/execution.js +130 -16
- package/dist/core/harness-adapters/base.js +150 -0
- package/dist/core/harness-adapters/claude.js +39 -0
- package/dist/core/harness-adapters/codex.js +57 -0
- package/dist/core/harness-adapters/harvest.js +109 -0
- package/dist/core/harness-adapters/index.js +8 -0
- package/dist/core/harness-adapters/prompt-only.js +13 -0
- package/dist/core/harness-adapters/registry.js +48 -0
- package/dist/core/harness-adapters/result.js +33 -0
- package/dist/core/harness-adapters/types.js +2 -0
- package/dist/core/ideation-loop-close.js +25 -2
- package/dist/core/instruction-templates.js +3 -2
- package/dist/core/loop-turn-dispatch.js +207 -0
- package/dist/core/loops/artifact-contract.js +11 -0
- package/dist/core/loops/attempt-authority.js +476 -0
- package/dist/core/loops/attempt-generations.js +509 -0
- package/dist/core/loops/attempt-reservation.js +197 -35
- package/dist/core/loops/attempt-rollout.js +404 -0
- package/dist/core/loops/attempt-takeover.js +155 -0
- package/dist/core/loops/bootstrap-acquire.js +7 -3
- package/dist/core/loops/evidence.js +187 -0
- package/dist/core/loops/facade-schema.js +41 -10
- package/dist/core/loops/gate-policy.js +485 -0
- package/dist/core/loops/impl-bind.js +37 -79
- package/dist/core/loops/index.js +9 -0
- package/dist/core/loops/iteration-engine.js +31 -19
- package/dist/core/loops/kind-policies.js +90 -0
- package/dist/core/loops/lock.js +71 -13
- package/dist/core/loops/reconcile-turn.js +235 -18
- package/dist/core/loops/result-reducers.js +99 -10
- package/dist/core/loops/store.js +30 -3
- package/dist/core/loops/turn-execution.js +480 -0
- package/dist/core/loops/types.js +113 -2
- package/dist/core/loops/verbs.js +332 -99
- package/dist/core/loops/verify-command.js +31 -8
- package/dist/core/loops/workspace-digest.js +54 -0
- package/dist/core/review-loop-close.js +25 -3
- package/dist/core/review-loop-turn-dispatch.js +210 -161
- package/dist/core/runtime-signals.js +62 -25
- package/dist/core/schema.js +35 -0
- package/dist/core/spawn-check.js +3 -2
- package/dist/core/upgrades/backup.js +27 -4
- package/dist/facts.js +7 -6
- package/dist/facts.json +6 -5
- package/docs/cli.md +49 -1
- package/docs/concepts/attempt-authority.md +407 -0
- package/docs/concepts/evidence-attestations.md +135 -0
- package/docs/concepts/execution-contract.md +166 -0
- package/docs/concepts/harness-adapters.md +166 -0
- package/docs/concepts/ideation-loop.md +5 -4
- package/docs/concepts/loop-engine.md +302 -113
- package/docs/index.md +4 -1
- package/docs/integrations/codex.md +3 -3
- package/docs/integrations/mcp.md +59 -5
- package/docs/loops/debug.md +144 -0
- package/docs/loops/ideation.md +158 -0
- package/docs/loops/implementation.md +154 -0
- package/docs/loops/research.md +136 -0
- package/docs/loops/review.md +200 -0
- package/docs/mcp-schema-changelog.md +14 -5
- package/package.json +1 -1
package/dist/core/loops/verbs.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
356
|
-
if (
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|