@chrok/braid 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/types.d.ts CHANGED
@@ -3,6 +3,14 @@ export interface ExecuteNode {
3
3
  id: string;
4
4
  prompt: string;
5
5
  model?: string;
6
+ /** Ask the parent adapter for a completion reminder on success or failure. Default: false. */
7
+ notifyOnCompletion?: boolean;
8
+ /** Optional failures remain in history; true aborts the entire run on failure. */
9
+ requireSuccess?: boolean;
10
+ /** Hold this execution's outgoing dependencies until explicitly resumed. */
11
+ pauseAfter?: boolean;
12
+ /** Fresh predecessor snapshot in Git; read-only disables writes. Read-only outside Git. */
13
+ workspace?: "read-only" | "worktree";
6
14
  }
7
15
  export interface DecisionNode {
8
16
  type: "decision";
@@ -10,18 +18,52 @@ export interface DecisionNode {
10
18
  prompt: string;
11
19
  choices: readonly string[];
12
20
  model?: string;
21
+ /** Ask the parent adapter for a completion reminder on success or failure. Default: false. */
22
+ notifyOnCompletion?: boolean;
23
+ /** Optional failures remain in history; true aborts the entire run on failure. */
24
+ requireSuccess?: boolean;
25
+ /** Hold this execution's outgoing dependencies until explicitly resumed. */
26
+ pauseAfter?: boolean;
27
+ /** Fresh predecessor snapshot in Git; read-only disables writes. Read-only outside Git. */
28
+ workspace?: "read-only" | "worktree";
13
29
  }
14
30
  export interface MergeNode {
15
31
  type: "merge";
16
32
  id: string;
17
- /** Defaults to reviewing and integrating all predecessor workspaces. */
33
+ /** Defaults to combining selected predecessor checkpoints in a new worktree. */
18
34
  prompt?: string;
19
35
  model?: string;
36
+ /** Ask the parent adapter for a completion reminder on success or failure. Default: false. */
37
+ notifyOnCompletion?: boolean;
38
+ /** Optional failures remain in history; true aborts the entire run on failure. */
39
+ requireSuccess?: boolean;
40
+ /** Hold this execution's outgoing dependencies until explicitly resumed. */
41
+ pauseAfter?: boolean;
20
42
  }
21
- export type BraidNode = ExecuteNode | DecisionNode | MergeNode;
43
+ export interface IntegrateNode extends Omit<MergeNode, "type"> {
44
+ type: "integrate";
45
+ }
46
+ export type BraidNode = ExecuteNode | DecisionNode | MergeNode | IntegrateNode;
47
+ /** A reference to a template in this graph submission; values are inserted literally. */
48
+ export interface PromptTemplateReference {
49
+ template: string;
50
+ variables: Readonly<Record<string, string>>;
51
+ }
52
+ export type NodePrompt = string | PromptTemplateReference;
53
+ /** Submission nodes may use templates; ModelRequest.node always has a string prompt. */
54
+ export type BraidInputNode = (Omit<ExecuteNode, "prompt"> & {
55
+ prompt: NodePrompt;
56
+ }) | (Omit<DecisionNode, "prompt"> & {
57
+ prompt: NodePrompt;
58
+ }) | (Omit<MergeNode, "prompt"> & {
59
+ prompt?: NodePrompt;
60
+ }) | (Omit<IntegrateNode, "prompt"> & {
61
+ prompt?: NodePrompt;
62
+ });
22
63
  export interface NodeWorkspace {
23
64
  nodeId: string;
24
- mode: "read-only" | "worktree" | "merge";
65
+ executionId?: string;
66
+ mode: "read-only" | "worktree" | "integrate";
25
67
  workingDirectory: string;
26
68
  worktreeRoot?: string;
27
69
  sourceRoot?: string;
@@ -31,11 +73,13 @@ export interface NodeWorkspace {
31
73
  checkpointCommit?: string;
32
74
  /** Source checkout snapshot captured before a merge agent receives write access. */
33
75
  backupRef?: string;
76
+ /** Agent decisions for this merge/integrate invocation; sources remain reusable. */
77
+ dispositions?: MergeDisposition[];
34
78
  state: "preparing" | "ready" | "integrated" | "discarded" | "archived" | "failed";
35
79
  reason?: string;
36
80
  }
37
81
  export interface MergeDisposition {
38
- nodeId: string;
82
+ executionId: string;
39
83
  disposition: "integrated" | "discarded" | "archived";
40
84
  reason: string;
41
85
  }
@@ -44,7 +88,7 @@ export interface GitPreview {
44
88
  truncated: boolean;
45
89
  }
46
90
  export interface MergeSource extends NodeWorkspace {
47
- /** Inspection-only summaries relative to snapshotCommit; omitted by custom runners. */
91
+ /** Inspection-only summaries relative to the job’s initial snapshot; omitted by custom runners. */
48
92
  changes?: {
49
93
  files: string[];
50
94
  filesTruncated: boolean;
@@ -66,11 +110,23 @@ export interface Edge {
66
110
  to: string;
67
111
  /** Omit for an unconditional edge, including from a decision node. */
68
112
  choice?: string;
113
+ /** Explicit feedback edge into the named loop's entry. */
114
+ feedback?: string;
115
+ /** Pin a dependency to a historical execution rather than the current round. */
116
+ executionId?: string;
117
+ }
118
+ export interface LoopDefinition {
119
+ id: string;
120
+ entry: string;
121
+ maxIterations: number;
69
122
  }
70
123
  export interface BraidInput {
71
124
  goal: string;
72
- nodes: readonly BraidNode[];
125
+ nodes: readonly BraidInputNode[];
73
126
  edges: readonly Edge[];
127
+ /** Named {{variable}} templates, expanded and validated before execution. */
128
+ promptTemplates?: Readonly<Record<string, string>>;
129
+ loops?: readonly LoopDefinition[];
74
130
  }
75
131
  export type NodeStatus = "pending" | "runnable" | "running" | "completed" | "skipped" | "failed";
76
132
  export interface TokenUsage {
@@ -84,14 +140,19 @@ export interface NodeOutput {
84
140
  }
85
141
  export interface PredecessorOutput extends NodeOutput {
86
142
  nodeId: string;
143
+ executionId?: string;
87
144
  /** Failed predecessors remain available on unconditional edges. */
88
145
  error?: ExecutionError;
89
146
  workspace?: NodeWorkspace;
90
147
  }
91
148
  export interface ExecutionContext {
92
149
  runId: string;
93
- /** Equal to runId in v0.1; reserved identity for future shared-root accounting. */
150
+ /** Equal to runId; reserved identity for future shared-root accounting. */
94
151
  rootRunId: string;
152
+ executionId?: string;
153
+ revision?: number;
154
+ loopId?: string;
155
+ iteration?: number;
95
156
  }
96
157
  export interface ModelRequest {
97
158
  goal: string;
@@ -108,9 +169,9 @@ export interface ModelRequest {
108
169
  graph?: number;
109
170
  };
110
171
  workspace?: NodeWorkspace;
111
- /** Adapters must wrap mutating file tools so cancellation and cleanup wait for in-flight writes. */
172
+ /** Rejects read-only writes; adapters must wrap mutating file tools so cleanup waits for in-flight writes. */
112
173
  withWorkspaceWrite?: <T>(operation: () => Promise<T>) => Promise<T>;
113
- /** Local Git operations: inspection for workers, integration commands for merge nodes. */
174
+ /** Local Git operations: inspection for workers, integration commands for merge/integrate nodes. */
114
175
  git?: (args: string[], input?: string) => Promise<GitResult>;
115
176
  /** Merge nodes must account for every source before returning their final answer. */
116
177
  merge?: {
@@ -129,10 +190,14 @@ export interface ModelResponse {
129
190
  }
130
191
  /** Each call starts a fresh conversation and exposes only the adapter’s declared capabilities. */
131
192
  export type ModelRunner = (request: ModelRequest) => Promise<ModelResponse>;
132
- /** Frozen snapshots in emission order. Creation means admission of the submitted DAG, not mutation. */
193
+ /** Frozen snapshots in emission order, including graph revisions and execution identities. */
133
194
  export type ExecutionEvent = Readonly<{
134
195
  sequence: number;
135
196
  timestamp: number;
197
+ executionId?: string;
198
+ revision?: number;
199
+ loopId?: string;
200
+ iteration?: number;
136
201
  } & ({
137
202
  type: "graph_created";
138
203
  nodeCount: number;
@@ -142,11 +207,23 @@ export type ExecutionEvent = Readonly<{
142
207
  nodeId: string;
143
208
  nodeType: BraidNode["type"];
144
209
  model?: string;
145
- } | {
210
+ } | ({
146
211
  type: "edge_created";
147
- from: string;
148
- to: string;
149
- choice?: string;
212
+ } & Edge) | {
213
+ type: "graph_updated";
214
+ graph: BraidInput;
215
+ } | {
216
+ type: "execution_paused";
217
+ nodeId: string;
218
+ } | {
219
+ type: "execution_resumed";
220
+ nodeId: string;
221
+ } | {
222
+ type: "loop_started";
223
+ entry: string;
224
+ } | {
225
+ type: "loop_completed";
226
+ entry: string;
150
227
  } | {
151
228
  type: "workspace_updated";
152
229
  workspace: Readonly<NodeWorkspace>;
@@ -159,6 +236,7 @@ export type ExecutionEvent = Readonly<{
159
236
  to: string;
160
237
  output: string;
161
238
  decision?: string;
239
+ fromExecutionId?: string;
162
240
  } | {
163
241
  type: "node_started";
164
242
  nodeId: string;
@@ -189,7 +267,7 @@ export type ExecutionEvent = Readonly<{
189
267
  export interface BraidOptions {
190
268
  runner: ModelRunner;
191
269
  defaultModel?: string;
192
- /** Source checkout. Git workspace management is automatic; outside Git, nodes are read-only. */
270
+ /** Source checkout for the initial snapshot and explicit integrate nodes. */
193
271
  cwd?: string;
194
272
  /** Positive integer. Defaults to 4. */
195
273
  maxConcurrency?: number;
@@ -201,30 +279,36 @@ export interface BraidOptions {
201
279
  signal?: AbortSignal;
202
280
  /** Live observer. Throws/rejections are ignored; returned work is not awaited. */
203
281
  onEvent?: (event: ExecutionEvent) => void;
282
+ /** Total admitted executions across loops and graph updates. Default: 1000. */
283
+ maxExecutions?: number;
204
284
  }
205
285
  export interface ExecutionError {
206
- code: "MODEL_ERROR" | "INVALID_RESPONSE" | "DECISION_REQUIRED" | "INVALID_DECISION" | "NODE_TIMEOUT" | "GRAPH_TIMEOUT" | "CANCELLED" | "MERGE_FAILED" | "CLEANUP_FAILED";
286
+ code: "MODEL_ERROR" | "INVALID_RESPONSE" | "DECISION_REQUIRED" | "INVALID_DECISION" | "NODE_TIMEOUT" | "GRAPH_TIMEOUT" | "CANCELLED" | "MERGE_FAILED" | "CLEANUP_FAILED" | "CHECKPOINT_FAILED" | "WORKSPACE_MERGE_REQUIRED" | "EXECUTION_LIMIT" | "LOOP_LIMIT" | "REQUIRED_NODE_FAILED" | "SCHEDULING_ERROR";
207
287
  message: string;
208
288
  }
209
289
  /** Output is absent if no valid response was received; status determines success. */
210
290
  export interface NodeResult extends Partial<NodeOutput> {
211
291
  id: string;
292
+ executionId?: string;
212
293
  status: NodeStatus;
213
294
  usage?: TokenUsage;
214
295
  startedAt?: number;
215
296
  finishedAt?: number;
216
297
  latencyMs?: number;
217
298
  error?: ExecutionError;
218
- skipReason?: "inactive" | "upstream_failed" | "graph_timeout" | "cancelled";
299
+ skipReason?: "inactive" | "upstream_failed" | "graph_timeout" | "cancelled" | "run_failed";
219
300
  workspace?: NodeWorkspace;
220
301
  }
221
302
  export interface BraidResult {
222
303
  status: "completed" | "failed";
223
- /** Completed nodes with no active outgoing edges in this execution. */
304
+ /** Latest output per node among completed, unconsumed executions; exact IDs are in terminalExecutionIds. */
224
305
  terminalOutputs: Record<string, NodeOutput>;
225
- /** Immutable execution log, including graph construction and runtime handoffs. */
306
+ /** Immutable execution log, including graph revisions and runtime handoffs. */
226
307
  events: readonly ExecutionEvent[];
227
308
  nodes: Record<string, NodeResult>;
309
+ executions: Record<string, NodeExecution>;
310
+ terminalExecutionIds: string[];
311
+ revision: number;
228
312
  workspaces?: Record<string, NodeWorkspace>;
229
313
  error?: ExecutionError;
230
314
  metadata: ExecutionContext & {
@@ -236,3 +320,42 @@ export interface BraidResult {
236
320
  usageReportedNodes: number;
237
321
  };
238
322
  }
323
+ /** One immutable invocation definition, with a result updated only by that invocation. */
324
+ export interface NodeExecution extends NodeResult {
325
+ executionId: string;
326
+ node: BraidNode;
327
+ revision: number;
328
+ predecessorExecutionIds: string[];
329
+ loopId?: string;
330
+ iteration?: number;
331
+ }
332
+ export interface GraphUpdate {
333
+ expectedRevision: number;
334
+ upsertNodes?: readonly BraidInputNode[];
335
+ removeNodeIds?: readonly string[];
336
+ addEdges?: readonly Edge[];
337
+ removeEdges?: readonly Edge[];
338
+ promptTemplates?: Readonly<Record<string, string>>;
339
+ loops?: readonly LoopDefinition[];
340
+ /** Resume these completed executions atomically with this update. */
341
+ resume?: readonly string[];
342
+ }
343
+ export interface BraidSnapshot {
344
+ runId: string;
345
+ status: "running" | "waiting" | "finalizing" | "completed" | "failed";
346
+ revision: number;
347
+ graph: BraidInput;
348
+ nodes: Record<string, NodeResult>;
349
+ executions: Record<string, NodeExecution>;
350
+ pausedExecutionIds: string[];
351
+ /** Present as soon as a run-wide failure or cancellation starts draining work. */
352
+ error?: ExecutionError;
353
+ }
354
+ export interface BraidRun {
355
+ runId: string;
356
+ result: Promise<BraidResult>;
357
+ snapshot(): BraidSnapshot;
358
+ update(update: GraphUpdate): BraidSnapshot;
359
+ resume(executionIds: readonly string[], expectedRevision: number): BraidSnapshot;
360
+ cancel(): void;
361
+ }
@@ -1,4 +1,4 @@
1
- import type { BraidInput, BraidNode, Edge } from "./types.js";
1
+ import type { BraidInput, BraidNode, Edge, LoopDefinition } from "./types.js";
2
2
  export declare class GraphValidationError extends Error {
3
3
  constructor(message: string);
4
4
  }
@@ -10,6 +10,12 @@ export interface Graph {
10
10
  edges: Edge[];
11
11
  incoming: Map<string, Edge[]>;
12
12
  outgoing: Map<string, Edge[]>;
13
+ loops: Map<string, CompiledLoop>;
14
+ membership: Map<string, string>;
15
+ }
16
+ export interface CompiledLoop extends LoopDefinition {
17
+ feedback: Edge;
18
+ members: Set<string>;
13
19
  }
14
20
  /** Throws before execution for malformed graphs, references, choices, or cycles. */
15
21
  export declare function validateGraph(input: BraidInput): void;
package/dist/validate.js CHANGED
@@ -19,31 +19,104 @@ function fields(value, allowed, label) {
19
19
  requireValid(allowed.includes(key), `${label}: unsupported field '${key}'`);
20
20
  }
21
21
  }
22
+ function compileTemplates(value) {
23
+ const templates = new Map();
24
+ if (value === undefined)
25
+ return templates;
26
+ requireValid(isRecord(value), "Graph promptTemplates must be an object");
27
+ for (const [name, source] of Object.entries(value)) {
28
+ requireValid(text(name), "Prompt template name must be a non-empty string");
29
+ const label = `Prompt template '${name}'`;
30
+ requireValid(text(source), `${label} must be a non-empty string`);
31
+ const parts = [];
32
+ const variables = new Set();
33
+ let offset = 0;
34
+ while (offset < source.length) {
35
+ const open = source.indexOf("{{", offset);
36
+ const close = source.indexOf("}}", offset);
37
+ requireValid(close === -1 || (open !== -1 && close > open), `${label} has an unmatched '}}'`);
38
+ if (open === -1) {
39
+ parts.push(source.slice(offset));
40
+ break;
41
+ }
42
+ requireValid(close !== -1, `${label} has an unclosed '{{'`);
43
+ const variable = source.slice(open + 2, close).trim();
44
+ requireValid(/^[A-Za-z_][A-Za-z0-9_]*$/.test(variable), `${label} has invalid variable '${variable}'`);
45
+ parts.push(source.slice(offset, open), { variable });
46
+ variables.add(variable);
47
+ offset = close + 2;
48
+ }
49
+ templates.set(name, { parts, variables });
50
+ }
51
+ return templates;
52
+ }
53
+ function renderPrompt(value, nodeId, templates) {
54
+ const nodeLabel = `Node '${nodeId}'`;
55
+ if (typeof value === "string") {
56
+ requireValid(text(value), `${nodeLabel} needs a non-empty prompt`);
57
+ return value;
58
+ }
59
+ requireValid(isRecord(value), `${nodeLabel} needs a non-empty prompt or template reference`);
60
+ fields(value, ["template", "variables"], `${nodeLabel} prompt`);
61
+ requireValid(text(value.template), `${nodeLabel} prompt needs a non-empty template reference`);
62
+ const label = `${nodeLabel} prompt template '${value.template}'`;
63
+ const template = templates.get(value.template);
64
+ requireValid(template, `${label} is unknown`);
65
+ requireValid(isRecord(value.variables), `${label} variables must be an object`);
66
+ const variables = new Map();
67
+ for (const [name, variable] of Object.entries(value.variables)) {
68
+ requireValid(typeof variable === "string", `${label} variable '${name}' must be a string`);
69
+ requireValid(template.variables.has(name), `${label} has unused variable '${name}'`);
70
+ variables.set(name, variable);
71
+ }
72
+ for (const name of template.variables) {
73
+ requireValid(variables.has(name), `${label} is missing variable '${name}'`);
74
+ }
75
+ // One pass over the parsed template: inserted values are never parsed or evaluated.
76
+ const rendered = template.parts.map(part => typeof part === "string" ? part : variables.get(part.variable)).join("");
77
+ requireValid(text(rendered), `${label} renders an empty prompt`);
78
+ return rendered;
79
+ }
22
80
  /** Throws before execution for malformed graphs, references, choices, or cycles. */
23
81
  export function validateGraph(input) {
24
82
  compileGraph(input);
25
83
  }
26
84
  export function compileGraph(input) {
27
85
  requireValid(isRecord(input), "Graph must be an object");
28
- fields(input, ["goal", "nodes", "edges"], "Graph");
86
+ fields(input, ["goal", "nodes", "edges", "promptTemplates", "loops"], "Graph");
29
87
  requireValid(text(input.goal), "Graph goal must be a non-empty string");
30
88
  requireValid(Array.isArray(input.nodes) && input.nodes.length > 0, "Graph needs at least one node");
31
89
  requireValid(Array.isArray(input.edges), "Graph edges must be an array");
90
+ const templates = compileTemplates(input.promptTemplates);
32
91
  const byId = new Map();
33
92
  for (const node of input.nodes) {
34
93
  requireValid(isRecord(node), "Node must be an object");
35
- requireValid(node.type === "execute" || node.type === "decision" || node.type === "merge", "Unknown node type");
94
+ requireValid(node.type === "execute" || node.type === "decision" || (node.type === "merge" || node.type === "integrate"), "Unknown node type");
36
95
  fields(node, node.type === "decision"
37
- ? ["type", "id", "prompt", "model", "choices"]
38
- : ["type", "id", "prompt", "model"], "Node");
96
+ ? ["type", "id", "prompt", "model", "choices", "workspace", "notifyOnCompletion", "requireSuccess", "pauseAfter"]
97
+ : node.type === "execute"
98
+ ? ["type", "id", "prompt", "model", "workspace", "notifyOnCompletion", "requireSuccess", "pauseAfter"]
99
+ : ["type", "id", "prompt", "model", "notifyOnCompletion", "requireSuccess", "pauseAfter"], "Node");
39
100
  requireValid(text(node.id), "Node id must be a non-empty string");
40
101
  requireValid(!byId.has(node.id), `Duplicate node id '${node.id}'`);
41
- requireValid(text(node.prompt) || (node.type === "merge" && node.prompt === undefined), `Node '${node.id}' needs a non-empty prompt`);
102
+ const prompt = (node.type === "merge" || node.type === "integrate") && node.prompt === undefined
103
+ ? (node.type === "merge" ? "Merge selected predecessor results into this new isolated worktree. Resolve conflicts and account for every source with finish_merge." : "Integrate selected predecessor results into the invoking checkout. Preserve user changes and account for every source with finish_merge.")
104
+ : renderPrompt(node.prompt, node.id, templates);
42
105
  requireValid(node.model === undefined || text(node.model), `Invalid model on '${node.id}'`);
106
+ const workspace = (node.type === "merge" || node.type === "integrate") ? undefined : node.workspace;
107
+ requireValid(node.notifyOnCompletion === undefined || typeof node.notifyOnCompletion === "boolean", `Invalid notifyOnCompletion on '${node.id}': expected a boolean`);
108
+ requireValid(workspace === undefined || workspace === "read-only" || workspace === "worktree", `Invalid workspace on '${node.id}'`);
109
+ for (const field of ["requireSuccess", "pauseAfter"]) {
110
+ requireValid(node[field] === undefined || typeof node[field] === "boolean", `Invalid ${field} on '${node.id}': expected a boolean`);
111
+ }
43
112
  const common = {
44
113
  id: node.id,
45
- prompt: node.prompt ?? "Review all predecessor changes, decide how to integrate them into the source repository, and account for every source with finish_merge.",
114
+ prompt,
46
115
  ...(node.model !== undefined ? { model: node.model } : {}),
116
+ ...(node.notifyOnCompletion !== undefined ? { notifyOnCompletion: node.notifyOnCompletion } : {}),
117
+ ...(node.requireSuccess !== undefined ? { requireSuccess: node.requireSuccess } : {}),
118
+ ...(node.pauseAfter !== undefined ? { pauseAfter: node.pauseAfter } : {}),
119
+ ...(workspace !== undefined ? { workspace } : {}),
47
120
  };
48
121
  if (node.type === "decision") {
49
122
  requireValid(Array.isArray(node.choices) && node.choices.length > 0, `Decision '${node.id}' needs at least one choice`);
@@ -66,39 +139,94 @@ export function compileGraph(input) {
66
139
  }
67
140
  for (const edge of input.edges) {
68
141
  requireValid(isRecord(edge), "Edge must be an object");
69
- fields(edge, ["from", "to", "choice"], "Edge");
70
- requireValid(text(edge.from) && byId.has(edge.from), `Missing source node '${edge.from}'`);
142
+ fields(edge, ["from", "to", "choice", "feedback", "executionId"], "Edge");
143
+ requireValid(edge.executionId === undefined || text(edge.executionId), "Invalid edge executionId");
144
+ requireValid(edge.feedback === undefined || text(edge.feedback), "Invalid edge feedback");
145
+ requireValid(!edge.feedback || !edge.executionId, "Feedback cannot pin an execution");
146
+ requireValid(text(edge.from) && (byId.has(edge.from) || edge.executionId !== undefined), `Missing source node '${edge.from}'`);
71
147
  requireValid(text(edge.to) && byId.has(edge.to), `Missing target node '${edge.to}'`);
72
- if (edge.choice !== undefined) {
148
+ requireValid(edge.choice === undefined || text(edge.choice), "Invalid edge choice");
149
+ if (edge.choice !== undefined && edge.executionId === undefined) {
73
150
  const source = byId.get(edge.from);
74
151
  requireValid(source.type === "decision", `Choice edge from non-decision '${edge.from}'`);
75
152
  requireValid(text(edge.choice) && source.choices.includes(edge.choice), `Undeclared edge choice '${edge.choice}' on '${edge.from}'`);
76
153
  }
77
- const key = JSON.stringify([edge.from, edge.to, edge.choice ?? null]);
154
+ const key = JSON.stringify([edge.from, edge.to, edge.choice ?? null, edge.feedback ?? null, edge.executionId ?? null]);
78
155
  requireValid(!seen.has(key), `Duplicate edge '${edge.from}' -> '${edge.to}'`);
79
156
  seen.add(key);
80
157
  const snapshot = {
81
158
  from: edge.from,
82
159
  to: edge.to,
83
160
  ...(edge.choice !== undefined ? { choice: edge.choice } : {}),
161
+ ...(edge.feedback !== undefined ? { feedback: edge.feedback } : {}),
162
+ ...(edge.executionId !== undefined ? { executionId: edge.executionId } : {}),
84
163
  };
85
164
  edges.push(snapshot);
86
165
  incoming.get(edge.to).push(snapshot);
87
- outgoing.get(edge.from).push(snapshot);
166
+ outgoing.get(edge.from)?.push(snapshot);
88
167
  }
89
168
  // Kahn's algorithm is iterative, including for very deep graphs.
90
169
  const nodes = [...byId.values()];
91
- const remaining = new Map(nodes.map((node) => [node.id, incoming.get(node.id).length]));
170
+ const remaining = new Map(nodes.map((node) => [node.id, incoming.get(node.id).filter(edge => !edge.feedback && !edge.executionId).length]));
92
171
  const topologicalOrder = nodes.filter((node) => remaining.get(node.id) === 0);
93
172
  for (let i = 0; i < topologicalOrder.length; i++) {
94
173
  for (const edge of outgoing.get(topologicalOrder[i].id)) {
174
+ if (edge.feedback || edge.executionId)
175
+ continue;
95
176
  const count = remaining.get(edge.to) - 1;
96
177
  remaining.set(edge.to, count);
97
178
  if (count === 0)
98
179
  topologicalOrder.push(byId.get(edge.to));
99
180
  }
100
181
  }
101
- requireValid(topologicalOrder.length === nodes.length, "Graph contains a cycle");
182
+ requireValid(topologicalOrder.length === nodes.length, "Graph contains a cycle without a declared feedback edge");
183
+ const loops = new Map();
184
+ const membership = new Map();
185
+ requireValid(input.loops === undefined || Array.isArray(input.loops), "Graph loops must be an array");
186
+ const reachable = (start, reverse = false) => {
187
+ const visited = new Set();
188
+ const queue = [start];
189
+ while (queue.length) {
190
+ const id = queue.pop();
191
+ if (visited.has(id))
192
+ continue;
193
+ visited.add(id);
194
+ for (const edge of (reverse ? incoming : outgoing).get(id) ?? []) {
195
+ if (!edge.feedback && !edge.executionId)
196
+ queue.push(reverse ? edge.from : edge.to);
197
+ }
198
+ }
199
+ return visited;
200
+ };
201
+ for (const loop of input.loops ?? []) {
202
+ requireValid(isRecord(loop), "Loop must be an object");
203
+ fields(loop, ["id", "entry", "maxIterations"], "Loop");
204
+ requireValid(text(loop.id) && !loops.has(loop.id), "Loop ids must be non-empty and unique");
205
+ requireValid(text(loop.entry) && byId.has(loop.entry), `Unknown loop entry '${loop.entry}'`);
206
+ requireValid(typeof loop.maxIterations === "number" && Number.isSafeInteger(loop.maxIterations) && loop.maxIterations > 0, `Loop '${loop.id}' needs a positive maxIterations`);
207
+ const feedback = edges.filter(edge => edge.feedback === loop.id);
208
+ requireValid(feedback.length === 1, `Loop '${loop.id}' needs exactly one feedback edge`);
209
+ const back = feedback[0];
210
+ const decision = byId.get(back.from);
211
+ requireValid(back.to === loop.entry && decision.type === "decision" && back.choice !== undefined, `Loop '${loop.id}' feedback must route a decision choice to its entry`);
212
+ requireValid(decision.choices.some(choice => choice !== back.choice), `Loop '${loop.id}' needs an exit choice`);
213
+ const ancestors = reachable(back.from, true);
214
+ const members = new Set([...reachable(loop.entry)].filter(id => ancestors.has(id)));
215
+ requireValid(members.has(loop.entry) && members.has(back.from), `Loop '${loop.id}' entry must reach its feedback decision`);
216
+ for (const id of members) {
217
+ requireValid(!membership.has(id), "Nested or overlapping loops are unsupported");
218
+ membership.set(id, loop.id);
219
+ }
220
+ for (const edge of edges) {
221
+ if (edge.feedback || edge.executionId)
222
+ continue;
223
+ requireValid(!(!members.has(edge.from) && members.has(edge.to) && edge.to !== loop.entry), `Loop '${loop.id}' has multiple entries`);
224
+ requireValid(!(members.has(edge.from) && !members.has(edge.to) && edge.from !== back.from), `Loop '${loop.id}' must exit through its feedback decision`);
225
+ }
226
+ loops.set(loop.id, { id: loop.id, entry: loop.entry, maxIterations: loop.maxIterations, feedback: back, members });
227
+ }
228
+ for (const edge of edges)
229
+ requireValid(!edge.feedback || loops.has(edge.feedback), `Unknown feedback loop '${edge.feedback}'`);
102
230
  return {
103
231
  goal: input.goal,
104
232
  nodes,
@@ -106,5 +234,7 @@ export function compileGraph(input) {
106
234
  edges,
107
235
  incoming,
108
236
  outgoing,
237
+ loops,
238
+ membership,
109
239
  };
110
240
  }
@@ -1,5 +1,5 @@
1
1
  import type { GitResult, MergeDisposition, MergeSource, ModelRequest, NodeWorkspace, SourceCheckoutStatus } from "./types.js";
2
- /** One source snapshot per graph; every invoked node gets its own detached worktree. */
2
+ /** Each execution owns a fresh worktree derived from immutable predecessor checkpoints. */
3
3
  export declare class GitWorkspaces {
4
4
  private readonly cwd;
5
5
  private readonly onWorkspace?;
@@ -7,10 +7,19 @@ export declare class GitWorkspaces {
7
7
  private records;
8
8
  private locations;
9
9
  private allocated;
10
+ private mergeParents;
10
11
  constructor(cwd: string, onWorkspace?: ((workspace: NodeWorkspace) => void) | undefined);
11
12
  private report;
13
+ private sourceRoot;
12
14
  private snapshot;
13
- prepare(request: ModelRequest): Promise<NodeWorkspace>;
15
+ /** Freeze the job's root snapshot before any invocation can write to the checkout. */
16
+ initialize(runId: string): Promise<void>;
17
+ private id;
18
+ private inputCommit;
19
+ prepare(request: ModelRequest, merge?: boolean): Promise<NodeWorkspace>;
20
+ /** Seal before releasing any downstream execution, including failures with partial writes. */
21
+ seal(request: ModelRequest): Promise<void>;
22
+ private sealCheckpoint;
14
23
  all(): Record<string, NodeWorkspace>;
15
24
  pending(): string[];
16
25
  /** Checkpoint every file, including ignored node outputs, before releasing a worktree. */
@@ -27,3 +36,7 @@ export declare class GitWorkspaces {
27
36
  complete: (success: boolean) => Promise<void>;
28
37
  }>;
29
38
  }
39
+ export declare class WorkspaceInputError extends Error {
40
+ }
41
+ export declare class WorkspaceCheckpointError extends Error {
42
+ }