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
|
@@ -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
|
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { NextActionSchema } from '../facade-schema.js';
|
|
7
|
+
import { memoryDir, writeFileAtomic } from '../io.js';
|
|
8
|
+
import { nowISO } from '../ids.js';
|
|
9
|
+
import { mutate } from '../mutation-pipeline.js';
|
|
10
|
+
import { appendAuditEntry } from '../audit.js';
|
|
11
|
+
import { createRuntimeEvent } from '../events.js';
|
|
12
|
+
import { artifactEvidenceDigest, validateArtifactEvidence } from './evidence.js';
|
|
13
|
+
import { getLoop, listLoops } from './store.js';
|
|
14
|
+
export const CONTINUATION_POLICY_VERSION = 'continuation-policy-v1';
|
|
15
|
+
export const ContinuationDecisionSchema = z.enum(['auto', 'require_approval', 'deny']);
|
|
16
|
+
export const ContinuationStateSchema = z.enum([
|
|
17
|
+
'proposed',
|
|
18
|
+
'approval_required',
|
|
19
|
+
'denied',
|
|
20
|
+
'applying',
|
|
21
|
+
'applied',
|
|
22
|
+
'failed_recoverable',
|
|
23
|
+
]);
|
|
24
|
+
const ContinuationOwnerSchema = z.object({
|
|
25
|
+
token: z.string().min(1),
|
|
26
|
+
pid: z.number().int().positive(),
|
|
27
|
+
host_id: z.string().min(1),
|
|
28
|
+
started_at: z.string(),
|
|
29
|
+
});
|
|
30
|
+
export const ContinuationRecordSchema = z.object({
|
|
31
|
+
schema_version: z.literal(1),
|
|
32
|
+
id: z.string().regex(/^ctn_[a-f0-9]{24}$/),
|
|
33
|
+
continuation_key: z.string().regex(/^[a-f0-9]{64}$/),
|
|
34
|
+
policy_version: z.literal(CONTINUATION_POLICY_VERSION),
|
|
35
|
+
source_loop_id: z.string().regex(/^lop_[0-9a-z]+$/),
|
|
36
|
+
source_iteration: z.number().int().nonnegative(),
|
|
37
|
+
source_artifact_id: z.string().regex(/^art_[0-9a-z]+$/),
|
|
38
|
+
source_artifact_digest: z.string().regex(/^[a-f0-9]{64}$/),
|
|
39
|
+
action_index: z.number().int().nonnegative(),
|
|
40
|
+
action_hash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
41
|
+
action: NextActionSchema,
|
|
42
|
+
autonomy_mode: z.enum(['autonomous', 'require_approval', 'deny']),
|
|
43
|
+
risk: z.enum(['normal', 'protected']),
|
|
44
|
+
decision: ContinuationDecisionSchema,
|
|
45
|
+
reason: z.array(z.string().min(1)).min(1),
|
|
46
|
+
state: ContinuationStateSchema,
|
|
47
|
+
downstream: z.object({ kind: z.literal('loop'), id: z.string().regex(/^lop_[0-9a-z]+$/) }).optional(),
|
|
48
|
+
action_required_id: z.string().regex(/^act_[0-9a-z]+$/).optional(),
|
|
49
|
+
owner: ContinuationOwnerSchema.optional(),
|
|
50
|
+
last_error: z.string().optional(),
|
|
51
|
+
created_at: z.string(),
|
|
52
|
+
updated_at: z.string(),
|
|
53
|
+
});
|
|
54
|
+
function continuationsDir(cwd) {
|
|
55
|
+
return path.join(memoryDir(cwd ?? process.cwd()), 'loops', 'continuations');
|
|
56
|
+
}
|
|
57
|
+
function continuationPath(key, cwd) {
|
|
58
|
+
return path.join(continuationsDir(cwd), `${key}.json`);
|
|
59
|
+
}
|
|
60
|
+
function canonicalize(value) {
|
|
61
|
+
if (Array.isArray(value))
|
|
62
|
+
return value.map(canonicalize);
|
|
63
|
+
if (value && typeof value === 'object') {
|
|
64
|
+
return Object.fromEntries(Object.entries(value)
|
|
65
|
+
.filter(([, child]) => child !== undefined)
|
|
66
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
67
|
+
.map(([key, child]) => [key, canonicalize(child)]));
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
function digest(value) {
|
|
72
|
+
return crypto.createHash('sha256').update(JSON.stringify(canonicalize(value))).digest('hex');
|
|
73
|
+
}
|
|
74
|
+
function writeRecord(record, cwd) {
|
|
75
|
+
const parsed = ContinuationRecordSchema.parse(record);
|
|
76
|
+
fs.mkdirSync(continuationsDir(cwd), { recursive: true });
|
|
77
|
+
writeFileAtomic(continuationPath(parsed.continuation_key, cwd), `${JSON.stringify(parsed, null, 2)}\n`);
|
|
78
|
+
}
|
|
79
|
+
export function loadContinuation(idOrKey, cwd) {
|
|
80
|
+
const dir = continuationsDir(cwd);
|
|
81
|
+
if (!fs.existsSync(dir))
|
|
82
|
+
return undefined;
|
|
83
|
+
if (/^[a-f0-9]{64}$/.test(idOrKey)) {
|
|
84
|
+
const file = continuationPath(idOrKey, cwd);
|
|
85
|
+
if (!fs.existsSync(file))
|
|
86
|
+
return undefined;
|
|
87
|
+
return ContinuationRecordSchema.parse(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
88
|
+
}
|
|
89
|
+
for (const name of fs.readdirSync(dir).filter((entry) => entry.endsWith('.json'))) {
|
|
90
|
+
const record = ContinuationRecordSchema.parse(JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')));
|
|
91
|
+
if (record.id === idOrKey)
|
|
92
|
+
return record;
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
export function listContinuations(cwd) {
|
|
97
|
+
const dir = continuationsDir(cwd);
|
|
98
|
+
if (!fs.existsSync(dir))
|
|
99
|
+
return [];
|
|
100
|
+
return fs.readdirSync(dir).filter((entry) => entry.endsWith('.json')).map((entry) => ContinuationRecordSchema.parse(JSON.parse(fs.readFileSync(path.join(dir, entry), 'utf8')))).sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
101
|
+
}
|
|
102
|
+
function findDownstream(key, cwd) {
|
|
103
|
+
const matches = listLoops({}, cwd).filter((loop) => loop.linked?.continuation_key === key);
|
|
104
|
+
if (matches.length > 1) {
|
|
105
|
+
throw new Error(`continuation_ambiguity: ${key} is linked to ${matches.length} downstream loops`);
|
|
106
|
+
}
|
|
107
|
+
return matches[0];
|
|
108
|
+
}
|
|
109
|
+
function containsPlaceholder(value) {
|
|
110
|
+
if (typeof value === 'string')
|
|
111
|
+
return /<[^>]+>/.test(value);
|
|
112
|
+
if (Array.isArray(value))
|
|
113
|
+
return value.some(containsPlaceholder);
|
|
114
|
+
return Boolean(value && typeof value === 'object' && Object.values(value).some(containsPlaceholder));
|
|
115
|
+
}
|
|
116
|
+
export function evaluateContinuation(input) {
|
|
117
|
+
const evidence = validateArtifactEvidence(input.source_loop, input.source_artifact);
|
|
118
|
+
if (!evidence.valid)
|
|
119
|
+
throw new Error(`continuation_source_unattested: ${evidence.reasons.join(',')}`);
|
|
120
|
+
if (containsPlaceholder(input.action))
|
|
121
|
+
throw new Error('continuation_action_placeholder: action is not executable');
|
|
122
|
+
const args = input.action.args ?? {};
|
|
123
|
+
const ideationToImplementation = input.source_loop.kind === 'ideation'
|
|
124
|
+
&& input.source_artifact.type === 'plan_draft'
|
|
125
|
+
&& Boolean(input.source_artifact.implementation_verify)
|
|
126
|
+
&& input.action.tool === 'bclaw_loop'
|
|
127
|
+
&& args.intent === 'open'
|
|
128
|
+
&& args.kind === 'implementation';
|
|
129
|
+
const targets = Array.isArray(args.targetAgents) ? args.targetAgents : [];
|
|
130
|
+
const implementationToReview = input.source_loop.kind === 'implementation'
|
|
131
|
+
&& input.source_artifact.type === 'handoff'
|
|
132
|
+
&& Boolean(input.source_artifact.ref)
|
|
133
|
+
&& input.action.tool === 'bclaw_coordinate'
|
|
134
|
+
&& args.intent === 'review'
|
|
135
|
+
&& args.open_loop === true
|
|
136
|
+
&& targets.length === 1;
|
|
137
|
+
if (!ideationToImplementation && !implementationToReview) {
|
|
138
|
+
throw new Error('continuation_action_unsupported: expected Ideation→Implementation or Implementation→Review');
|
|
139
|
+
}
|
|
140
|
+
const sourceDigest = artifactEvidenceDigest(input.source_artifact);
|
|
141
|
+
const actionHash = digest(input.action);
|
|
142
|
+
const continuationKey = digest({
|
|
143
|
+
source_loop_id: input.source_loop.id,
|
|
144
|
+
source_iteration: input.source_artifact.iteration ?? input.source_loop.iteration_count,
|
|
145
|
+
source_artifact_digest: sourceDigest,
|
|
146
|
+
canonical_action_hash: actionHash,
|
|
147
|
+
policy_version: CONTINUATION_POLICY_VERSION,
|
|
148
|
+
});
|
|
149
|
+
const decision = input.autonomy_mode === 'deny'
|
|
150
|
+
? 'deny'
|
|
151
|
+
: input.autonomy_mode === 'require_approval' || input.risk === 'protected'
|
|
152
|
+
? 'require_approval'
|
|
153
|
+
: 'auto';
|
|
154
|
+
const evidenceReason = ideationToImplementation ? 'attested ideation plan_draft' : 'attested implementation handoff';
|
|
155
|
+
const actionReason = ideationToImplementation ? 'concrete implementation action' : 'concrete independent review action';
|
|
156
|
+
const reason = decision === 'auto'
|
|
157
|
+
? [evidenceReason, actionReason, 'normal risk under autonomous mode']
|
|
158
|
+
: decision === 'require_approval'
|
|
159
|
+
? [input.risk === 'protected' ? 'protected risk requires operator approval' : 'project autonomy mode requires approval']
|
|
160
|
+
: ['project autonomy mode denies continuation'];
|
|
161
|
+
return { continuation_key: continuationKey, source_artifact_digest: sourceDigest, action_hash: actionHash, decision, reason };
|
|
162
|
+
}
|
|
163
|
+
function ownerAlive(owner) {
|
|
164
|
+
if (owner.host_id !== os.hostname())
|
|
165
|
+
return true;
|
|
166
|
+
try {
|
|
167
|
+
process.kill(owner.pid, 0);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
return error.code === 'EPERM';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function audit(record, before, actor, actorId, cwd) {
|
|
175
|
+
appendAuditEntry({
|
|
176
|
+
actor, actor_id: actorId, action: before ? 'update' : 'create', item_id: record.id,
|
|
177
|
+
item_type: 'state', before: before ? { state: before } : undefined,
|
|
178
|
+
after: { state: record.state, decision: record.decision, continuation_key: record.continuation_key, downstream: record.downstream },
|
|
179
|
+
}, cwd);
|
|
180
|
+
createRuntimeEvent({
|
|
181
|
+
agent: actor, agent_id: actorId, event_type: 'observation',
|
|
182
|
+
text: `Continuation ${record.decision}: ${record.source_loop_id} → ${record.downstream?.id ?? record.state}`,
|
|
183
|
+
tags: ['loop-engine', 'continuation', `decision:${record.decision}`, `state:${record.state}`],
|
|
184
|
+
metadata: { protocol: CONTINUATION_POLICY_VERSION, continuation_id: record.id, continuation_key: record.continuation_key },
|
|
185
|
+
}, cwd);
|
|
186
|
+
}
|
|
187
|
+
export async function ensureContinuation(input, cwd) {
|
|
188
|
+
const proposal = evaluateContinuation(input);
|
|
189
|
+
const prepared = mutate({ cwd }, () => {
|
|
190
|
+
const existing = loadContinuation(proposal.continuation_key, cwd);
|
|
191
|
+
if (existing && existing.action_hash !== proposal.action_hash) {
|
|
192
|
+
throw new Error(`continuation_key_conflict: stored=${existing.action_hash} submitted=${proposal.action_hash}`);
|
|
193
|
+
}
|
|
194
|
+
const downstream = findDownstream(proposal.continuation_key, cwd);
|
|
195
|
+
if (downstream) {
|
|
196
|
+
const next = existing
|
|
197
|
+
? { ...existing, state: 'applied', downstream: { kind: 'loop', id: downstream.id }, owner: undefined, updated_at: nowISO() }
|
|
198
|
+
: undefined;
|
|
199
|
+
if (!next)
|
|
200
|
+
throw new Error('continuation_projection_missing: downstream exists without a continuation record');
|
|
201
|
+
writeRecord(next, cwd);
|
|
202
|
+
return { record: next, shouldExecute: false, reused: true };
|
|
203
|
+
}
|
|
204
|
+
if (existing?.state === 'applied' && existing.downstream)
|
|
205
|
+
return { record: existing, shouldExecute: false, reused: true };
|
|
206
|
+
if (existing?.state === 'denied' || existing?.state === 'approval_required') {
|
|
207
|
+
return { record: existing, shouldExecute: false, reused: true };
|
|
208
|
+
}
|
|
209
|
+
if (existing?.state === 'applying' && existing.owner && ownerAlive(existing.owner)) {
|
|
210
|
+
return { record: existing, shouldExecute: false, reused: true, executingElsewhere: true };
|
|
211
|
+
}
|
|
212
|
+
const now = nowISO();
|
|
213
|
+
const owner = { token: crypto.randomUUID(), pid: process.pid, host_id: os.hostname(), started_at: now };
|
|
214
|
+
const base = existing ?? {
|
|
215
|
+
schema_version: 1,
|
|
216
|
+
id: `ctn_${proposal.continuation_key.slice(0, 24)}`,
|
|
217
|
+
continuation_key: proposal.continuation_key,
|
|
218
|
+
policy_version: CONTINUATION_POLICY_VERSION,
|
|
219
|
+
source_loop_id: input.source_loop.id,
|
|
220
|
+
source_iteration: input.source_artifact.iteration ?? input.source_loop.iteration_count,
|
|
221
|
+
source_artifact_id: input.source_artifact.artifact_id,
|
|
222
|
+
source_artifact_digest: proposal.source_artifact_digest,
|
|
223
|
+
action_index: input.action_index,
|
|
224
|
+
action_hash: proposal.action_hash,
|
|
225
|
+
action: input.action,
|
|
226
|
+
autonomy_mode: input.autonomy_mode,
|
|
227
|
+
risk: input.risk,
|
|
228
|
+
decision: proposal.decision,
|
|
229
|
+
reason: proposal.reason,
|
|
230
|
+
state: 'proposed',
|
|
231
|
+
created_at: now,
|
|
232
|
+
updated_at: now,
|
|
233
|
+
};
|
|
234
|
+
const state = proposal.decision === 'deny' ? 'denied' : proposal.decision === 'require_approval' ? 'approval_required' : 'applying';
|
|
235
|
+
const record = {
|
|
236
|
+
...base,
|
|
237
|
+
decision: proposal.decision,
|
|
238
|
+
reason: existing?.reason ?? proposal.reason,
|
|
239
|
+
state,
|
|
240
|
+
owner: state === 'applying' ? owner : undefined,
|
|
241
|
+
updated_at: now,
|
|
242
|
+
};
|
|
243
|
+
writeRecord(record, cwd);
|
|
244
|
+
audit(record, existing?.state, input.actor, input.actor_id, cwd);
|
|
245
|
+
return { record, shouldExecute: state === 'applying', reused: Boolean(existing) };
|
|
246
|
+
});
|
|
247
|
+
if (!prepared.shouldExecute) {
|
|
248
|
+
return { record: prepared.record, reused: prepared.reused, executing_elsewhere: prepared.executingElsewhere };
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const downstream = await input.execute(prepared.record);
|
|
252
|
+
const committed = mutate({ cwd }, () => {
|
|
253
|
+
const current = loadContinuation(prepared.record.continuation_key, cwd);
|
|
254
|
+
if (!current)
|
|
255
|
+
throw new Error('continuation_record_disappeared');
|
|
256
|
+
if (current.owner?.token !== prepared.record.owner?.token)
|
|
257
|
+
throw new Error('continuation_owner_fenced');
|
|
258
|
+
const record = { ...current, state: 'applied', downstream, owner: undefined, updated_at: nowISO() };
|
|
259
|
+
writeRecord(record, cwd);
|
|
260
|
+
audit(record, current.state, input.actor, input.actor_id, cwd);
|
|
261
|
+
return record;
|
|
262
|
+
});
|
|
263
|
+
return { record: committed, reused: prepared.reused };
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
mutate({ cwd }, () => {
|
|
267
|
+
const current = loadContinuation(prepared.record.continuation_key, cwd);
|
|
268
|
+
if (!current || current.owner?.token !== prepared.record.owner?.token)
|
|
269
|
+
return;
|
|
270
|
+
const record = { ...current, state: 'failed_recoverable', owner: undefined, last_error: error instanceof Error ? error.message : String(error), updated_at: nowISO() };
|
|
271
|
+
writeRecord(record, cwd);
|
|
272
|
+
audit(record, current.state, input.actor, input.actor_id, cwd);
|
|
273
|
+
});
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
export function attachContinuationActionRequired(continuationId, actionId, actor, actorId, cwd) {
|
|
278
|
+
return mutate({ cwd }, () => {
|
|
279
|
+
const current = loadContinuation(continuationId, cwd);
|
|
280
|
+
if (!current)
|
|
281
|
+
throw new Error(`unknown continuation ${continuationId}`);
|
|
282
|
+
if (current.state !== 'approval_required')
|
|
283
|
+
throw new Error(`continuation ${continuationId} is ${current.state}, not approval_required`);
|
|
284
|
+
if (current.action_required_id && current.action_required_id !== actionId)
|
|
285
|
+
throw new Error('continuation_action_required_conflict');
|
|
286
|
+
const record = { ...current, action_required_id: actionId, updated_at: nowISO() };
|
|
287
|
+
writeRecord(record, cwd);
|
|
288
|
+
audit(record, current.state, actor, actorId, cwd);
|
|
289
|
+
return record;
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
export function denyContinuation(continuationId, reason, actor, actorId, cwd) {
|
|
293
|
+
return mutate({ cwd }, () => {
|
|
294
|
+
const current = loadContinuation(continuationId, cwd);
|
|
295
|
+
if (!current)
|
|
296
|
+
throw new Error(`unknown continuation ${continuationId}`);
|
|
297
|
+
if (current.state === 'applied')
|
|
298
|
+
throw new Error(`continuation ${continuationId} is already applied`);
|
|
299
|
+
if (current.state === 'denied')
|
|
300
|
+
return current;
|
|
301
|
+
const record = {
|
|
302
|
+
...current,
|
|
303
|
+
decision: 'deny',
|
|
304
|
+
state: 'denied',
|
|
305
|
+
owner: undefined,
|
|
306
|
+
reason: [...current.reason, reason],
|
|
307
|
+
updated_at: nowISO(),
|
|
308
|
+
};
|
|
309
|
+
writeRecord(record, cwd);
|
|
310
|
+
audit(record, current.state, actor, actorId, cwd);
|
|
311
|
+
return record;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
export async function resumeApprovedContinuation(continuationId, actionId, actor, actorId, execute, cwd) {
|
|
315
|
+
const record = loadContinuation(continuationId, cwd);
|
|
316
|
+
if (!record)
|
|
317
|
+
throw new Error(`unknown continuation ${continuationId}`);
|
|
318
|
+
if (record.action_required_id !== actionId)
|
|
319
|
+
throw new Error('continuation_approval_mismatch');
|
|
320
|
+
const source = getLoop(record.source_loop_id, cwd);
|
|
321
|
+
const artifact = source?.artifacts.find((item) => item.artifact_id === record.source_artifact_id);
|
|
322
|
+
if (!source || !artifact)
|
|
323
|
+
throw new Error('continuation_source_missing');
|
|
324
|
+
mutate({ cwd }, () => {
|
|
325
|
+
const fresh = loadContinuation(continuationId, cwd);
|
|
326
|
+
if (fresh.state === 'applied')
|
|
327
|
+
return;
|
|
328
|
+
if (fresh.state !== 'approval_required' && fresh.state !== 'failed_recoverable')
|
|
329
|
+
throw new Error(`continuation ${continuationId} is ${fresh.state}`);
|
|
330
|
+
writeRecord({ ...fresh, state: 'failed_recoverable', autonomy_mode: 'autonomous', decision: 'auto', reason: [...fresh.reason, `approved by ${actor}`], updated_at: nowISO() }, cwd);
|
|
331
|
+
});
|
|
332
|
+
return ensureContinuation({
|
|
333
|
+
source_loop: source, source_artifact: artifact, action: record.action, action_index: record.action_index,
|
|
334
|
+
autonomy_mode: 'autonomous', risk: 'normal', actor, actor_id: actorId, execute,
|
|
335
|
+
}, cwd);
|
|
336
|
+
}
|
|
337
|
+
//# sourceMappingURL=continuation.js.map
|
|
@@ -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,
|
|
@@ -201,6 +234,19 @@ export const BclawLoopBindSchema = z.object({
|
|
|
201
234
|
// No expected_version: bind is idempotent by loop phase (past `bind` → noop), not CAS.
|
|
202
235
|
...CallerEnvelopeFields,
|
|
203
236
|
});
|
|
237
|
+
/**
|
|
238
|
+
* Evaluate and apply one persisted cross-loop continuation. This is an
|
|
239
|
+
* orchestration intent: the downstream mutation still traverses the public
|
|
240
|
+
* `open` and `bind` handlers, never a private Loop-store shortcut.
|
|
241
|
+
*/
|
|
242
|
+
export const BclawLoopContinueSchema = z.object({
|
|
243
|
+
intent: z.literal('continue'),
|
|
244
|
+
loop_id: z.string().regex(/^lop_[0-9a-z]+$/),
|
|
245
|
+
action_index: z.number().int().nonnegative().default(0),
|
|
246
|
+
autonomy_mode: z.enum(['autonomous', 'require_approval', 'deny']).default('autonomous'),
|
|
247
|
+
risk: z.enum(['normal', 'protected']).default('normal'),
|
|
248
|
+
...CallerEnvelopeFields,
|
|
249
|
+
});
|
|
204
250
|
/**
|
|
205
251
|
* pln#508 step 2 — `bclaw_loop(intent='request_input')`.
|
|
206
252
|
*
|
|
@@ -273,6 +319,7 @@ export const BclawLoopRequestSchema = z.discriminatedUnion('intent', [
|
|
|
273
319
|
BclawLoopCloseSchema,
|
|
274
320
|
BclawLoopVerifySchema,
|
|
275
321
|
BclawLoopBindSchema,
|
|
322
|
+
BclawLoopContinueSchema,
|
|
276
323
|
BclawLoopRequestInputSchema,
|
|
277
324
|
BclawLoopProvideInputSchema,
|
|
278
325
|
]);
|
|
@@ -290,6 +337,7 @@ export const BCLAW_LOOP_INTENTS = [
|
|
|
290
337
|
'close',
|
|
291
338
|
'verify',
|
|
292
339
|
'bind',
|
|
340
|
+
'continue',
|
|
293
341
|
'request_input',
|
|
294
342
|
'provide_input',
|
|
295
343
|
];
|