brainclaw 1.27.0 → 1.28.1
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/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-coordination.js +12 -0
- package/dist/commands/loop.js +12 -0
- package/dist/commands/loops-handlers.js +261 -2
- package/dist/commands/mcp-catalog.js +16 -3
- package/dist/commands/mcp-schemas.generated.js +20 -0
- package/dist/commands/mcp-write-claims.js +55 -8
- package/dist/commands/mcp-write-coordination.js +3 -0
- package/dist/core/actions.js +17 -3
- package/dist/core/execution-adapters.js +29 -0
- package/dist/core/facade-schema.js +3 -0
- package/dist/core/loop-turn-dispatch.js +31 -3
- package/dist/core/loops/attempt-authority.js +20 -0
- package/dist/core/loops/brief-assembly.js +21 -4
- package/dist/core/loops/continuation.js +337 -0
- package/dist/core/loops/evidence.js +1 -0
- package/dist/core/loops/facade-schema.js +49 -1
- package/dist/core/loops/gate-policy.js +52 -4
- package/dist/core/loops/impl-bind.js +58 -6
- package/dist/core/loops/index.js +1 -0
- package/dist/core/loops/reconcile-turn.js +2 -0
- package/dist/core/loops/result-reducers.js +15 -1
- package/dist/core/loops/store.js +4 -0
- package/dist/core/loops/types.js +20 -1
- package/dist/core/loops/verbs.js +3 -0
- package/dist/core/loops/verify-command.js +77 -15
- package/dist/core/reviewer-policy.js +39 -0
- package/dist/core/schema.js +21 -1
- package/dist/facts.js +7 -7
- package/dist/facts.json +6 -6
- package/docs/cli.md +4 -2
- package/docs/concepts/loop-engine.md +25 -0
- package/docs/loops/implementation.md +20 -0
- package/docs/mcp-schema-changelog.md +10 -1
- package/package.json +1 -1
|
@@ -93,11 +93,14 @@ function hasUsableContent(artifact) {
|
|
|
93
93
|
return (artifact.body ?? '').trim().length > 0 || artifact.ref !== undefined;
|
|
94
94
|
}
|
|
95
95
|
function legacyEligibleCount(thread, artifacts, purpose, cwd) {
|
|
96
|
+
return legacyEligibleArtifacts(thread, artifacts, purpose, cwd).length;
|
|
97
|
+
}
|
|
98
|
+
function legacyEligibleArtifacts(thread, artifacts, purpose, cwd) {
|
|
96
99
|
// Kind-specialized purposes stay fail-closed even for persisted legacy
|
|
97
100
|
// loops. Legacy relaxes envelope presence; it does not invent an authority
|
|
98
101
|
// that the kind's policy explicitly forbids.
|
|
99
102
|
if (GATE_POLICIES[thread.kind].requirements[purpose].authorities.length === 0)
|
|
100
|
-
return
|
|
103
|
+
return [];
|
|
101
104
|
return artifacts.filter((artifact) => {
|
|
102
105
|
if (purpose !== 'critic_signal' && !hasUsableContent(artifact))
|
|
103
106
|
return false;
|
|
@@ -105,7 +108,7 @@ function legacyEligibleCount(thread, artifacts, purpose, cwd) {
|
|
|
105
108
|
return true;
|
|
106
109
|
return validateArtifactEvidence(thread, artifact).valid
|
|
107
110
|
&& reconciledV2AuthorityRejection(thread, artifact, cwd) === undefined;
|
|
108
|
-
})
|
|
111
|
+
});
|
|
109
112
|
}
|
|
110
113
|
function payloadFingerprint(artifact) {
|
|
111
114
|
return evidenceDigest({
|
|
@@ -425,7 +428,24 @@ export function evaluateGateCondition(thread, condition, cwd) {
|
|
|
425
428
|
case 'min_artifacts_by_type': {
|
|
426
429
|
const candidates = artifactCandidates(thread, condition);
|
|
427
430
|
const set = selectEligible(thread, candidates, 'artifact', cwd);
|
|
428
|
-
|
|
431
|
+
const requiredLanes = thread.kind === 'implementation' && condition.type === 'verify_report'
|
|
432
|
+
? [...new Set(thread.slots.map((slot) => slot.lane).filter((lane) => Boolean(lane)))]
|
|
433
|
+
: [];
|
|
434
|
+
const covers = (artifacts) => {
|
|
435
|
+
if (requiredLanes.length === 0)
|
|
436
|
+
return artifacts.length >= condition.n;
|
|
437
|
+
const reported = new Set(artifacts.flatMap((artifact) => {
|
|
438
|
+
try {
|
|
439
|
+
const lane = JSON.parse(artifact.body ?? '{}').lane;
|
|
440
|
+
return lane ? [lane] : [];
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return [];
|
|
444
|
+
}
|
|
445
|
+
}));
|
|
446
|
+
return requiredLanes.every((lane) => reported.has(lane));
|
|
447
|
+
};
|
|
448
|
+
return decision(thread, condition, covers(set.eligible), covers(legacyEligibleArtifacts(thread, candidates, 'artifact', cwd)), set);
|
|
429
449
|
}
|
|
430
450
|
case 'any': {
|
|
431
451
|
const children = condition.conditions.map((child) => evaluateGateCondition(thread, child, cwd));
|
|
@@ -460,7 +480,35 @@ export function evaluateCommandGreen(thread, iteration, cwd) {
|
|
|
460
480
|
}
|
|
461
481
|
});
|
|
462
482
|
const set = selectEligible(thread, candidates, 'command_green', cwd);
|
|
463
|
-
|
|
483
|
+
const requiredLanes = thread.kind === 'implementation'
|
|
484
|
+
? [...new Set(thread.slots.map((slot) => slot.lane).filter((lane) => Boolean(lane)))]
|
|
485
|
+
: [];
|
|
486
|
+
const greenLanes = new Set(set.eligible.flatMap((artifact) => {
|
|
487
|
+
try {
|
|
488
|
+
const lane = JSON.parse(artifact.body ?? '{}').lane;
|
|
489
|
+
return lane ? [lane] : [];
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return [];
|
|
493
|
+
}
|
|
494
|
+
}));
|
|
495
|
+
const allLanesGreen = requiredLanes.length === 0
|
|
496
|
+
? set.eligible.length > 0
|
|
497
|
+
: requiredLanes.every((lane) => greenLanes.has(lane));
|
|
498
|
+
const legacyCandidates = legacyEligibleArtifacts(thread, candidates, 'command_green', cwd);
|
|
499
|
+
const legacyGreenLanes = new Set(legacyCandidates.flatMap((artifact) => {
|
|
500
|
+
try {
|
|
501
|
+
const lane = JSON.parse(artifact.body ?? '{}').lane;
|
|
502
|
+
return lane ? [lane] : [];
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
return [];
|
|
506
|
+
}
|
|
507
|
+
}));
|
|
508
|
+
const legacyAllLanesGreen = requiredLanes.length === 0
|
|
509
|
+
? legacyCandidates.length > 0
|
|
510
|
+
: requiredLanes.every((lane) => legacyGreenLanes.has(lane));
|
|
511
|
+
return decision(thread, { kind: 'command_green', iteration }, allLanesGreen, legacyAllLanesGreen, set);
|
|
464
512
|
}
|
|
465
513
|
export function evaluateCriticSignal(thread, iteration, cwd) {
|
|
466
514
|
const candidates = thread.artifacts.filter((artifact) => artifact.type === 'critic_signal' && (artifact.iteration ?? 0) === iteration);
|
|
@@ -1,7 +1,53 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { loadSequence } from '../sequence.js';
|
|
2
|
+
import { loadState } from '../state.js';
|
|
2
3
|
import { withLoopLock } from './lock.js';
|
|
3
4
|
import { getLoop } from './store.js';
|
|
4
5
|
import { advance } from './verbs.js';
|
|
6
|
+
function deriveBindings(loop, sequenceId, cwd) {
|
|
7
|
+
if (!loop)
|
|
8
|
+
throw new Error('implementation loop disappeared during bind');
|
|
9
|
+
const sequence = loadSequence(sequenceId, cwd);
|
|
10
|
+
if (sequence.items.length === 0)
|
|
11
|
+
throw new Error(`linked sequence ${sequenceId} has no items`);
|
|
12
|
+
const linkedPlans = new Set(loop.linked?.plan_ids ?? []);
|
|
13
|
+
if (linkedPlans.size === 0) {
|
|
14
|
+
throw new Error(`impl-bind requires linked.plan_ids in addition to linked.sequence_ids`);
|
|
15
|
+
}
|
|
16
|
+
const plans = new Map(loadState(cwd).plan_items.map((plan) => [plan.id, plan]));
|
|
17
|
+
for (const item of sequence.items) {
|
|
18
|
+
if (!linkedPlans.has(item.planId)) {
|
|
19
|
+
throw new Error(`sequence item rank ${item.rank} references unlinked plan ${item.planId}`);
|
|
20
|
+
}
|
|
21
|
+
const plan = plans.get(item.planId);
|
|
22
|
+
if (!plan)
|
|
23
|
+
throw new Error(`linked sequence ${sequenceId} references missing plan ${item.planId}`);
|
|
24
|
+
if (item.stepId && !(plan.steps ?? []).some((step) => step.id === item.stepId)) {
|
|
25
|
+
throw new Error(`sequence item rank ${item.rank} references missing step ${item.stepId} on plan ${item.planId}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const grouped = new Map();
|
|
29
|
+
for (const item of sequence.items) {
|
|
30
|
+
const lane = item.lane?.trim() || 'default';
|
|
31
|
+
grouped.set(lane, [...(grouped.get(lane) ?? []), item]);
|
|
32
|
+
}
|
|
33
|
+
const lanes = [...grouped.keys()].sort();
|
|
34
|
+
if (loop.slots.length !== lanes.length) {
|
|
35
|
+
throw new Error(`impl-bind lane/slot mismatch: sequence ${sequenceId} has ${lanes.length} lane(s) (${lanes.join(', ')}) but loop has ${loop.slots.length} slot(s); open one worker slot per lane`);
|
|
36
|
+
}
|
|
37
|
+
const bindings = {};
|
|
38
|
+
loop.slots.forEach((slot, index) => {
|
|
39
|
+
const lane = lanes[index];
|
|
40
|
+
const items = grouped.get(lane);
|
|
41
|
+
const scopes = [...new Set(items.map((item) => item.scope_hint?.trim()).filter((value) => Boolean(value)))];
|
|
42
|
+
bindings[slot.slot_id] = {
|
|
43
|
+
lane,
|
|
44
|
+
scope_hint: scopes.length > 0 ? scopes.join(', ') : undefined,
|
|
45
|
+
plan_ids: [...new Set(items.map((item) => item.planId))],
|
|
46
|
+
step_ids: [...new Set(items.flatMap((item) => item.stepId ? [item.stepId] : []))],
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
return bindings;
|
|
50
|
+
}
|
|
5
51
|
const ENGINE_ONLY_WARNING = 'implementation bind is engine-only and does not dispatch workers; use bclaw_loop(intent="turn", dispatch=true, slot_id=...) in execute';
|
|
6
52
|
function compatibilityWarnings(input) {
|
|
7
53
|
const usedLaunchOption = input.lanes !== undefined
|
|
@@ -44,8 +90,12 @@ export async function runImplBind(input, cwd) {
|
|
|
44
90
|
if (!sequenceId) {
|
|
45
91
|
throw new Error(`impl-bind requires a linked sequence: open the implementation loop with linked.sequence_ids=[...] (the sequence whose lanes it executes). None found on ${loop_id}.`);
|
|
46
92
|
}
|
|
47
|
-
|
|
48
|
-
|
|
93
|
+
let bindings;
|
|
94
|
+
try {
|
|
95
|
+
bindings = deriveBindings(loop, sequenceId, cwd);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
throw new Error(`impl-bind validation failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
49
99
|
}
|
|
50
100
|
if (input.dryRun) {
|
|
51
101
|
return {
|
|
@@ -56,6 +106,7 @@ export async function runImplBind(input, cwd) {
|
|
|
56
106
|
messages_sent: 0,
|
|
57
107
|
warnings: compatibilityWarnings(input),
|
|
58
108
|
reason: `dry run: linked sequence ${sequenceId} is valid; loop stays in 'bind' and no worker is dispatched`,
|
|
109
|
+
lanes: Object.entries(bindings).map(([slot_id, binding]) => ({ slot_id, lane: binding.lane, scope_hint: binding.scope_hint })),
|
|
59
110
|
};
|
|
60
111
|
}
|
|
61
112
|
const advanced = withLoopLock({
|
|
@@ -68,11 +119,11 @@ export async function runImplBind(input, cwd) {
|
|
|
68
119
|
if (!fresh || fresh.status !== 'open' || fresh.current_phase !== 'bind')
|
|
69
120
|
return null;
|
|
70
121
|
const freshSequenceId = fresh.linked?.sequence_ids?.[0];
|
|
71
|
-
if (freshSequenceId !== sequenceId
|
|
72
|
-
|| !listSequences(cwd).some((sequence) => sequence.id === sequenceId)) {
|
|
122
|
+
if (freshSequenceId !== sequenceId) {
|
|
73
123
|
throw new Error(`linked sequence ${sequenceId} changed or disappeared before bind could advance`);
|
|
74
124
|
}
|
|
75
|
-
const
|
|
125
|
+
const freshBindings = deriveBindings(fresh, sequenceId, cwd);
|
|
126
|
+
const result = advance({ id: loop_id, actor: dispatcherAgent, slot_bindings: freshBindings }, cwd);
|
|
76
127
|
return { phase: result.loop.current_phase, auto_closed: result.auto_closed };
|
|
77
128
|
},
|
|
78
129
|
});
|
|
@@ -97,6 +148,7 @@ export async function runImplBind(input, cwd) {
|
|
|
97
148
|
messages_sent: 0,
|
|
98
149
|
warnings: compatibilityWarnings(input),
|
|
99
150
|
reason: `validated linked sequence ${sequenceId}; advanced bind -> ${advanced.phase}; dispatch worker slots with turn(dispatch=true)`,
|
|
151
|
+
lanes: Object.entries(bindings).map(([slot_id, binding]) => ({ slot_id, lane: binding.lane, scope_hint: binding.scope_hint })),
|
|
100
152
|
};
|
|
101
153
|
}
|
|
102
154
|
//# sourceMappingURL=impl-bind.js.map
|
package/dist/core/loops/index.js
CHANGED
|
@@ -21,4 +21,5 @@ export { deriveWorkerReplyContract, renderWorkerReplyProse, workerReplyNextActio
|
|
|
21
21
|
export { abortAttempt, inspectAttempt, matchEvidence, prepareAttempt, projectAndCross, revokeAttempt, } from './attempt-authority.js';
|
|
22
22
|
export { LOOP_KIND_POLICIES, assertLoopKindPoliciesComplete, isWorkerPhase, phasePolicy, policyForKind, } from './kind-policies.js';
|
|
23
23
|
export { ensureTurnExecutionProjections, prepareTurnExecution, } from './turn-execution.js';
|
|
24
|
+
export { CONTINUATION_POLICY_VERSION, ContinuationDecisionSchema, ContinuationRecordSchema, ContinuationStateSchema, attachContinuationActionRequired, denyContinuation, ensureContinuation, evaluateContinuation, listContinuations, loadContinuation, resumeApprovedContinuation, } from './continuation.js';
|
|
24
25
|
//# sourceMappingURL=index.js.map
|
|
@@ -385,6 +385,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
385
385
|
body: a.body,
|
|
386
386
|
produced_by: a.produced_by,
|
|
387
387
|
addresses_critique: a.addresses_critique,
|
|
388
|
+
implementation_verify: a.implementation_verify,
|
|
388
389
|
},
|
|
389
390
|
}, cwd);
|
|
390
391
|
}
|
|
@@ -418,6 +419,7 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
|
|
|
418
419
|
type: primary.type,
|
|
419
420
|
body: primary.body,
|
|
420
421
|
addresses_critique: primary.addresses_critique,
|
|
422
|
+
implementation_verify: primary.implementation_verify,
|
|
421
423
|
},
|
|
422
424
|
} : {}),
|
|
423
425
|
}, cwd);
|
|
@@ -85,8 +85,22 @@ export const ideationReducer = (input, attempt) => {
|
|
|
85
85
|
if (uniqueAddresses.length === 0) {
|
|
86
86
|
return { artifacts: [], slot_outcome: 'failed', failure_reason: 'ideation synthesis must cite critique artifact ids in lane.artifacts' };
|
|
87
87
|
}
|
|
88
|
+
if (!lane.implementation_verify) {
|
|
89
|
+
return {
|
|
90
|
+
artifacts: [],
|
|
91
|
+
slot_outcome: 'failed',
|
|
92
|
+
failure_reason: 'ideation synthesis must declare implementation_verify for deterministic downstream verification',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
88
95
|
return {
|
|
89
|
-
artifacts: [{
|
|
96
|
+
artifacts: [{
|
|
97
|
+
phase,
|
|
98
|
+
type: artifactType,
|
|
99
|
+
body: capBody(body),
|
|
100
|
+
produced_by: attempt.agent,
|
|
101
|
+
addresses_critique: uniqueAddresses,
|
|
102
|
+
implementation_verify: lane.implementation_verify,
|
|
103
|
+
}],
|
|
90
104
|
slot_outcome: 'done',
|
|
91
105
|
};
|
|
92
106
|
}
|
package/dist/core/loops/store.js
CHANGED
|
@@ -71,6 +71,10 @@ function buildSlot(partial) {
|
|
|
71
71
|
assignment_id: partial.assignment_id,
|
|
72
72
|
claim_id: partial.claim_id,
|
|
73
73
|
phase: partial.phase,
|
|
74
|
+
lane: partial.lane,
|
|
75
|
+
scope_hint: partial.scope_hint,
|
|
76
|
+
plan_ids: partial.plan_ids,
|
|
77
|
+
step_ids: partial.step_ids,
|
|
74
78
|
status: partial.status ?? 'open',
|
|
75
79
|
};
|
|
76
80
|
}
|
package/dist/core/loops/types.js
CHANGED
|
@@ -15,7 +15,7 @@ export const REVIEW_MODES = ['asymmetric', 'symmetric'];
|
|
|
15
15
|
*/
|
|
16
16
|
export const SLOT_STATUSES = ['open', 'assigned', 'working', 'waiting_input', 'done', 'failed', 'cancelled'];
|
|
17
17
|
export const TERMINAL_SLOT_STATUSES = ['done', 'failed', 'cancelled'];
|
|
18
|
-
export const LOOP_REF_KINDS = ['plan', 'sequence', 'claim', 'handoff', 'candidate', 'message'];
|
|
18
|
+
export const LOOP_REF_KINDS = ['plan', 'sequence', 'claim', 'handoff', 'candidate', 'message', 'commit', 'branch'];
|
|
19
19
|
export const LoopRefSchema = z.object({
|
|
20
20
|
kind: z.enum(LOOP_REF_KINDS),
|
|
21
21
|
id: z.string().min(1),
|
|
@@ -23,6 +23,14 @@ export const LoopRefSchema = z.object({
|
|
|
23
23
|
export const LoopLinksSchema = z.object({
|
|
24
24
|
plan_ids: z.array(z.string().min(1)).optional(),
|
|
25
25
|
sequence_ids: z.array(z.string().min(1)).optional(),
|
|
26
|
+
/** Upstream loop in an ideation → implementation → review pipeline. */
|
|
27
|
+
source_loop_id: z.string().regex(/^lop_[0-9a-z]+$/).optional(),
|
|
28
|
+
/** Exact upstream artifact that authorized this continuation. */
|
|
29
|
+
source_artifact_id: z.string().regex(/^art_[0-9a-z]+$/).optional(),
|
|
30
|
+
/** Sealed digest of source_artifact_id at continuation evaluation time. */
|
|
31
|
+
source_artifact_digest: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
|
32
|
+
/** Durable, deterministic identity of the policy decision that created this loop. */
|
|
33
|
+
continuation_key: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
|
26
34
|
});
|
|
27
35
|
/**
|
|
28
36
|
* Memory categories a loop phase can request via `context_filter` (pln#492).
|
|
@@ -155,6 +163,13 @@ export const LoopSlotSchema = z.object({
|
|
|
155
163
|
assignment_id: z.string().optional(),
|
|
156
164
|
claim_id: z.string().optional(),
|
|
157
165
|
phase: z.string().optional(),
|
|
166
|
+
/** Implementation-loop lane bound from the linked sequence at bind time. */
|
|
167
|
+
lane: z.string().optional(),
|
|
168
|
+
/** File/path scope carried by the bound sequence lane. */
|
|
169
|
+
scope_hint: z.string().optional(),
|
|
170
|
+
/** Plans and steps executed by this lane (derived, never worker-authored). */
|
|
171
|
+
plan_ids: z.array(z.string().min(1)).optional(),
|
|
172
|
+
step_ids: z.array(z.string().min(1)).optional(),
|
|
158
173
|
status: z.enum(SLOT_STATUSES),
|
|
159
174
|
/**
|
|
160
175
|
* pln#630 PR2b-a (§13 R1) — pointer to the immutable turn-attempt record for
|
|
@@ -418,6 +433,8 @@ export const VerifyReportBodySchema = z.object({
|
|
|
418
433
|
command_digest: z.string().regex(/^[0-9a-f]{64}$/).optional(),
|
|
419
434
|
workspace_digest: z.string().regex(/^[0-9a-f]{64}$/).optional(),
|
|
420
435
|
workspace_stable: z.boolean().optional(),
|
|
436
|
+
/** Implementation lane whose worktree was verified. */
|
|
437
|
+
lane: z.string().optional(),
|
|
421
438
|
});
|
|
422
439
|
export const KNOWN_ARTIFACT_BODY_SCHEMAS = {
|
|
423
440
|
// inline JSON body: body = JSON.stringify({ ...fields per OperatorQuestionBodySchema })
|
|
@@ -457,6 +474,8 @@ export const LoopArtifactSchema = z
|
|
|
457
474
|
* critique artifact) is deferred to v1.1 per the plan.
|
|
458
475
|
*/
|
|
459
476
|
addresses_critique: z.array(z.string().min(1)).optional(),
|
|
477
|
+
/** Executable acceptance command carried from synthesis into implementation. */
|
|
478
|
+
implementation_verify: LoopVerifyConfigSchema.optional(),
|
|
460
479
|
/**
|
|
461
480
|
* pln#492 phase 2.b — iteration window the artifact was produced in.
|
|
462
481
|
* 0-indexed (proposal/early phases produce iteration=0). Optional for
|
package/dist/core/loops/verbs.js
CHANGED
|
@@ -283,6 +283,9 @@ export function advance(input, cwd) {
|
|
|
283
283
|
mutation_id,
|
|
284
284
|
current_phase: to_phase,
|
|
285
285
|
iteration_count,
|
|
286
|
+
slots: input.slot_bindings
|
|
287
|
+
? current.slots.map((slot) => ({ ...slot, ...(input.slot_bindings?.[slot.slot_id] ?? {}) }))
|
|
288
|
+
: current.slots,
|
|
286
289
|
updated_at: now,
|
|
287
290
|
};
|
|
288
291
|
// pln#492 phase 2.b — when the iteration engine forces the cycle out
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { spawnSync } from 'node:child_process';
|
|
26
26
|
import path from 'node:path';
|
|
27
|
+
import { loadAssignment } from '../assignments.js';
|
|
27
28
|
import { getLoop } from './store.js';
|
|
28
29
|
import { withLoopLock } from './lock.js';
|
|
29
30
|
import { addArtifactWithEvidence } from './verbs.js';
|
|
@@ -31,6 +32,8 @@ import { artifactsInIteration } from './iteration-engine.js';
|
|
|
31
32
|
import { evidenceDigest } from './evidence.js';
|
|
32
33
|
import { eligibleArtifactsForPurpose } from './gate-policy.js';
|
|
33
34
|
import { captureWorkspaceDigest } from './workspace-digest.js';
|
|
35
|
+
import { findReservationByAssignmentId } from './attempt-reservation.js';
|
|
36
|
+
import { resolveTurnGenerationChain } from './attempt-generations.js';
|
|
34
37
|
import { VERIFY_DEFAULT_TIMEOUT_MS, LOOP_ARTIFACT_BODY_MAX_BYTES, } from './types.js';
|
|
35
38
|
/** VerifyReportBodySchema caps stdout_tail/stderr_tail at 1024. */
|
|
36
39
|
const TAIL_MAX = 1024;
|
|
@@ -51,7 +54,15 @@ export const defaultVerifyRunner = (config) => {
|
|
|
51
54
|
delete env[k];
|
|
52
55
|
}
|
|
53
56
|
const started = Date.now();
|
|
54
|
-
const
|
|
57
|
+
const requestedExecutable = config.command[0];
|
|
58
|
+
const npmCli = process.platform === 'win32' && (requestedExecutable === 'npm' || requestedExecutable === 'npx')
|
|
59
|
+
? path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', `${requestedExecutable}-cli.js`)
|
|
60
|
+
: undefined;
|
|
61
|
+
// Node cannot spawn .cmd shims with shell:false on Windows. Invoke npm's JS
|
|
62
|
+
// entrypoint through the current Node binary so the no-shell security contract holds.
|
|
63
|
+
const executable = npmCli ? process.execPath : requestedExecutable;
|
|
64
|
+
const commandArgs = npmCli ? [npmCli, ...config.command.slice(1)] : config.command.slice(1);
|
|
65
|
+
const r = spawnSync(executable, commandArgs, {
|
|
55
66
|
cwd: config.cwd,
|
|
56
67
|
env,
|
|
57
68
|
shell: false,
|
|
@@ -79,19 +90,52 @@ export const defaultVerifyRunner = (config) => {
|
|
|
79
90
|
};
|
|
80
91
|
};
|
|
81
92
|
/**
|
|
82
|
-
* Resolve the verify command for a loop.
|
|
83
|
-
*
|
|
84
|
-
* when the loop opted out (no `protocol.verify`).
|
|
93
|
+
* Resolve the verify command for a loop. Bound implementation lanes run only
|
|
94
|
+
* in their assignment worktree; legacy/unbound loops retain the project cwd.
|
|
95
|
+
* Returns `unconfigured` when the loop opted out (no `protocol.verify`).
|
|
85
96
|
*/
|
|
86
|
-
|
|
97
|
+
function assignmentWorktree(assignmentId, cwd) {
|
|
98
|
+
const assignment = loadAssignment(assignmentId, cwd);
|
|
99
|
+
const reservation = findReservationByAssignmentId(assignmentId, cwd);
|
|
100
|
+
const generation = reservation
|
|
101
|
+
? resolveTurnGenerationChain(cwd ?? reservation.store_root, reservation.turn_id)?.latest_generation
|
|
102
|
+
: undefined;
|
|
103
|
+
return generation?.workspace_path ?? assignment?.worktree_path;
|
|
104
|
+
}
|
|
105
|
+
export function resolveVerifyCommand(thread, cwd, slotId) {
|
|
87
106
|
const cfg = thread.protocol?.verify;
|
|
88
107
|
if (!cfg)
|
|
89
108
|
return { kind: 'unconfigured' };
|
|
109
|
+
let verifyCwd = path.resolve(cwd ?? process.cwd());
|
|
110
|
+
if (thread.kind === 'implementation') {
|
|
111
|
+
const selected = slotId ? thread.slots.find((slot) => slot.slot_id === slotId) : undefined;
|
|
112
|
+
if (slotId && !selected)
|
|
113
|
+
throw new Error(`verify: slot ${slotId} not found on loop ${thread.id}`);
|
|
114
|
+
const candidates = (selected ? [selected] : thread.slots)
|
|
115
|
+
.filter((slot) => slot.assignment_id)
|
|
116
|
+
.map((slot) => ({ slot, worktree_path: assignmentWorktree(slot.assignment_id, cwd) }))
|
|
117
|
+
.filter((entry) => entry.worktree_path);
|
|
118
|
+
if (!selected && candidates.length > 1) {
|
|
119
|
+
throw new Error(`verify: implementation loop ${thread.id} has multiple bound worktrees; pass slot_id to verify one lane deterministically`);
|
|
120
|
+
}
|
|
121
|
+
if (!selected && thread.slots.some((slot) => slot.lane) && candidates.length === 0) {
|
|
122
|
+
throw new Error(`verify: implementation loop ${thread.id} has bound lanes but no assignment worktree; dispatch and settle the execute turn first`);
|
|
123
|
+
}
|
|
124
|
+
if (selected?.lane && !selected.assignment_id) {
|
|
125
|
+
throw new Error(`verify: slot ${selected.slot_id} is bound to lane ${selected.lane} but has no assignment worktree; dispatch and settle the execute turn first`);
|
|
126
|
+
}
|
|
127
|
+
const candidate = candidates[0];
|
|
128
|
+
if (selected?.assignment_id && !candidate?.worktree_path) {
|
|
129
|
+
throw new Error(`verify: slot ${selected.slot_id} assignment ${selected.assignment_id} has no worktree_path`);
|
|
130
|
+
}
|
|
131
|
+
if (candidate?.worktree_path)
|
|
132
|
+
verifyCwd = path.resolve(candidate.worktree_path);
|
|
133
|
+
}
|
|
90
134
|
return {
|
|
91
135
|
kind: 'ok',
|
|
92
136
|
config: {
|
|
93
137
|
command: cfg.command,
|
|
94
|
-
cwd:
|
|
138
|
+
cwd: verifyCwd,
|
|
95
139
|
timeout_ms: cfg.timeout_ms ?? VERIFY_DEFAULT_TIMEOUT_MS,
|
|
96
140
|
},
|
|
97
141
|
};
|
|
@@ -133,8 +177,19 @@ export function buildVerifyReportBody(config, result, bindings) {
|
|
|
133
177
|
});
|
|
134
178
|
}
|
|
135
179
|
/** True when an authoritative, still-fresh engine report exists for this iteration. */
|
|
136
|
-
function hasVerifyReportForIteration(thread, iteration) {
|
|
137
|
-
const reports = artifactsInIteration(thread, iteration).filter((artifact) =>
|
|
180
|
+
function hasVerifyReportForIteration(thread, iteration, lane) {
|
|
181
|
+
const reports = artifactsInIteration(thread, iteration).filter((artifact) => {
|
|
182
|
+
if (artifact.type !== 'verify_report')
|
|
183
|
+
return false;
|
|
184
|
+
if (!lane)
|
|
185
|
+
return true;
|
|
186
|
+
try {
|
|
187
|
+
return JSON.parse(artifact.body ?? '{}').lane === lane;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
138
193
|
return eligibleArtifactsForPurpose(thread, reports, 'command_green').eligible.length > 0;
|
|
139
194
|
}
|
|
140
195
|
/**
|
|
@@ -158,16 +213,23 @@ export function runVerify(input, cwd) {
|
|
|
158
213
|
const thread = getLoop(input.loop_id, cwd);
|
|
159
214
|
if (!thread)
|
|
160
215
|
throw new Error(`loop ${input.loop_id} not found`);
|
|
161
|
-
const
|
|
216
|
+
const inferredSlots = thread.kind === 'implementation' && !input.slot_id
|
|
217
|
+
? thread.slots.filter((slot) => slot.assignment_id && assignmentWorktree(slot.assignment_id, cwd))
|
|
218
|
+
: [];
|
|
219
|
+
const selectedSlot = input.slot_id
|
|
220
|
+
? thread.slots.find((slot) => slot.slot_id === input.slot_id)
|
|
221
|
+
: inferredSlots.length === 1 ? inferredSlots[0] : undefined;
|
|
222
|
+
const lane = selectedSlot?.lane;
|
|
223
|
+
const resolved = resolveVerifyCommand(thread, cwd, selectedSlot?.slot_id ?? input.slot_id);
|
|
162
224
|
if (resolved.kind === 'unconfigured')
|
|
163
225
|
return { state: 'unconfigured', thread };
|
|
164
226
|
const iteration = thread.iteration_count;
|
|
165
|
-
if (hasVerifyReportForIteration(thread, iteration))
|
|
227
|
+
if (hasVerifyReportForIteration(thread, iteration, lane))
|
|
166
228
|
return { state: 'deduped', thread };
|
|
167
229
|
// Snapshot the iteration + phase we are about to verify. The command tests THIS
|
|
168
230
|
// iteration's working tree; the report must be attributed to it even if a
|
|
169
231
|
// concurrent advance bumps the loop's iteration while we spawn (review F1).
|
|
170
|
-
return { state: 'run', thread, config: resolved.config, iteration, phase: thread.current_phase };
|
|
232
|
+
return { state: 'run', thread, config: resolved.config, iteration, phase: thread.current_phase, lane };
|
|
171
233
|
},
|
|
172
234
|
});
|
|
173
235
|
if (snapshot.state === 'unconfigured')
|
|
@@ -175,13 +237,13 @@ export function runVerify(input, cwd) {
|
|
|
175
237
|
if (snapshot.state === 'deduped')
|
|
176
238
|
return { thread: snapshot.thread, deduped: true };
|
|
177
239
|
// --- OUT OF LOCK: run the command (may take minutes). ---
|
|
178
|
-
const { config, iteration, phase } = snapshot;
|
|
240
|
+
const { config, iteration, phase, lane } = snapshot;
|
|
179
241
|
const command_digest = evidenceDigest({ command: config.command });
|
|
180
242
|
const workspaceBefore = captureWorkspaceDigest(config.cwd);
|
|
181
243
|
const runResult = runner(config);
|
|
182
244
|
const workspaceAfter = captureWorkspaceDigest(config.cwd);
|
|
183
245
|
const workspace_stable = workspaceBefore === workspaceAfter;
|
|
184
|
-
const reportAfterRun = buildVerifyReportBody(config, { ...runResult, passed: runResult.passed && workspace_stable }, { command_digest, workspace_digest: workspaceAfter, workspace_stable });
|
|
246
|
+
const reportAfterRun = buildVerifyReportBody(config, { ...runResult, passed: runResult.passed && workspace_stable }, { command_digest, workspace_digest: workspaceAfter, workspace_stable, lane });
|
|
185
247
|
// --- Lock scope 2: re-check idempotency (by SNAPSHOT iteration), then append. ---
|
|
186
248
|
return withLoopLock({
|
|
187
249
|
cwd,
|
|
@@ -195,7 +257,7 @@ export function runVerify(input, cwd) {
|
|
|
195
257
|
// Dedup on the SNAPSHOT iteration — a report for the iteration we verified already
|
|
196
258
|
// landed (a concurrent verify won). Checking the snapshot (not the current)
|
|
197
259
|
// iteration is what makes this correct after a concurrent advance (review F1).
|
|
198
|
-
if (hasVerifyReportForIteration(thread, iteration)) {
|
|
260
|
+
if (hasVerifyReportForIteration(thread, iteration, lane)) {
|
|
199
261
|
return { thread, report: reportAfterRun, deduped: true };
|
|
200
262
|
}
|
|
201
263
|
// Close the final out-of-lock race: the bytes verified above must still be the
|
|
@@ -203,7 +265,7 @@ export function runVerify(input, cwd) {
|
|
|
203
265
|
// repeats this freshness check so a post-commit mutation also fails closed.
|
|
204
266
|
const workspaceAtCommit = captureWorkspaceDigest(config.cwd);
|
|
205
267
|
const commitStable = workspace_stable && workspaceAtCommit === workspaceAfter;
|
|
206
|
-
const report = buildVerifyReportBody(config, { ...runResult, passed: runResult.passed && commitStable }, { command_digest, workspace_digest: workspaceAtCommit, workspace_stable: commitStable });
|
|
268
|
+
const report = buildVerifyReportBody(config, { ...runResult, passed: runResult.passed && commitStable }, { command_digest, workspace_digest: workspaceAtCommit, workspace_stable: commitStable, lane });
|
|
207
269
|
const updated = addArtifactWithEvidence({
|
|
208
270
|
id: input.loop_id,
|
|
209
271
|
actor: input.actor,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { listAgentIdentities } from './agent-registry.js';
|
|
2
|
+
import { resolveExecutionCandidate } from './execution-contract.js';
|
|
3
|
+
export const REVIEWER_SELECTION_POLICY_VERSION = 'reviewer-selection-v1';
|
|
4
|
+
/**
|
|
5
|
+
* Select a concrete review worker from project-registered identities.
|
|
6
|
+
*
|
|
7
|
+
* The shared execution-contract resolver supplies capability checks and stable
|
|
8
|
+
* ordering. The policy additionally enforces reviewer independence by
|
|
9
|
+
* excluding every identity frozen onto an implementation slot.
|
|
10
|
+
*/
|
|
11
|
+
export function selectImplementationReviewer(source, cwd) {
|
|
12
|
+
if (source.kind !== 'implementation') {
|
|
13
|
+
throw new Error(`reviewer_selection_source_invalid: loop ${source.id} is ${source.kind}`);
|
|
14
|
+
}
|
|
15
|
+
const excludedImplementers = source.slots
|
|
16
|
+
.filter((slot) => Boolean(slot.agent))
|
|
17
|
+
.map((slot) => ({ agent: slot.agent, ...(slot.agent_id ? { agent_id: slot.agent_id } : {}) }));
|
|
18
|
+
const excludedNames = new Set(excludedImplementers.map((identity) => identity.agent.normalize('NFC')));
|
|
19
|
+
const excludedIds = new Set(excludedImplementers.flatMap((identity) => identity.agent_id ? [identity.agent_id.normalize('NFC')] : []));
|
|
20
|
+
const identities = listAgentIdentities(cwd)
|
|
21
|
+
.filter((identity) => identity.kind !== 'human')
|
|
22
|
+
.filter((identity) => !excludedNames.has(identity.agent_name.normalize('NFC')) && !excludedIds.has(identity.agent_id.normalize('NFC')))
|
|
23
|
+
.map((identity) => ({ agent: identity.agent_name, agent_id: identity.agent_id }));
|
|
24
|
+
const resolution = resolveExecutionCandidate(identities, { roles: ['review'], required_surfaces: ['cli_spawn'], execution_surfaces: [], required_tools: [] });
|
|
25
|
+
if (resolution.kind !== 'selected') {
|
|
26
|
+
const reasons = resolution.evaluated
|
|
27
|
+
.map((candidate) => `${candidate.agent}:${candidate.snapshot.reasons.map((reason) => reason.code).join('+') || 'excluded'}`)
|
|
28
|
+
.join(', ');
|
|
29
|
+
throw new Error(`continuation_reviewer_unavailable: no independent spawnable reviewer${reasons ? ` (${reasons})` : ''}`);
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
policy_version: REVIEWER_SELECTION_POLICY_VERSION,
|
|
33
|
+
agent: resolution.selected.agent,
|
|
34
|
+
agent_id: resolution.selected.agent_id,
|
|
35
|
+
evaluated: resolution.evaluated,
|
|
36
|
+
excluded_implementers: excludedImplementers,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=reviewer-policy.js.map
|
package/dist/core/schema.js
CHANGED
|
@@ -923,11 +923,18 @@ export const ActionRequiredResponseSchema = z.object({
|
|
|
923
923
|
responded_by_id: z.string().optional(),
|
|
924
924
|
responded_at: z.string(),
|
|
925
925
|
});
|
|
926
|
+
export const ActionRequiredTargetSchema = z.discriminatedUnion('kind', [
|
|
927
|
+
z.object({ kind: z.literal('assignment'), assignment_id: z.string() }),
|
|
928
|
+
z.object({ kind: z.literal('continuation'), continuation_id: z.string().regex(/^ctn_[a-f0-9]{24}$/) }),
|
|
929
|
+
]);
|
|
926
930
|
export const ActionRequiredSchema = z.object({
|
|
927
931
|
schema_version: z.number().int().positive().optional(),
|
|
928
932
|
id: z.string(),
|
|
929
933
|
short_label: z.string().optional(),
|
|
930
|
-
|
|
934
|
+
/** Legacy top-level assignment link; retained for v1 records. */
|
|
935
|
+
assignment_id: z.string().optional(),
|
|
936
|
+
/** Discriminated approval target. New records always persist this field. */
|
|
937
|
+
target: ActionRequiredTargetSchema.optional(),
|
|
931
938
|
run_id: z.string().optional(),
|
|
932
939
|
claim_id: z.string().optional(),
|
|
933
940
|
message_id: z.string().optional(),
|
|
@@ -949,6 +956,14 @@ export const ActionRequiredSchema = z.object({
|
|
|
949
956
|
resolved_at: z.string().optional(),
|
|
950
957
|
response: ActionRequiredResponseSchema.optional(),
|
|
951
958
|
tags: TagsWithDefaultSchema,
|
|
959
|
+
}).superRefine((action, ctx) => {
|
|
960
|
+
const target = action.target ?? (action.assignment_id ? { kind: 'assignment', assignment_id: action.assignment_id } : undefined);
|
|
961
|
+
if (!target) {
|
|
962
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['target'], message: 'ActionRequired requires an assignment or continuation target' });
|
|
963
|
+
}
|
|
964
|
+
if (target?.kind === 'assignment' && action.assignment_id && target.assignment_id !== action.assignment_id) {
|
|
965
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['target'], message: 'assignment target must match assignment_id' });
|
|
966
|
+
}
|
|
952
967
|
});
|
|
953
968
|
// --- Runtime notes schemas ---
|
|
954
969
|
export const RuntimeNoteTypeSchema = z.enum(['observation', 'session_start', 'session_end']);
|
|
@@ -1091,6 +1106,11 @@ export const LaneResultSchema = z.object({
|
|
|
1091
1106
|
* reconcile this to its phase's required artifact type.
|
|
1092
1107
|
*/
|
|
1093
1108
|
artifact_type: z.string().min(1).optional(),
|
|
1109
|
+
/** Synthesis-only executable acceptance policy for the downstream implementation loop. */
|
|
1110
|
+
implementation_verify: z.object({
|
|
1111
|
+
command: z.array(z.string().min(1)).min(1),
|
|
1112
|
+
timeout_ms: z.number().int().positive().max(15 * 60 * 1000).optional(),
|
|
1113
|
+
}).optional(),
|
|
1094
1114
|
/**
|
|
1095
1115
|
* pln#628 Focus 4B — review-loop verdict. A worker running a review-loop turn
|
|
1096
1116
|
* sets this to signal whether the change is good to merge (`approve`) or needs
|
package/dist/facts.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Generated by scripts/emit-site-facts.mjs at build time. Do not edit manually.
|
|
2
|
-
// Source: brainclaw v1.
|
|
2
|
+
// Source: brainclaw v1.28.1 on 2026-08-24T17:40:07.204Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.
|
|
5
|
-
"generated_at": "2026-08-
|
|
4
|
+
"version": "1.28.1",
|
|
5
|
+
"generated_at": "2026-08-24T17:40:07.204Z",
|
|
6
6
|
"tools": {
|
|
7
7
|
"count": 70,
|
|
8
8
|
"published_count": 68,
|
|
@@ -478,7 +478,7 @@ export const FACTS = {
|
|
|
478
478
|
},
|
|
479
479
|
"bench": {
|
|
480
480
|
"schema": "brainclaw.bench.v1",
|
|
481
|
-
"generated_at": "2026-08-
|
|
481
|
+
"generated_at": "2026-08-24T17:40:05.036Z",
|
|
482
482
|
"node_version": "v24.19.0",
|
|
483
483
|
"platform": "linux-x64",
|
|
484
484
|
"repeats": 3,
|
|
@@ -487,7 +487,7 @@ export const FACTS = {
|
|
|
487
487
|
"name": "cold_onboard",
|
|
488
488
|
"volume": "empty",
|
|
489
489
|
"description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
|
|
490
|
-
"duration_ms_median":
|
|
490
|
+
"duration_ms_median": 85,
|
|
491
491
|
"payload_chars_median": 1640,
|
|
492
492
|
"payload_tokens_est_median": 410
|
|
493
493
|
},
|
|
@@ -495,7 +495,7 @@ export const FACTS = {
|
|
|
495
495
|
"name": "warm_work",
|
|
496
496
|
"volume": "medium",
|
|
497
497
|
"description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
|
|
498
|
-
"duration_ms_median":
|
|
498
|
+
"duration_ms_median": 128,
|
|
499
499
|
"payload_chars_median": 2626,
|
|
500
500
|
"payload_tokens_est_median": 657
|
|
501
501
|
},
|
|
@@ -503,7 +503,7 @@ export const FACTS = {
|
|
|
503
503
|
"name": "first_edit",
|
|
504
504
|
"volume": "medium",
|
|
505
505
|
"description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
|
|
506
|
-
"duration_ms_median":
|
|
506
|
+
"duration_ms_median": 11,
|
|
507
507
|
"payload_chars_median": 1305,
|
|
508
508
|
"payload_tokens_est_median": 326
|
|
509
509
|
}
|