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.
- 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 +143 -15
- package/dist/commands/mcp-catalog.js +52 -18
- package/dist/commands/mcp-schemas.generated.js +64 -0
- package/dist/commands/mcp-write-claims.js +128 -1
- package/dist/commands/mcp-write-coordination.js +149 -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 +189 -14
- package/dist/core/execution-contract.js +345 -0
- package/dist/core/execution.js +130 -16
- package/dist/core/facade-schema.js +3 -0
- 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 +235 -0
- package/dist/core/loops/artifact-contract.js +11 -0
- package/dist/core/loops/attempt-authority.js +496 -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/brief-assembly.js +21 -4
- package/dist/core/loops/evidence.js +188 -0
- package/dist/core/loops/facade-schema.js +75 -11
- package/dist/core/loops/gate-policy.js +533 -0
- package/dist/core/loops/impl-bind.js +91 -81
- 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 +237 -18
- package/dist/core/loops/result-reducers.js +113 -10
- package/dist/core/loops/store.js +34 -3
- package/dist/core/loops/turn-execution.js +480 -0
- package/dist/core/loops/types.js +127 -3
- package/dist/core/loops/verbs.js +335 -99
- package/dist/core/loops/verify-command.js +105 -20
- 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 +40 -0
- package/dist/core/spawn-check.js +3 -2
- package/dist/core/upgrades/backup.js +27 -4
- package/dist/facts.js +9 -8
- package/dist/facts.json +8 -7
- 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 +174 -0
- package/docs/loops/research.md +136 -0
- package/docs/loops/review.md +200 -0
- package/docs/mcp-schema-changelog.md +18 -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();
|
|
@@ -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
|
|
356
|
-
if (
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|