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.
Files changed (35) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/cli/register-coordination.js +12 -0
  3. package/dist/commands/loop.js +12 -0
  4. package/dist/commands/loops-handlers.js +261 -2
  5. package/dist/commands/mcp-catalog.js +16 -3
  6. package/dist/commands/mcp-schemas.generated.js +20 -0
  7. package/dist/commands/mcp-write-claims.js +55 -8
  8. package/dist/commands/mcp-write-coordination.js +3 -0
  9. package/dist/core/actions.js +17 -3
  10. package/dist/core/execution-adapters.js +29 -0
  11. package/dist/core/facade-schema.js +3 -0
  12. package/dist/core/loop-turn-dispatch.js +31 -3
  13. package/dist/core/loops/attempt-authority.js +20 -0
  14. package/dist/core/loops/brief-assembly.js +21 -4
  15. package/dist/core/loops/continuation.js +337 -0
  16. package/dist/core/loops/evidence.js +1 -0
  17. package/dist/core/loops/facade-schema.js +49 -1
  18. package/dist/core/loops/gate-policy.js +52 -4
  19. package/dist/core/loops/impl-bind.js +58 -6
  20. package/dist/core/loops/index.js +1 -0
  21. package/dist/core/loops/reconcile-turn.js +2 -0
  22. package/dist/core/loops/result-reducers.js +15 -1
  23. package/dist/core/loops/store.js +4 -0
  24. package/dist/core/loops/types.js +20 -1
  25. package/dist/core/loops/verbs.js +3 -0
  26. package/dist/core/loops/verify-command.js +77 -15
  27. package/dist/core/reviewer-policy.js +39 -0
  28. package/dist/core/schema.js +21 -1
  29. package/dist/facts.js +7 -7
  30. package/dist/facts.json +6 -6
  31. package/docs/cli.md +4 -2
  32. package/docs/concepts/loop-engine.md +25 -0
  33. package/docs/loops/implementation.md +20 -0
  34. package/docs/mcp-schema-changelog.md +10 -1
  35. 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: input.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: input.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() ?? '(no proposal seed found)';
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
- `# ideation_loop brief`,
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.object({
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
  ];