brainclaw 1.28.0 → 1.28.2

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 (39) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/cli/register-coordination.js +12 -0
  3. package/dist/commands/code-map.js +2 -0
  4. package/dist/commands/doctor.js +1 -0
  5. package/dist/commands/harvest.js +32 -43
  6. package/dist/commands/loop.js +12 -0
  7. package/dist/commands/loops-handlers.js +284 -17
  8. package/dist/commands/mcp-catalog.js +6 -3
  9. package/dist/commands/mcp-write-claims.js +55 -8
  10. package/dist/commands/mcp-write-coordination.js +413 -137
  11. package/dist/commands/mcp.js +32 -4
  12. package/dist/core/actions.js +17 -3
  13. package/dist/core/agentrun-reconciler.js +138 -4
  14. package/dist/core/claims.js +4 -1
  15. package/dist/core/code-map/backend.js +8 -0
  16. package/dist/core/execution-adapters.js +15 -7
  17. package/dist/core/hygiene-policy.js +2 -1
  18. package/dist/core/loop-turn-dispatch.js +18 -1
  19. package/dist/core/loops/attempt-authority.js +22 -4
  20. package/dist/core/loops/attempt-generations.js +17 -4
  21. package/dist/core/loops/attempt-reservation.js +14 -1
  22. package/dist/core/loops/attempt-takeover.js +173 -76
  23. package/dist/core/loops/continuation.js +337 -0
  24. package/dist/core/loops/facade-schema.js +15 -0
  25. package/dist/core/loops/index.js +1 -0
  26. package/dist/core/loops/reconcile-turn.js +224 -26
  27. package/dist/core/loops/result-reducers.js +8 -8
  28. package/dist/core/loops/turn-execution.js +38 -19
  29. package/dist/core/loops/types.js +9 -0
  30. package/dist/core/loops/verbs.js +1 -1
  31. package/dist/core/reviewer-policy.js +39 -0
  32. package/dist/core/schema.js +16 -1
  33. package/dist/facts.js +8 -8
  34. package/dist/facts.json +7 -7
  35. package/docs/cli.md +4 -2
  36. package/docs/code-map.md +10 -0
  37. package/docs/concepts/loop-engine.md +30 -0
  38. package/docs/mcp-schema-changelog.md +6 -1
  39. package/package.json +1 -1
@@ -446,7 +446,20 @@ export function findReservationByAssignmentId(assignmentId, cwd) {
446
446
  // coexists with a real LANE-RESULT (dispatch commits before spawn). Filtering here makes
447
447
  // the turn-owned discriminator explicit — a `prepared`/`aborted` reservation must never
448
448
  // route a lane to reconcileTurn (it has no live launch generation to accept evidence for).
449
- return listReservations({ decision: 'committed' }, cwd).find((r) => r.child_ids.assignment_id === assignmentId);
449
+ return listReservations({ decision: 'committed' }, cwd).find((reservation) => {
450
+ if (reservation.child_ids.assignment_id === assignmentId)
451
+ return true;
452
+ try {
453
+ const root = cwd ?? reservation.store_root;
454
+ const initial = readInitialGeneration(root, reservation.turn_id);
455
+ return initial
456
+ ? listAttemptGenerations(root, initial).some((generation) => generation.assignment_id === assignmentId)
457
+ : false;
458
+ }
459
+ catch {
460
+ return false;
461
+ }
462
+ });
450
463
  }
451
464
  /** Run-scoping key for runtime evidence; undefined preserves legacy assignment-scoped paths. */
452
465
  export function currentAttemptRunIdForAssignment(assignmentId, cwd) {
@@ -1,16 +1,36 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import { ensureAgentRunProjection, loadAgentRun, transitionAgentRun } from '../agentruns.js';
4
- import { loadAssignment } from '../assignments.js';
4
+ import { convergeAssignmentToTerminal, loadAssignment, transitionAssignment } from '../assignments.js';
5
5
  import { createRuntimeEvent } from '../events.js';
6
6
  import { nowISO } from '../ids.js';
7
7
  import { prepareAttemptTakeoverV2 } from './attempt-authority.js';
8
- import { generationDigest } from './attempt-generations.js';
8
+ import { generationDigest, resolveTurnGenerationChain } from './attempt-generations.js';
9
9
  import { commitViaIntent } from './commit-intent.js';
10
10
  import { evidenceDigest } from './evidence.js';
11
11
  import { withLoopLock } from './lock.js';
12
12
  import { getReservation } from './attempt-reservation.js';
13
13
  import { getLoop, listLoopEvents } from './store.js';
14
+ /**
15
+ * Signals that the immutable successor generation already won even though a
16
+ * replayable projection failed afterward. Callers must never roll claims back
17
+ * on this error; replaying the same takeover input repairs the projections.
18
+ */
19
+ export class AttemptTakeoverCommittedError extends Error {
20
+ turn_id;
21
+ assignment_id;
22
+ run_id;
23
+ attempt_epoch;
24
+ constructor(takeover, cause) {
25
+ super(`takeover generation ${takeover.next_generation.attempt_epoch} committed; projection repair required: ${cause instanceof Error ? cause.message : String(cause)}`);
26
+ this.name = 'AttemptTakeoverCommittedError';
27
+ this.turn_id = takeover.next_generation.turn_id;
28
+ this.assignment_id = takeover.next_generation.assignment_id;
29
+ this.run_id = takeover.next_generation.run_id;
30
+ this.attempt_epoch = takeover.next_generation.attempt_epoch;
31
+ this.cause = cause;
32
+ }
33
+ }
14
34
  /**
15
35
  * Operator/engine takeover transaction.
16
36
  *
@@ -25,66 +45,137 @@ export function takeoverLoopAttempt(input) {
25
45
  throw new Error(`takeover workspace must already exist as an isolated directory: ${input.next_workspace_path}`);
26
46
  }
27
47
  const authorityActor = input.actor_id ?? input.actor;
28
- const transaction = withLoopLock({
29
- cwd: input.cwd,
30
- intent: 'attempt-takeover',
31
- agentId: authorityActor,
32
- scope: { kind: 'loop', loopId: input.loop_id },
33
- work: () => {
34
- const loop = getLoop(input.loop_id, input.cwd);
35
- if (!loop || loop.status !== 'open')
36
- throw new Error(`loop ${input.loop_id} is not open`);
37
- if (loop.created_by !== authorityActor) {
38
- throw new Error(`attempt takeover requires loop coordinator ${loop.created_by}; caller is ${authorityActor}`);
48
+ let committed;
49
+ let transaction;
50
+ try {
51
+ transaction = withLoopLock({
52
+ cwd: input.cwd,
53
+ intent: 'attempt-takeover',
54
+ agentId: authorityActor,
55
+ scope: { kind: 'loop', loopId: input.loop_id },
56
+ work: () => {
57
+ const loop = getLoop(input.loop_id, input.cwd);
58
+ if (!loop || loop.status !== 'open')
59
+ throw new Error(`loop ${input.loop_id} is not open`);
60
+ if (loop.created_by !== authorityActor) {
61
+ throw new Error(`attempt takeover requires loop coordinator ${loop.created_by}; caller is ${authorityActor}`);
62
+ }
63
+ const slot = loop.slots.find((candidate) => candidate.slot_id === input.slot_id);
64
+ if (!slot)
65
+ throw new Error(`slot ${input.slot_id} not found in loop ${input.loop_id}`);
66
+ const reservation = getReservation(input.turn_id, input.cwd);
67
+ if (!reservation || reservation.loop_id !== loop.id || reservation.slot_id !== slot.slot_id) {
68
+ throw new Error(`turn ${input.turn_id} does not own ${loop.id}/${slot.slot_id}`);
69
+ }
70
+ const activeGeneration = resolveTurnGenerationChain(input.cwd, input.turn_id)?.latest_generation;
71
+ const activeAssignmentId = activeGeneration?.assignment_id ?? reservation.child_ids.assignment_id;
72
+ if (slot.current_turn_id !== input.turn_id || slot.assignment_id !== activeAssignmentId) {
73
+ throw new Error(`slot ${slot.slot_id} is no longer bound to turn ${input.turn_id}`);
74
+ }
75
+ const takeover = prepareAttemptTakeoverV2({ ...input, actor: authorityActor });
76
+ committed = takeover;
77
+ input.on_stage?.('authority_committed');
78
+ const duplicate = listLoopEvents(loop.id, input.cwd).some((event) => event.kind === 'attempt_generation_changed'
79
+ && event.turn_id === input.turn_id
80
+ && event.to_epoch === takeover.next_generation.attempt_epoch
81
+ && event.to_run_id === takeover.next_generation.run_id);
82
+ if (duplicate)
83
+ return { loop, takeover, reservation };
84
+ const now = nowISO();
85
+ const mutationId = crypto.randomUUID();
86
+ const executor = takeover.next_generation.executor ?? {
87
+ agent: reservation.agent,
88
+ agent_id: reservation.agent_id,
89
+ claim_id: reservation.claim_id,
90
+ capability_snapshot: reservation.capability_snapshot,
91
+ };
92
+ const event = {
93
+ event_id: crypto.randomUUID(),
94
+ loop_id: loop.id,
95
+ seq: loop.version + 1,
96
+ at: now,
97
+ by: authorityActor,
98
+ mutation_id: mutationId,
99
+ kind: 'attempt_generation_changed',
100
+ slot_id: slot.slot_id,
101
+ turn_id: input.turn_id,
102
+ assignment_id: takeover.next_generation.assignment_id,
103
+ claim_id: executor.claim_id,
104
+ agent: executor.agent,
105
+ agent_id: executor.agent_id,
106
+ from_epoch: takeover.previous_generation.attempt_epoch,
107
+ to_epoch: takeover.next_generation.attempt_epoch,
108
+ from_run_id: takeover.previous_generation.run_id,
109
+ to_run_id: takeover.next_generation.run_id,
110
+ close_digest: evidenceDigest(takeover.close_cell),
111
+ cause: input.cause,
112
+ };
113
+ const next = {
114
+ ...loop,
115
+ version: loop.version + 1,
116
+ mutation_id: mutationId,
117
+ slots: loop.slots.map((candidate) => candidate.slot_id === slot.slot_id
118
+ ? {
119
+ ...candidate,
120
+ agent: executor.agent,
121
+ agent_id: executor.agent_id,
122
+ assignment_id: takeover.next_generation.assignment_id,
123
+ claim_id: executor.claim_id,
124
+ current_turn_id: input.turn_id,
125
+ status: 'assigned',
126
+ }
127
+ : candidate),
128
+ updated_at: now,
129
+ };
130
+ commitViaIntent({ loop_id: loop.id, base_version: loop.version, events: [event], thread_snapshot: next }, input.cwd);
131
+ input.on_stage?.('loop_committed');
132
+ return { loop: next, takeover, reservation };
133
+ },
134
+ });
135
+ }
136
+ catch (error) {
137
+ if (committed)
138
+ throw new AttemptTakeoverCommittedError(committed, error);
139
+ throw error;
140
+ }
141
+ const { takeover, reservation } = transaction;
142
+ const executor = takeover.next_generation.executor ?? {
143
+ agent: reservation.agent,
144
+ agent_id: reservation.agent_id,
145
+ claim_id: reservation.claim_id,
146
+ capability_snapshot: reservation.capability_snapshot,
147
+ };
148
+ if (takeover.previous_generation.assignment_id !== takeover.next_generation.assignment_id) {
149
+ try {
150
+ const previousAssignmentId = takeover.previous_generation.assignment_id;
151
+ const previousAssignment = loadAssignment(previousAssignmentId, input.cwd);
152
+ const predecessorTerminal = input.predecessor_assignment_terminal ?? 'cancelled';
153
+ const statusReason = `fenced by ${input.mode ?? 'takeover'} to epoch ${takeover.next_generation.attempt_epoch}`;
154
+ // System convergence intentionally covers only file-worker states
155
+ // (offered/accepted/started). A takeover can win before launch while the
156
+ // predecessor is still created, or during retry setup; both states have
157
+ // a legal direct cancellation edge and must be terminal before the stable
158
+ // claim is rebound to the successor generation.
159
+ if (predecessorTerminal === 'rerouted' && previousAssignment && previousAssignment.status !== 'rerouted') {
160
+ transitionAssignment(previousAssignmentId, 'rerouted', {
161
+ actor: input.actor,
162
+ syncAgentRun: false,
163
+ status_reason: statusReason,
164
+ }, input.cwd);
39
165
  }
40
- const slot = loop.slots.find((candidate) => candidate.slot_id === input.slot_id);
41
- if (!slot)
42
- throw new Error(`slot ${input.slot_id} not found in loop ${input.loop_id}`);
43
- const reservation = getReservation(input.turn_id, input.cwd);
44
- if (!reservation || reservation.loop_id !== loop.id || reservation.slot_id !== slot.slot_id) {
45
- throw new Error(`turn ${input.turn_id} does not own ${loop.id}/${slot.slot_id}`);
166
+ else if (previousAssignment?.status === 'created' || previousAssignment?.status === 'retrying') {
167
+ transitionAssignment(previousAssignmentId, 'cancelled', {
168
+ actor: input.actor,
169
+ syncAgentRun: false,
170
+ status_reason: statusReason,
171
+ }, input.cwd);
46
172
  }
47
- if (slot.current_turn_id !== input.turn_id || slot.assignment_id !== reservation.child_ids.assignment_id) {
48
- throw new Error(`slot ${slot.slot_id} is no longer bound to turn ${input.turn_id}`);
173
+ else {
174
+ convergeAssignmentToTerminal(previousAssignmentId, 'cancelled', statusReason, input.cwd);
49
175
  }
50
- const takeover = prepareAttemptTakeoverV2({ ...input, actor: authorityActor });
51
- const duplicate = listLoopEvents(loop.id, input.cwd).some((event) => event.kind === 'attempt_generation_changed'
52
- && event.turn_id === input.turn_id
53
- && event.to_epoch === takeover.next_generation.attempt_epoch
54
- && event.to_run_id === takeover.next_generation.run_id);
55
- if (duplicate)
56
- return { loop, takeover, reservation };
57
- const now = nowISO();
58
- const mutationId = crypto.randomUUID();
59
- const event = {
60
- event_id: crypto.randomUUID(),
61
- loop_id: loop.id,
62
- seq: loop.version + 1,
63
- at: now,
64
- by: authorityActor,
65
- mutation_id: mutationId,
66
- kind: 'attempt_generation_changed',
67
- slot_id: slot.slot_id,
68
- turn_id: input.turn_id,
69
- assignment_id: takeover.next_generation.assignment_id,
70
- from_epoch: takeover.previous_generation.attempt_epoch,
71
- to_epoch: takeover.next_generation.attempt_epoch,
72
- from_run_id: takeover.previous_generation.run_id,
73
- to_run_id: takeover.next_generation.run_id,
74
- close_digest: evidenceDigest(takeover.close_cell),
75
- cause: input.cause,
76
- };
77
- const next = {
78
- ...loop,
79
- version: loop.version + 1,
80
- mutation_id: mutationId,
81
- updated_at: now,
82
- };
83
- commitViaIntent({ loop_id: loop.id, base_version: loop.version, events: [event], thread_snapshot: next }, input.cwd);
84
- return { loop: next, takeover, reservation };
85
- },
86
- });
87
- const { takeover, reservation } = transaction;
176
+ }
177
+ catch { /* immutable close cell already fences the old assignment */ }
178
+ }
88
179
  const assignment = loadAssignment(takeover.next_generation.assignment_id, input.cwd);
89
180
  const previousRun = loadAgentRun(takeover.previous_generation.run_id, input.cwd);
90
181
  if (previousRun && !['completed', 'failed', 'cancelled', 'timed_out', 'interrupted'].includes(previousRun.status)) {
@@ -97,23 +188,29 @@ export function takeoverLoopAttempt(input) {
97
188
  }
98
189
  catch { /* immutable close cell already fences the old run */ }
99
190
  }
100
- ensureAgentRunProjection({
101
- id: takeover.next_generation.run_id,
102
- short_label: takeover.next_generation.run_id,
103
- assignment_id: takeover.next_generation.assignment_id,
104
- claim_id: reservation.claim_id,
105
- attempt_index: takeover.next_generation.attempt_epoch + 1,
106
- agent: reservation.agent,
107
- agent_id: reservation.agent_id,
108
- transport: 'cli_spawn',
109
- status: 'created',
110
- scope: assignment?.scope ?? reservation.execution_contract?.workspace_policy.scope ?? reservation.cwd,
111
- description: assignment?.description ?? `Attempt generation ${takeover.next_generation.attempt_epoch} for ${input.turn_id}`,
112
- worktree_path: takeover.next_generation.workspace_path,
113
- execution_contract_ref: takeover.execution_contract_ref,
114
- capability_snapshot: reservation.capability_snapshot,
115
- tags: ['turn-owned', 'loop', 'attempt-takeover', `attempt-generation:${takeover.next_generation.attempt_epoch}`],
116
- }, input.cwd);
191
+ try {
192
+ ensureAgentRunProjection({
193
+ id: takeover.next_generation.run_id,
194
+ short_label: takeover.next_generation.run_id,
195
+ assignment_id: takeover.next_generation.assignment_id,
196
+ claim_id: executor.claim_id,
197
+ attempt_index: takeover.next_generation.attempt_epoch + 1,
198
+ agent: executor.agent,
199
+ agent_id: executor.agent_id,
200
+ transport: 'cli_spawn',
201
+ status: 'created',
202
+ scope: assignment?.scope ?? reservation.execution_contract?.workspace_policy.scope ?? reservation.cwd,
203
+ description: assignment?.description ?? `Attempt generation ${takeover.next_generation.attempt_epoch} for ${input.turn_id}`,
204
+ worktree_path: takeover.next_generation.workspace_path,
205
+ execution_contract_ref: takeover.execution_contract_ref,
206
+ capability_snapshot: executor.capability_snapshot,
207
+ tags: ['turn-owned', 'loop', 'attempt-takeover', `attempt-generation:${takeover.next_generation.attempt_epoch}`],
208
+ }, input.cwd);
209
+ input.on_stage?.('run_projected');
210
+ }
211
+ catch (error) {
212
+ throw new AttemptTakeoverCommittedError(takeover, error);
213
+ }
117
214
  try {
118
215
  createRuntimeEvent({
119
216
  agent: input.actor,
@@ -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