brainclaw 1.27.0 → 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/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/loops-handlers.js +56 -1
- package/dist/commands/mcp-catalog.js +10 -0
- package/dist/commands/mcp-schemas.generated.js +20 -0
- package/dist/commands/mcp-write-coordination.js +3 -0
- 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/evidence.js +1 -0
- package/dist/core/loops/facade-schema.js +34 -1
- package/dist/core/loops/gate-policy.js +52 -4
- package/dist/core/loops/impl-bind.js +58 -6
- 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 +14 -1
- package/dist/core/loops/verbs.js +3 -0
- package/dist/core/loops/verify-command.js +77 -15
- package/dist/core/schema.js +5 -0
- package/dist/facts.js +7 -7
- package/dist/facts.json +6 -6
- package/docs/loops/implementation.md +20 -0
- package/docs/mcp-schema-changelog.md +5 -1
- package/package.json +1 -1
|
Binary file
|
|
@@ -16,6 +16,10 @@ function resolveActor(req, defaultActor) {
|
|
|
16
16
|
return { actor, agentId };
|
|
17
17
|
}
|
|
18
18
|
function successResponse(intent, result, artifacts, side_effects, warnings, durationMs, summary) {
|
|
19
|
+
const resultLoop = result && typeof result === 'object' && 'loop' in result
|
|
20
|
+
? result.loop
|
|
21
|
+
: undefined;
|
|
22
|
+
const nextActions = resultLoop ? pipelineNextActions(resultLoop) : [];
|
|
19
23
|
return {
|
|
20
24
|
response: {
|
|
21
25
|
status: 'ok',
|
|
@@ -25,10 +29,59 @@ function successResponse(intent, result, artifacts, side_effects, warnings, dura
|
|
|
25
29
|
side_effects,
|
|
26
30
|
warnings,
|
|
27
31
|
duration_ms: durationMs,
|
|
32
|
+
...(nextActions.length > 0 ? { next_actions: nextActions } : {}),
|
|
28
33
|
},
|
|
29
34
|
summary,
|
|
30
35
|
};
|
|
31
36
|
}
|
|
37
|
+
/** Cross-loop affordances: explicit next calls, never hidden orchestration. */
|
|
38
|
+
function pipelineNextActions(loop) {
|
|
39
|
+
if (loop.kind === 'ideation') {
|
|
40
|
+
const draft = [...loop.artifacts].reverse().find((artifact) => artifact.type === 'plan_draft');
|
|
41
|
+
if (!draft || (loop.current_phase !== 'synthesis' && loop.status !== 'completed'))
|
|
42
|
+
return [];
|
|
43
|
+
const planIds = loop.linked?.plan_ids ?? [];
|
|
44
|
+
const sequenceIds = loop.linked?.sequence_ids ?? [];
|
|
45
|
+
if (planIds.length > 0 && sequenceIds.length > 0) {
|
|
46
|
+
return [{
|
|
47
|
+
tool: 'bclaw_loop',
|
|
48
|
+
args: {
|
|
49
|
+
intent: 'open', kind: 'implementation', title: `Implement ${loop.title}`,
|
|
50
|
+
goal: loop.goal, linked: { plan_ids: planIds, sequence_ids: sequenceIds, source_loop_id: loop.id },
|
|
51
|
+
verify: draft.implementation_verify,
|
|
52
|
+
slots: [{ role: 'implementer' }], allow_orphan: true,
|
|
53
|
+
},
|
|
54
|
+
when: 'start implementation from the accepted synthesis',
|
|
55
|
+
}];
|
|
56
|
+
}
|
|
57
|
+
return [{
|
|
58
|
+
tool: 'bclaw_create',
|
|
59
|
+
args: { entity: 'plan', text: draft.body ?? '<materialize the plan_draft artifact>', status: 'todo' },
|
|
60
|
+
when: 'materialize the synthesis before opening its implementation loop',
|
|
61
|
+
}];
|
|
62
|
+
}
|
|
63
|
+
if (loop.kind === 'implementation' && (loop.current_phase === 'handoff_ready' || loop.status === 'completed')) {
|
|
64
|
+
const handoff = [...loop.artifacts].reverse().find((artifact) => artifact.type === 'handoff');
|
|
65
|
+
const reviewScope = [...new Set(loop.slots.map((slot) => slot.scope_hint?.trim()).filter((scope) => Boolean(scope)))].join(',');
|
|
66
|
+
return [{
|
|
67
|
+
tool: 'bclaw_coordinate',
|
|
68
|
+
args: {
|
|
69
|
+
intent: 'review', open_loop: true,
|
|
70
|
+
task: handoff?.ref
|
|
71
|
+
? `Review implementation loop ${loop.id}; handoff ${handoff.ref.kind}:${handoff.ref.id}`
|
|
72
|
+
: `Review implementation loop ${loop.id} (${loop.title})`,
|
|
73
|
+
targetAgents: ['<reviewer>'],
|
|
74
|
+
...(reviewScope ? { scope: reviewScope } : {}),
|
|
75
|
+
...(handoff?.ref && (handoff.ref.kind === 'commit' || handoff.ref.kind === 'branch')
|
|
76
|
+
? { ref: handoff.ref.id }
|
|
77
|
+
: {}),
|
|
78
|
+
linked: { source_loop_id: loop.id, plan_ids: loop.linked?.plan_ids, sequence_ids: loop.linked?.sequence_ids },
|
|
79
|
+
},
|
|
80
|
+
when: 'implementation evidence is handoff-ready',
|
|
81
|
+
}];
|
|
82
|
+
}
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
32
85
|
function errorResponse(intent, code, message, durationMs, result = null) {
|
|
33
86
|
return {
|
|
34
87
|
response: {
|
|
@@ -371,6 +424,7 @@ export async function handleBclawLoop(options) {
|
|
|
371
424
|
body: req.artifact.body,
|
|
372
425
|
ref: req.artifact.ref,
|
|
373
426
|
addresses_critique: req.artifact.addresses_critique,
|
|
427
|
+
implementation_verify: req.artifact.implementation_verify,
|
|
374
428
|
}
|
|
375
429
|
: undefined,
|
|
376
430
|
actor,
|
|
@@ -434,6 +488,7 @@ export async function handleBclawLoop(options) {
|
|
|
434
488
|
body: req.artifact.body,
|
|
435
489
|
ref: req.artifact.ref,
|
|
436
490
|
addresses_critique: req.artifact.addresses_critique,
|
|
491
|
+
implementation_verify: req.artifact.implementation_verify,
|
|
437
492
|
},
|
|
438
493
|
actor,
|
|
439
494
|
}, options.cwd);
|
|
@@ -522,7 +577,7 @@ export async function handleBclawLoop(options) {
|
|
|
522
577
|
return errorResponse('verify', 'not_found', `unknown loop_id ${req.loop_id}`, Date.now() - startMs);
|
|
523
578
|
}
|
|
524
579
|
const beforeEvents = snapshotLoopEvents(req.loop_id, options.cwd);
|
|
525
|
-
const result = runVerify({ loop_id: req.loop_id, actor }, options.cwd);
|
|
580
|
+
const result = runVerify({ loop_id: req.loop_id, slot_id: req.slot_id, actor }, options.cwd);
|
|
526
581
|
const newEvents = findNewLoopEvents(result.thread.id, beforeEvents, options.cwd);
|
|
527
582
|
const summary = result.unconfigured
|
|
528
583
|
? `verify: loop has no protocol.verify — falling back to an agent-narrated verify_report`
|
|
@@ -854,6 +854,16 @@ const MCP_WRITE_TOOLS = [
|
|
|
854
854
|
targetAgents: { type: 'array', items: { type: 'string' }, description: 'Agent names to target. If omitted, all spawnable agents are used.' },
|
|
855
855
|
constraints: { type: 'object', description: 'Optional structured constraints passed alongside the brief (e.g. deadline, reviewCriteria).' },
|
|
856
856
|
threadId: { type: 'string', description: 'Thread ID for summarize intent.' },
|
|
857
|
+
linked: {
|
|
858
|
+
type: 'object',
|
|
859
|
+
description: 'Optional pipeline provenance persisted on a review loop opened by this call.',
|
|
860
|
+
properties: {
|
|
861
|
+
plan_ids: { type: 'array', items: { type: 'string' } },
|
|
862
|
+
sequence_ids: { type: 'array', items: { type: 'string' } },
|
|
863
|
+
source_loop_id: { type: 'string', pattern: '^lop_[0-9a-z]+$' },
|
|
864
|
+
},
|
|
865
|
+
additionalProperties: false,
|
|
866
|
+
},
|
|
857
867
|
autoExecute: { type: 'boolean', description: 'Attempt to spawn target agents after delivery (default: true). Applies to the spawning intents assign/review/reroute AND to multi-agent ideate (with targetAgents, it spawns one worktree-isolated critic worker per target). consult is inbox-only and ignores autoExecute; summarize just reads a thread and ignores it. When false on a spawning intent, returns command_ready_manual with bash commands for the supervisor to run.' },
|
|
858
868
|
open_loop: { type: 'boolean', description: 'For intent=review only: also open a review Loop on top of the candidate (author + reviewer slots, advance to `findings`, dispatch turns). Default false — existing review callers are unaffected. See docs/concepts/loop-engine.md §Automation.' },
|
|
859
869
|
review_mode: { type: 'string', enum: ['asymmetric', 'symmetric'], description: 'Optional review Loop mode when open_loop=true. `asymmetric` (default) keeps the classical author→reviewer handoff; `symmetric` lets each reviewer turn also apply fixes directly, halving round-trips for spec/doc reviews. Ignored when open_loop is false.' },
|
|
@@ -283,6 +283,26 @@ export const generatedSchemas = {
|
|
|
283
283
|
"phase": {
|
|
284
284
|
"type": "string"
|
|
285
285
|
},
|
|
286
|
+
"lane": {
|
|
287
|
+
"type": "string"
|
|
288
|
+
},
|
|
289
|
+
"scope_hint": {
|
|
290
|
+
"type": "string"
|
|
291
|
+
},
|
|
292
|
+
"plan_ids": {
|
|
293
|
+
"type": "array",
|
|
294
|
+
"items": {
|
|
295
|
+
"type": "string",
|
|
296
|
+
"minLength": 1
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
"step_ids": {
|
|
300
|
+
"type": "array",
|
|
301
|
+
"items": {
|
|
302
|
+
"type": "string",
|
|
303
|
+
"minLength": 1
|
|
304
|
+
}
|
|
305
|
+
},
|
|
286
306
|
"status": {
|
|
287
307
|
"type": "string",
|
|
288
308
|
"enum": [
|
|
@@ -1002,6 +1002,7 @@ export async function handleBclawCoordinate(args, ctx) {
|
|
|
1002
1002
|
created_by: creatorActor,
|
|
1003
1003
|
slots,
|
|
1004
1004
|
mode: req.review_mode ?? 'asymmetric',
|
|
1005
|
+
linked: req.linked,
|
|
1005
1006
|
}, dispatchCwd);
|
|
1006
1007
|
out.loopId = loop.id;
|
|
1007
1008
|
out.artifacts.push({ type: 'loop', id: loop.id });
|
|
@@ -1585,6 +1586,7 @@ export async function handleBclawCoordinate(args, ctx) {
|
|
|
1585
1586
|
goal: req.scope,
|
|
1586
1587
|
created_by: creatorActor,
|
|
1587
1588
|
slots,
|
|
1589
|
+
linked: req.linked,
|
|
1588
1590
|
...(presetSelected
|
|
1589
1591
|
? {
|
|
1590
1592
|
phases: presetSelected.phases,
|
|
@@ -1677,6 +1679,7 @@ export async function handleBclawCoordinate(args, ctx) {
|
|
|
1677
1679
|
category,
|
|
1678
1680
|
text: r.text,
|
|
1679
1681
|
score: r.score,
|
|
1682
|
+
relatedPaths: r.related_paths,
|
|
1680
1683
|
}));
|
|
1681
1684
|
},
|
|
1682
1685
|
};
|
|
@@ -178,6 +178,33 @@ function buildManualEnvPrefix(claimId) {
|
|
|
178
178
|
// wrapper for symmetry with the dispatcher's buildEnvPrefix.
|
|
179
179
|
return buildClaimEnvPrefix(claimId);
|
|
180
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Make the isolated worktree the explicit Codex workspace root. Relying only
|
|
183
|
+
* on child_process.cwd is insufficient for non-interactive Windows launches:
|
|
184
|
+
* the Codex sandbox can retain the coordinator workspace and apply_patch then
|
|
185
|
+
* refuses writes in ~/.brainclaw/worktrees even when NTFS grants access.
|
|
186
|
+
* `--cd` defines the primary root. Do not redundantly add the same path with
|
|
187
|
+
* `--add-dir`: the unelevated Windows sandbox cannot enforce split writable
|
|
188
|
+
* root sets and refuses to prepare its wrapper in that configuration.
|
|
189
|
+
*/
|
|
190
|
+
export function withCodexWorkspaceRoot(invoke, agent, worktreePath, isWin32 = process.platform === 'win32') {
|
|
191
|
+
const executableName = path.win32.basename(invoke.executable).replace(/\.(?:cmd|exe|bat|com)$/i, '').toLowerCase();
|
|
192
|
+
if (agent.trim().toLowerCase() !== 'codex' || executableName !== 'codex' || !worktreePath)
|
|
193
|
+
return invoke;
|
|
194
|
+
const args = [...invoke.args];
|
|
195
|
+
const subcommandIndex = args.indexOf('exec');
|
|
196
|
+
const insertAt = subcommandIndex >= 0 ? subcommandIndex : 0;
|
|
197
|
+
args.splice(insertAt, 0, '--cd', worktreePath);
|
|
198
|
+
const quote = (value) => isWin32
|
|
199
|
+
? `"${value.replace(/"/g, '""')}"`
|
|
200
|
+
: `'${value.replace(/'/g, `'\\''`)}'`;
|
|
201
|
+
const flags = `--cd ${quote(worktreePath)}`;
|
|
202
|
+
const prefix = invoke.executable;
|
|
203
|
+
const suffix = invoke.bashCommand.startsWith(`${prefix} `)
|
|
204
|
+
? invoke.bashCommand.slice(prefix.length + 1)
|
|
205
|
+
: invoke.bashCommand;
|
|
206
|
+
return { ...invoke, args, bashCommand: `${prefix} ${flags} ${suffix}` };
|
|
207
|
+
}
|
|
181
208
|
export class CliExecutionAdapter {
|
|
182
209
|
id = 'cli';
|
|
183
210
|
canSpawn(agentName) {
|
|
@@ -198,6 +225,7 @@ export class CliExecutionAdapter {
|
|
|
198
225
|
}
|
|
199
226
|
prepareManualCommand(invoke, options) {
|
|
200
227
|
const isWin32 = process.platform === 'win32';
|
|
228
|
+
invoke = withCodexWorkspaceRoot(invoke, options.agent, options.worktreePath, isWin32);
|
|
201
229
|
const shell = isWin32 ? 'cmd' : (invoke.shell ? 'bash' : 'sh');
|
|
202
230
|
if (options.turnEcho?.contract_hash
|
|
203
231
|
&& options.turnEcho.capability_snapshot_hash
|
|
@@ -246,6 +274,7 @@ export class CliExecutionAdapter {
|
|
|
246
274
|
}
|
|
247
275
|
start(invoke, options) {
|
|
248
276
|
const isWin32 = process.platform === 'win32';
|
|
277
|
+
invoke = withCodexWorkspaceRoot(invoke, options.agent, options.worktreePath, isWin32);
|
|
249
278
|
// F7 (trp_0e5150d3): route worker env through buildWorkerIdentityEnv so the
|
|
250
279
|
// worker is an independent agent — coordinator identity (BRAINCLAW_AGENT*,
|
|
251
280
|
// SESSION_ID, PROJECT) is scrubbed LAST and cannot be reintroduced by
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { LoopLinksSchema } from './loops/types.js';
|
|
2
3
|
export const ExecutionStatusSchema = z.enum(['delivered_and_started', 'command_ready_manual', 'inbox_only']);
|
|
3
4
|
export const WorkIntentSchema = z.enum(['execute', 'consult', 'resume', 'review']);
|
|
4
5
|
// pln#626 — coordinate intents split into three honest contracts:
|
|
@@ -37,6 +38,8 @@ export const CoordinateRequestSchema = z.object({
|
|
|
37
38
|
targetAgents: z.array(z.string()).optional(),
|
|
38
39
|
constraints: z.record(z.string(), z.unknown()).optional(),
|
|
39
40
|
threadId: z.string().optional(),
|
|
41
|
+
/** Optional pipeline provenance persisted when open_loop creates a loop. */
|
|
42
|
+
linked: LoopLinksSchema.optional(),
|
|
40
43
|
autoExecute: z.boolean().optional(),
|
|
41
44
|
/**
|
|
42
45
|
* When intent=review and open_loop=true, a review Loop is opened on top of
|
|
@@ -12,10 +12,12 @@ import { transitionAgentRun } from './agentruns.js';
|
|
|
12
12
|
import { loadAssignment, patchAssignmentMessageId, transitionAssignment } from './assignments.js';
|
|
13
13
|
import { attachAssignmentMessageToClaim, createCoordinatorClaim, ensureClaimAssignmentBinding, } from './claims.js';
|
|
14
14
|
import { generateDispatchBrief } from './dispatcher.js';
|
|
15
|
+
import { search } from './search.js';
|
|
15
16
|
import { attemptExecution } from './execution.js';
|
|
16
17
|
import { resolveExecutionCandidate } from './execution-contract.js';
|
|
17
18
|
import { buildHarnessInvocation, resolveHarnessBinding } from './harness-adapters/index.js';
|
|
18
19
|
import { phasePolicy } from './loops/kind-policies.js';
|
|
20
|
+
import { buildIdeationBrief } from './loops/brief-assembly.js';
|
|
19
21
|
import { getLoop } from './loops/store.js';
|
|
20
22
|
import { prepareTurnExecution } from './loops/turn-execution.js';
|
|
21
23
|
import { sendMessage } from './messaging.js';
|
|
@@ -60,7 +62,33 @@ export async function dispatchLoopTurn(input) {
|
|
|
60
62
|
agentId = selection.selected.agent_id;
|
|
61
63
|
result.agent = agent;
|
|
62
64
|
}
|
|
63
|
-
const scope = `loop:${loop.kind}:${loop.id}:slot:${slot.slot_id}`;
|
|
65
|
+
const scope = slot.scope_hint ?? `loop:${loop.kind}:${loop.id}:slot:${slot.slot_id}`;
|
|
66
|
+
const sectionByCategory = {
|
|
67
|
+
traps: 'traps', decisions: 'decisions', constraints: 'constraints', handoffs: 'handoffs',
|
|
68
|
+
plans: 'plans', candidates: 'candidates',
|
|
69
|
+
};
|
|
70
|
+
const provider = {
|
|
71
|
+
fetch(category, query, topK) {
|
|
72
|
+
const section = sectionByCategory[category];
|
|
73
|
+
if (!section)
|
|
74
|
+
return [];
|
|
75
|
+
return search({ query, section, maxResults: topK, cwd: input.cwd, includePending: section === 'candidates' })
|
|
76
|
+
.map((item) => ({
|
|
77
|
+
id: item.id, category, text: item.text, score: item.score, relatedPaths: item.related_paths,
|
|
78
|
+
}));
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
const phaseBrief = buildIdeationBrief({
|
|
82
|
+
thread: loop,
|
|
83
|
+
slotRole: slot.role,
|
|
84
|
+
memoryProvider: provider,
|
|
85
|
+
seedText: input.task,
|
|
86
|
+
scopeHints: slot.scope_hint ? slot.scope_hint.split(',').map((value) => value.trim()) : [],
|
|
87
|
+
});
|
|
88
|
+
const laneContext = slot.lane
|
|
89
|
+
? `Lane: ${slot.lane}\nPlans: ${(slot.plan_ids ?? []).join(', ') || '(none)'}\nSteps: ${(slot.step_ids ?? []).join(', ') || '(whole plan)'}`
|
|
90
|
+
: '';
|
|
91
|
+
const scopedTask = [phaseBrief.text, laneContext].filter(Boolean).join('\n\n');
|
|
64
92
|
const description = `${loop.kind} loop turn for ${loop.id} slot ${slot.slot_id} phase ${loop.current_phase}. ${input.task}`;
|
|
65
93
|
try {
|
|
66
94
|
const claim = createCoordinatorClaim({
|
|
@@ -92,7 +120,7 @@ export async function dispatchLoopTurn(input) {
|
|
|
92
120
|
dispatcher_session_id: input.session_id,
|
|
93
121
|
scope,
|
|
94
122
|
description,
|
|
95
|
-
task:
|
|
123
|
+
task: scopedTask,
|
|
96
124
|
cwd: input.cwd,
|
|
97
125
|
worktree_path: claim.worktreePath,
|
|
98
126
|
model,
|
|
@@ -121,7 +149,7 @@ export async function dispatchLoopTurn(input) {
|
|
|
121
149
|
...(prepared.workspace_digest ? { workspace_digest: prepared.workspace_digest } : {}),
|
|
122
150
|
};
|
|
123
151
|
const brief = generateDispatchBrief({
|
|
124
|
-
task:
|
|
152
|
+
task: scopedTask,
|
|
125
153
|
agent,
|
|
126
154
|
claimId: claim.claimId,
|
|
127
155
|
scope,
|
|
@@ -199,6 +199,26 @@ export function executionContractForGeneration(reservation, generation) {
|
|
|
199
199
|
if (!reservation.execution_contract || !reservation.capability_snapshot) {
|
|
200
200
|
throw new AttemptGenerationError('invalid_transition', `turn ${reservation.turn_id} has no immutable execution contract`);
|
|
201
201
|
}
|
|
202
|
+
// Generation zero anchors the already-crossed immutable reservation. Keep
|
|
203
|
+
// its original serialized contract: on Windows the generation cell stores a
|
|
204
|
+
// canonicalized (case-folded) workspace path, and rebuilding the contract
|
|
205
|
+
// from that path changes its hash even though it names the same checkout.
|
|
206
|
+
// Successor generations still derive a new contract below because their
|
|
207
|
+
// epoch, run id, and workspace are intentionally different.
|
|
208
|
+
if (generation.attempt_epoch === 0) {
|
|
209
|
+
const contract = reservation.execution_contract;
|
|
210
|
+
if (contract.identity.assignment_id !== generation.assignment_id
|
|
211
|
+
|| contract.identity.run_id !== generation.run_id
|
|
212
|
+
|| canonicalWorkspacePath(contract.workspace_policy.worktree_path ?? contract.workspace_policy.cwd)
|
|
213
|
+
!== canonicalWorkspacePath(generation.workspace_path)) {
|
|
214
|
+
throw new AttemptGenerationError('fenced', 'generation zero diverges from its immutable reservation contract');
|
|
215
|
+
}
|
|
216
|
+
const ref = executionContractRef(contract, reservation.capability_snapshot);
|
|
217
|
+
if (ref.hash !== generation.contract_hash) {
|
|
218
|
+
throw new AttemptGenerationError('fenced', `generation zero contract hash ${generation.contract_hash} does not match reservation ${ref.hash}`);
|
|
219
|
+
}
|
|
220
|
+
return { contract, ref };
|
|
221
|
+
}
|
|
202
222
|
const contract = ExecutionContractSchema.parse({
|
|
203
223
|
...reservation.execution_contract,
|
|
204
224
|
identity: {
|
|
@@ -45,9 +45,9 @@ const LOOP_INTERNAL_CATEGORIES = new Set([
|
|
|
45
45
|
'synthesis_artifact',
|
|
46
46
|
]);
|
|
47
47
|
export function buildIdeationBrief(input) {
|
|
48
|
-
const { thread, slotRole, memoryProvider, maxChars = DEFAULT_MAX_CHARS, topKPerCategory = DEFAULT_TOP_K_PER_CATEGORY, } = input;
|
|
48
|
+
const { thread, slotRole, memoryProvider, maxChars = DEFAULT_MAX_CHARS, topKPerCategory = DEFAULT_TOP_K_PER_CATEGORY, seedText, scopeHints = [], } = input;
|
|
49
49
|
const proposal = findProposalArtifact(thread);
|
|
50
|
-
const proposalText = proposal?.body?.trim()
|
|
50
|
+
const proposalText = seedText?.trim() || proposal?.body?.trim() || '(no proposal seed found)';
|
|
51
51
|
// Resolve which memory categories the current phase wants. If the
|
|
52
52
|
// current phase has no context_filter, fall back to '*' (full bundle).
|
|
53
53
|
const currentPhaseDef = thread.phases.find((p) => p.name === thread.current_phase);
|
|
@@ -57,7 +57,7 @@ export function buildIdeationBrief(input) {
|
|
|
57
57
|
const fetchedItemsByCategory = new Map();
|
|
58
58
|
const categoriesUsed = [];
|
|
59
59
|
for (const category of userFacingCategories) {
|
|
60
|
-
const items = memoryProvider.fetch(category, proposalText, topKPerCategory);
|
|
60
|
+
const items = scopeMemoryItems(memoryProvider.fetch(category, `${proposalText} ${scopeHints.join(' ')}`.trim(), topKPerCategory), scopeHints);
|
|
61
61
|
if (items.length > 0) {
|
|
62
62
|
fetchedItemsByCategory.set(category, items);
|
|
63
63
|
categoriesUsed.push(category);
|
|
@@ -116,7 +116,7 @@ function expandUserFacingCategories(requested) {
|
|
|
116
116
|
}
|
|
117
117
|
function renderHeader(thread, slotRole, phase) {
|
|
118
118
|
const lines = [
|
|
119
|
-
`#
|
|
119
|
+
`# ${thread.kind}_loop brief`,
|
|
120
120
|
`loop: ${thread.id}`,
|
|
121
121
|
`phase: ${phase}`,
|
|
122
122
|
`iteration: ${thread.iteration_count}`,
|
|
@@ -127,6 +127,23 @@ function renderHeader(thread, slotRole, phase) {
|
|
|
127
127
|
lines.push(`goal: ${thread.goal}`);
|
|
128
128
|
return lines.join('\n');
|
|
129
129
|
}
|
|
130
|
+
function normalizeScope(value) {
|
|
131
|
+
return value.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
|
|
132
|
+
}
|
|
133
|
+
/** Keep project-wide memories and memories whose related_paths overlap this lane. */
|
|
134
|
+
function scopeMemoryItems(items, scopeHints) {
|
|
135
|
+
const scopes = scopeHints.map(normalizeScope).filter(Boolean);
|
|
136
|
+
if (scopes.length === 0)
|
|
137
|
+
return items;
|
|
138
|
+
return items.filter((item) => {
|
|
139
|
+
if (!item.relatedPaths || item.relatedPaths.length === 0)
|
|
140
|
+
return true;
|
|
141
|
+
return item.relatedPaths.some((related) => {
|
|
142
|
+
const path = normalizeScope(related);
|
|
143
|
+
return scopes.some((scope) => path.startsWith(scope) || scope.startsWith(path));
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
130
147
|
function renderProposalBlock(proposalText) {
|
|
131
148
|
return `## proposal\n\n${proposalText}`;
|
|
132
149
|
}
|
|
@@ -24,6 +24,7 @@ export function artifactEvidenceDigest(artifact) {
|
|
|
24
24
|
produced_by: artifact.produced_by,
|
|
25
25
|
produced_at: artifact.produced_at,
|
|
26
26
|
addresses_critique: artifact.addresses_critique,
|
|
27
|
+
implementation_verify: artifact.implementation_verify,
|
|
27
28
|
iteration: artifact.iteration ?? 0,
|
|
28
29
|
});
|
|
29
30
|
}
|
|
@@ -102,6 +102,21 @@ export const BclawLoopCompleteTurnSchema = z.object({
|
|
|
102
102
|
ref: LoopRefSchema.optional(),
|
|
103
103
|
/** pln#492 synthesis audit trail. Required when type === 'plan_draft'. */
|
|
104
104
|
addresses_critique: z.array(z.string().min(1)).optional(),
|
|
105
|
+
implementation_verify: z
|
|
106
|
+
.object({
|
|
107
|
+
command: z.array(z.string().min(1)).min(1),
|
|
108
|
+
timeout_ms: z.number().int().positive().optional(),
|
|
109
|
+
})
|
|
110
|
+
.optional(),
|
|
111
|
+
})
|
|
112
|
+
.superRefine((artifact, ctx) => {
|
|
113
|
+
if (artifact.type === 'plan_draft' && !artifact.implementation_verify) {
|
|
114
|
+
ctx.addIssue({
|
|
115
|
+
code: z.ZodIssueCode.custom,
|
|
116
|
+
message: "plan_draft requires implementation_verify for deterministic downstream verification",
|
|
117
|
+
path: ['implementation_verify'],
|
|
118
|
+
});
|
|
119
|
+
}
|
|
105
120
|
})
|
|
106
121
|
.optional(),
|
|
107
122
|
expected_version: z.number().int().nonnegative().optional(),
|
|
@@ -132,13 +147,29 @@ export const BclawLoopAdvanceSchema = z.object({
|
|
|
132
147
|
export const BclawLoopAddArtifactSchema = z.object({
|
|
133
148
|
intent: z.literal('add_artifact'),
|
|
134
149
|
loop_id: z.string().regex(/^lop_[0-9a-z]+$/),
|
|
135
|
-
artifact: z
|
|
150
|
+
artifact: z
|
|
151
|
+
.object({
|
|
136
152
|
phase: z.string().min(1),
|
|
137
153
|
type: z.string().min(1),
|
|
138
154
|
body: z.string().optional(),
|
|
139
155
|
ref: LoopRefSchema.optional(),
|
|
140
156
|
/** pln#492 synthesis audit trail. Required when type === 'plan_draft'. */
|
|
141
157
|
addresses_critique: z.array(z.string().min(1)).optional(),
|
|
158
|
+
implementation_verify: z
|
|
159
|
+
.object({
|
|
160
|
+
command: z.array(z.string().min(1)).min(1),
|
|
161
|
+
timeout_ms: z.number().int().positive().optional(),
|
|
162
|
+
})
|
|
163
|
+
.optional(),
|
|
164
|
+
})
|
|
165
|
+
.superRefine((artifact, ctx) => {
|
|
166
|
+
if (artifact.type === 'plan_draft' && !artifact.implementation_verify) {
|
|
167
|
+
ctx.addIssue({
|
|
168
|
+
code: z.ZodIssueCode.custom,
|
|
169
|
+
message: "plan_draft requires implementation_verify for deterministic downstream verification",
|
|
170
|
+
path: ['implementation_verify'],
|
|
171
|
+
});
|
|
172
|
+
}
|
|
142
173
|
}),
|
|
143
174
|
expected_version: z.number().int().nonnegative().optional(),
|
|
144
175
|
...CallerEnvelopeFields,
|
|
@@ -173,6 +204,8 @@ export const BclawLoopCloseSchema = z.object({
|
|
|
173
204
|
export const BclawLoopVerifySchema = z.object({
|
|
174
205
|
intent: z.literal('verify'),
|
|
175
206
|
loop_id: z.string().regex(/^lop_[0-9a-z]+$/),
|
|
207
|
+
/** Required when an implementation loop has more than one bound lane. */
|
|
208
|
+
slot_id: z.string().regex(/^lsl_[0-9a-z]+$/).optional(),
|
|
176
209
|
// No expected_version: runVerify is idempotent by (loop, iteration) via its own
|
|
177
210
|
// two-lock re-check, not optimistic-concurrency CAS (review F3).
|
|
178
211
|
...CallerEnvelopeFields,
|
|
@@ -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
|
|
@@ -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,8 @@ 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(),
|
|
26
28
|
});
|
|
27
29
|
/**
|
|
28
30
|
* Memory categories a loop phase can request via `context_filter` (pln#492).
|
|
@@ -155,6 +157,13 @@ export const LoopSlotSchema = z.object({
|
|
|
155
157
|
assignment_id: z.string().optional(),
|
|
156
158
|
claim_id: z.string().optional(),
|
|
157
159
|
phase: z.string().optional(),
|
|
160
|
+
/** Implementation-loop lane bound from the linked sequence at bind time. */
|
|
161
|
+
lane: z.string().optional(),
|
|
162
|
+
/** File/path scope carried by the bound sequence lane. */
|
|
163
|
+
scope_hint: z.string().optional(),
|
|
164
|
+
/** Plans and steps executed by this lane (derived, never worker-authored). */
|
|
165
|
+
plan_ids: z.array(z.string().min(1)).optional(),
|
|
166
|
+
step_ids: z.array(z.string().min(1)).optional(),
|
|
158
167
|
status: z.enum(SLOT_STATUSES),
|
|
159
168
|
/**
|
|
160
169
|
* pln#630 PR2b-a (§13 R1) — pointer to the immutable turn-attempt record for
|
|
@@ -418,6 +427,8 @@ export const VerifyReportBodySchema = z.object({
|
|
|
418
427
|
command_digest: z.string().regex(/^[0-9a-f]{64}$/).optional(),
|
|
419
428
|
workspace_digest: z.string().regex(/^[0-9a-f]{64}$/).optional(),
|
|
420
429
|
workspace_stable: z.boolean().optional(),
|
|
430
|
+
/** Implementation lane whose worktree was verified. */
|
|
431
|
+
lane: z.string().optional(),
|
|
421
432
|
});
|
|
422
433
|
export const KNOWN_ARTIFACT_BODY_SCHEMAS = {
|
|
423
434
|
// inline JSON body: body = JSON.stringify({ ...fields per OperatorQuestionBodySchema })
|
|
@@ -457,6 +468,8 @@ export const LoopArtifactSchema = z
|
|
|
457
468
|
* critique artifact) is deferred to v1.1 per the plan.
|
|
458
469
|
*/
|
|
459
470
|
addresses_critique: z.array(z.string().min(1)).optional(),
|
|
471
|
+
/** Executable acceptance command carried from synthesis into implementation. */
|
|
472
|
+
implementation_verify: LoopVerifyConfigSchema.optional(),
|
|
460
473
|
/**
|
|
461
474
|
* pln#492 phase 2.b — iteration window the artifact was produced in.
|
|
462
475
|
* 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,
|
package/dist/core/schema.js
CHANGED
|
@@ -1091,6 +1091,11 @@ export const LaneResultSchema = z.object({
|
|
|
1091
1091
|
* reconcile this to its phase's required artifact type.
|
|
1092
1092
|
*/
|
|
1093
1093
|
artifact_type: z.string().min(1).optional(),
|
|
1094
|
+
/** Synthesis-only executable acceptance policy for the downstream implementation loop. */
|
|
1095
|
+
implementation_verify: z.object({
|
|
1096
|
+
command: z.array(z.string().min(1)).min(1),
|
|
1097
|
+
timeout_ms: z.number().int().positive().max(15 * 60 * 1000).optional(),
|
|
1098
|
+
}).optional(),
|
|
1094
1099
|
/**
|
|
1095
1100
|
* pln#628 Focus 4B — review-loop verdict. A worker running a review-loop turn
|
|
1096
1101
|
* 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.0 on 2026-08-24T07:09:07.814Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.
|
|
5
|
-
"generated_at": "2026-08-
|
|
4
|
+
"version": "1.28.0",
|
|
5
|
+
"generated_at": "2026-08-24T07:09:07.814Z",
|
|
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-24T07:09:05.728Z",
|
|
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": 81,
|
|
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": 120,
|
|
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": 13,
|
|
507
507
|
"payload_chars_median": 1305,
|
|
508
508
|
"payload_tokens_est_median": 326
|
|
509
509
|
}
|
package/dist/facts.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
3
|
-
"generated_at": "2026-08-
|
|
2
|
+
"version": "1.28.0",
|
|
3
|
+
"generated_at": "2026-08-24T07:09:07.814Z",
|
|
4
4
|
"tools": {
|
|
5
5
|
"count": 70,
|
|
6
6
|
"published_count": 68,
|
|
@@ -476,7 +476,7 @@
|
|
|
476
476
|
},
|
|
477
477
|
"bench": {
|
|
478
478
|
"schema": "brainclaw.bench.v1",
|
|
479
|
-
"generated_at": "2026-08-
|
|
479
|
+
"generated_at": "2026-08-24T07:09:05.728Z",
|
|
480
480
|
"node_version": "v24.19.0",
|
|
481
481
|
"platform": "linux-x64",
|
|
482
482
|
"repeats": 3,
|
|
@@ -485,7 +485,7 @@
|
|
|
485
485
|
"name": "cold_onboard",
|
|
486
486
|
"volume": "empty",
|
|
487
487
|
"description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
|
|
488
|
-
"duration_ms_median":
|
|
488
|
+
"duration_ms_median": 81,
|
|
489
489
|
"payload_chars_median": 1640,
|
|
490
490
|
"payload_tokens_est_median": 410
|
|
491
491
|
},
|
|
@@ -493,7 +493,7 @@
|
|
|
493
493
|
"name": "warm_work",
|
|
494
494
|
"volume": "medium",
|
|
495
495
|
"description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
|
|
496
|
-
"duration_ms_median":
|
|
496
|
+
"duration_ms_median": 120,
|
|
497
497
|
"payload_chars_median": 2626,
|
|
498
498
|
"payload_tokens_est_median": 657
|
|
499
499
|
},
|
|
@@ -501,7 +501,7 @@
|
|
|
501
501
|
"name": "first_edit",
|
|
502
502
|
"volume": "medium",
|
|
503
503
|
"description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
|
|
504
|
-
"duration_ms_median":
|
|
504
|
+
"duration_ms_median": 13,
|
|
505
505
|
"payload_chars_median": 1305,
|
|
506
506
|
"payload_tokens_est_median": 326
|
|
507
507
|
}
|
|
@@ -19,6 +19,11 @@ validates the plan/sequence link and advances to `execute`. It never launches
|
|
|
19
19
|
a worker. `execute ↔ verify` iterates until the verify command is green or
|
|
20
20
|
the cycle cap is hit.
|
|
21
21
|
|
|
22
|
+
Binding validates the complete graph: every sequence item must reference a
|
|
23
|
+
linked, existing plan (and an existing step when `stepId` is present). Explicit
|
|
24
|
+
sequence lanes are paired deterministically with worker slots, one slot per
|
|
25
|
+
lane. Each slot then carries its lane, plan/step ids, and `scope_hint`.
|
|
26
|
+
|
|
22
27
|
## Default protocol
|
|
23
28
|
|
|
24
29
|
```
|
|
@@ -67,6 +72,11 @@ iteration** — this guards the narrated-verify anti-pattern where a slot
|
|
|
67
72
|
claims it verified without actually running the command. `command_green` in
|
|
68
73
|
the iteration engine reads the reports produced against this gate.
|
|
69
74
|
|
|
75
|
+
For bound lanes, call `bclaw_loop(intent='verify', slot_id=…)`. The command
|
|
76
|
+
runs in that slot assignment's worktree. Omitting `slot_id` when several lane
|
|
77
|
+
worktrees exist fails closed, and `command_green` requires a current-iteration
|
|
78
|
+
green report from every bound lane.
|
|
79
|
+
|
|
70
80
|
## Stop condition
|
|
71
81
|
|
|
72
82
|
```ts
|
|
@@ -96,6 +106,9 @@ the iteration engine reads the reports produced against this gate.
|
|
|
96
106
|
scope claim created by `turn(dispatch=true)`, and the common driver runs in
|
|
97
107
|
the worktree bound to that claim. `bind` creates neither claim nor assignment.
|
|
98
108
|
`session_id` is observability-only.
|
|
109
|
+
Dispatch also uses the bound `scope_hint` to retrieve only path-related
|
|
110
|
+
decisions, constraints, traps and runtime context (while retaining unscoped
|
|
111
|
+
project-wide memory).
|
|
99
112
|
[Attempt authority](../concepts/attempt-authority.md#ordered-dispatch)
|
|
100
113
|
mints a deterministic `turn_id` from `(loop_id, slot_id, iteration)` on
|
|
101
114
|
every dispatch, so a concurrent re-dispatch hits `reservation_exists` and
|
|
@@ -105,6 +118,13 @@ uses `(loop_id, slot_id, phase, iteration)` for a versioned successor logical
|
|
|
105
118
|
turn. This is a Loop Engine rule shared by every kind, not implementation-loop
|
|
106
119
|
special handling.
|
|
107
120
|
|
|
121
|
+
At `handoff_ready`, the facade emits a structured `next_actions` call for
|
|
122
|
+
`bclaw_coordinate(intent='review', open_loop=true)`. It remains explicit: the
|
|
123
|
+
engine does not invent a reviewer or silently mutate external state. The
|
|
124
|
+
created review loop persists `linked.source_loop_id`, while the implementation
|
|
125
|
+
loop received the same provenance from its ideation source, so `list/get`
|
|
126
|
+
surfaces the pipeline chain without a separate registry.
|
|
127
|
+
|
|
108
128
|
## Recovery
|
|
109
129
|
|
|
110
130
|
- **Execute worker crashed mid-iteration.** Launch grant lease expires;
|
|
@@ -408,7 +408,11 @@ will still succeed. A follow-up PR will strip the dead handler code.
|
|
|
408
408
|
changelog records the published MCP surface fingerprint. When a tool
|
|
409
409
|
name, tier, category, or input schema changes, the test fails until
|
|
410
410
|
this section is updated.
|
|
411
|
-
- MCP public surface fingerprint: `sha256:
|
|
411
|
+
- MCP public surface fingerprint: `sha256:681c47cba85b79c3`
|
|
412
|
+
(`LoopSlotInput` gains optional `lane`, `scope_hint`, `plan_ids`, and
|
|
413
|
+
`step_ids` fields so implementation-loop lane scope and provenance survive
|
|
414
|
+
through the public facade. Existing callers remain valid.)
|
|
415
|
+
Previous: `sha256:81243f3d507c274e`
|
|
412
416
|
(updated 2026-08-23 for the common Loop Engine worker driver: `turn` exposes
|
|
413
417
|
real dispatch/model/candidate controls and `complete_turn` exposes the full
|
|
414
418
|
AttemptAuthority fence; bind remains engine-only with compatibility inputs.)
|