@chrok/braid 0.1.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.
@@ -0,0 +1,238 @@
1
+ export interface ExecuteNode {
2
+ type: "execute";
3
+ id: string;
4
+ prompt: string;
5
+ model?: string;
6
+ }
7
+ export interface DecisionNode {
8
+ type: "decision";
9
+ id: string;
10
+ prompt: string;
11
+ choices: readonly string[];
12
+ model?: string;
13
+ }
14
+ export interface MergeNode {
15
+ type: "merge";
16
+ id: string;
17
+ /** Defaults to reviewing and integrating all predecessor workspaces. */
18
+ prompt?: string;
19
+ model?: string;
20
+ }
21
+ export type BraidNode = ExecuteNode | DecisionNode | MergeNode;
22
+ export interface NodeWorkspace {
23
+ nodeId: string;
24
+ mode: "read-only" | "worktree" | "merge";
25
+ workingDirectory: string;
26
+ worktreeRoot?: string;
27
+ sourceRoot?: string;
28
+ baseCommit?: string;
29
+ snapshotCommit?: string;
30
+ checkpointRef?: string;
31
+ checkpointCommit?: string;
32
+ /** Source checkout snapshot captured before a merge agent receives write access. */
33
+ backupRef?: string;
34
+ state: "preparing" | "ready" | "integrated" | "discarded" | "archived" | "failed";
35
+ reason?: string;
36
+ }
37
+ export interface MergeDisposition {
38
+ nodeId: string;
39
+ disposition: "integrated" | "discarded" | "archived";
40
+ reason: string;
41
+ }
42
+ export interface GitPreview {
43
+ text: string;
44
+ truncated: boolean;
45
+ }
46
+ export interface MergeSource extends NodeWorkspace {
47
+ /** Inspection-only summaries relative to snapshotCommit; omitted by custom runners. */
48
+ changes?: {
49
+ files: string[];
50
+ filesTruncated: boolean;
51
+ stat: GitPreview;
52
+ diff: GitPreview;
53
+ };
54
+ }
55
+ export interface SourceCheckoutStatus extends GitPreview {
56
+ /** True when Git porcelain status has any staged, unstaged, or untracked entries. */
57
+ dirty: boolean;
58
+ }
59
+ export interface GitResult {
60
+ exitCode: number;
61
+ stdout: string;
62
+ stderr: string;
63
+ }
64
+ export interface Edge {
65
+ from: string;
66
+ to: string;
67
+ /** Omit for an unconditional edge, including from a decision node. */
68
+ choice?: string;
69
+ }
70
+ export interface BraidInput {
71
+ goal: string;
72
+ nodes: readonly BraidNode[];
73
+ edges: readonly Edge[];
74
+ }
75
+ export type NodeStatus = "pending" | "runnable" | "running" | "completed" | "skipped" | "failed";
76
+ export interface TokenUsage {
77
+ inputTokens: number;
78
+ outputTokens: number;
79
+ }
80
+ export interface NodeOutput {
81
+ output: string;
82
+ decision?: string;
83
+ model?: string;
84
+ }
85
+ export interface PredecessorOutput extends NodeOutput {
86
+ nodeId: string;
87
+ /** Failed predecessors remain available on unconditional edges. */
88
+ error?: ExecutionError;
89
+ workspace?: NodeWorkspace;
90
+ }
91
+ export interface ExecutionContext {
92
+ runId: string;
93
+ /** Equal to runId in v0.1; reserved identity for future shared-root accounting. */
94
+ rootRunId: string;
95
+ }
96
+ export interface ModelRequest {
97
+ goal: string;
98
+ node: BraidNode;
99
+ /** Node override, otherwise the run's defaultModel, otherwise adapter default. */
100
+ model?: string;
101
+ /** Direct active predecessors, including failures on unconditional edges; no parent history. */
102
+ predecessors: PredecessorOutput[];
103
+ execution: ExecutionContext;
104
+ signal: AbortSignal;
105
+ /** Finite deadlines on the performance.now() clock; absent scopes are unlimited. */
106
+ deadlines?: {
107
+ node?: number;
108
+ graph?: number;
109
+ };
110
+ workspace?: NodeWorkspace;
111
+ /** Adapters must wrap mutating file tools so cancellation and cleanup wait for in-flight writes. */
112
+ withWorkspaceWrite?: <T>(operation: () => Promise<T>) => Promise<T>;
113
+ /** Local Git operations: inspection for workers, integration commands for merge nodes. */
114
+ git?: (args: string[], input?: string) => Promise<GitResult>;
115
+ /** Merge nodes must account for every source before returning their final answer. */
116
+ merge?: {
117
+ sources: MergeSource[];
118
+ sourceStatus?: SourceCheckoutStatus;
119
+ finish: (dispositions: MergeDisposition[]) => Promise<void>;
120
+ };
121
+ /** Present only on decision nodes. An adapter exposes this as the decide tool. */
122
+ decide?: (choice: string) => void;
123
+ }
124
+ export interface ModelResponse {
125
+ output: string;
126
+ /** The actual model reported by the provider, if known. */
127
+ model?: string;
128
+ usage?: TokenUsage;
129
+ }
130
+ /** Each call starts a fresh conversation and exposes only the adapter’s declared capabilities. */
131
+ export type ModelRunner = (request: ModelRequest) => Promise<ModelResponse>;
132
+ /** Frozen snapshots in emission order. Creation means admission of the submitted DAG, not mutation. */
133
+ export type ExecutionEvent = Readonly<{
134
+ sequence: number;
135
+ timestamp: number;
136
+ } & ({
137
+ type: "graph_created";
138
+ nodeCount: number;
139
+ edgeCount: number;
140
+ } | {
141
+ type: "node_created";
142
+ nodeId: string;
143
+ nodeType: BraidNode["type"];
144
+ model?: string;
145
+ } | {
146
+ type: "edge_created";
147
+ from: string;
148
+ to: string;
149
+ choice?: string;
150
+ } | {
151
+ type: "workspace_updated";
152
+ workspace: Readonly<NodeWorkspace>;
153
+ } | {
154
+ type: "node_runnable";
155
+ nodeId: string;
156
+ } | {
157
+ type: "handoff";
158
+ from: string;
159
+ to: string;
160
+ output: string;
161
+ decision?: string;
162
+ } | {
163
+ type: "node_started";
164
+ nodeId: string;
165
+ model?: string;
166
+ } | ({
167
+ type: "node_completed";
168
+ nodeId: string;
169
+ latencyMs: number;
170
+ usage?: Readonly<TokenUsage>;
171
+ } & NodeOutput) | {
172
+ type: "node_skipped";
173
+ nodeId: string;
174
+ reason: NonNullable<NodeResult["skipReason"]>;
175
+ } | ({
176
+ type: "node_failed";
177
+ nodeId: string;
178
+ error: Readonly<ExecutionError>;
179
+ latencyMs: number;
180
+ usage?: Readonly<TokenUsage>;
181
+ } & Partial<NodeOutput>) | {
182
+ type: "graph_completed";
183
+ terminalNodeIds: readonly string[];
184
+ } | {
185
+ type: "graph_failed";
186
+ error: Readonly<ExecutionError>;
187
+ terminalNodeIds: readonly string[];
188
+ })>;
189
+ export interface BraidOptions {
190
+ runner: ModelRunner;
191
+ defaultModel?: string;
192
+ /** Source checkout. Git workspace management is automatic; outside Git, nodes are read-only. */
193
+ cwd?: string;
194
+ /** Positive integer. Defaults to 4. */
195
+ maxConcurrency?: number;
196
+ /** Applied separately to each invocation, starting when it runs. Default: 60s. Infinity disables it. */
197
+ nodeTimeoutMs?: number;
198
+ /** Includes queueing and execution of the entire graph. Default: 5 minutes. Infinity disables it. */
199
+ graphTimeoutMs?: number;
200
+ /** Caller cancellation, independent of node and graph deadlines. */
201
+ signal?: AbortSignal;
202
+ /** Live observer. Throws/rejections are ignored; returned work is not awaited. */
203
+ onEvent?: (event: ExecutionEvent) => void;
204
+ }
205
+ export interface ExecutionError {
206
+ code: "MODEL_ERROR" | "INVALID_RESPONSE" | "DECISION_REQUIRED" | "INVALID_DECISION" | "NODE_TIMEOUT" | "GRAPH_TIMEOUT" | "CANCELLED" | "MERGE_FAILED" | "CLEANUP_FAILED";
207
+ message: string;
208
+ }
209
+ /** Output is absent if no valid response was received; status determines success. */
210
+ export interface NodeResult extends Partial<NodeOutput> {
211
+ id: string;
212
+ status: NodeStatus;
213
+ usage?: TokenUsage;
214
+ startedAt?: number;
215
+ finishedAt?: number;
216
+ latencyMs?: number;
217
+ error?: ExecutionError;
218
+ skipReason?: "inactive" | "upstream_failed" | "graph_timeout" | "cancelled";
219
+ workspace?: NodeWorkspace;
220
+ }
221
+ export interface BraidResult {
222
+ status: "completed" | "failed";
223
+ /** Completed nodes with no active outgoing edges in this execution. */
224
+ terminalOutputs: Record<string, NodeOutput>;
225
+ /** Immutable execution log, including graph construction and runtime handoffs. */
226
+ events: readonly ExecutionEvent[];
227
+ nodes: Record<string, NodeResult>;
228
+ workspaces?: Record<string, NodeWorkspace>;
229
+ error?: ExecutionError;
230
+ metadata: ExecutionContext & {
231
+ startedAt: number;
232
+ finishedAt: number;
233
+ latencyMs: number;
234
+ /** Sum of reported usage only, including responses that fail decision validation. */
235
+ usage: TokenUsage;
236
+ usageReportedNodes: number;
237
+ };
238
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ import type { BraidInput, BraidNode, Edge } from "./types.js";
2
+ export declare class GraphValidationError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ /** Internal, validated snapshot; arrays/maps are never handed to the runner. */
6
+ export interface Graph {
7
+ goal: string;
8
+ nodes: BraidNode[];
9
+ topologicalOrder: BraidNode[];
10
+ edges: Edge[];
11
+ incoming: Map<string, Edge[]>;
12
+ outgoing: Map<string, Edge[]>;
13
+ }
14
+ /** Throws before execution for malformed graphs, references, choices, or cycles. */
15
+ export declare function validateGraph(input: BraidInput): void;
16
+ export declare function compileGraph(input: BraidInput): Graph;
@@ -0,0 +1,110 @@
1
+ export class GraphValidationError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = "GraphValidationError";
5
+ }
6
+ }
7
+ function requireValid(condition, message) {
8
+ if (!condition)
9
+ throw new GraphValidationError(message);
10
+ }
11
+ function isRecord(value) {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+ function text(value) {
15
+ return typeof value === "string" && value.trim().length > 0;
16
+ }
17
+ function fields(value, allowed, label) {
18
+ for (const key of Object.keys(value)) {
19
+ requireValid(allowed.includes(key), `${label}: unsupported field '${key}'`);
20
+ }
21
+ }
22
+ /** Throws before execution for malformed graphs, references, choices, or cycles. */
23
+ export function validateGraph(input) {
24
+ compileGraph(input);
25
+ }
26
+ export function compileGraph(input) {
27
+ requireValid(isRecord(input), "Graph must be an object");
28
+ fields(input, ["goal", "nodes", "edges"], "Graph");
29
+ requireValid(text(input.goal), "Graph goal must be a non-empty string");
30
+ requireValid(Array.isArray(input.nodes) && input.nodes.length > 0, "Graph needs at least one node");
31
+ requireValid(Array.isArray(input.edges), "Graph edges must be an array");
32
+ const byId = new Map();
33
+ for (const node of input.nodes) {
34
+ requireValid(isRecord(node), "Node must be an object");
35
+ requireValid(node.type === "execute" || node.type === "decision" || node.type === "merge", "Unknown node type");
36
+ fields(node, node.type === "decision"
37
+ ? ["type", "id", "prompt", "model", "choices"]
38
+ : ["type", "id", "prompt", "model"], "Node");
39
+ requireValid(text(node.id), "Node id must be a non-empty string");
40
+ 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`);
42
+ requireValid(node.model === undefined || text(node.model), `Invalid model on '${node.id}'`);
43
+ const common = {
44
+ 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.",
46
+ ...(node.model !== undefined ? { model: node.model } : {}),
47
+ };
48
+ if (node.type === "decision") {
49
+ requireValid(Array.isArray(node.choices) && node.choices.length > 0, `Decision '${node.id}' needs at least one choice`);
50
+ const choices = [...node.choices];
51
+ requireValid(choices.every(text), `Invalid choice on '${node.id}'`);
52
+ requireValid(new Set(choices).size === choices.length, `Duplicate choices on '${node.id}'`);
53
+ byId.set(node.id, { type: "decision", ...common, choices });
54
+ }
55
+ else {
56
+ byId.set(node.id, { type: node.type, ...common });
57
+ }
58
+ }
59
+ const edges = [];
60
+ const incoming = new Map();
61
+ const outgoing = new Map();
62
+ const seen = new Set();
63
+ for (const id of byId.keys()) {
64
+ incoming.set(id, []);
65
+ outgoing.set(id, []);
66
+ }
67
+ for (const edge of input.edges) {
68
+ 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}'`);
71
+ requireValid(text(edge.to) && byId.has(edge.to), `Missing target node '${edge.to}'`);
72
+ if (edge.choice !== undefined) {
73
+ const source = byId.get(edge.from);
74
+ requireValid(source.type === "decision", `Choice edge from non-decision '${edge.from}'`);
75
+ requireValid(text(edge.choice) && source.choices.includes(edge.choice), `Undeclared edge choice '${edge.choice}' on '${edge.from}'`);
76
+ }
77
+ const key = JSON.stringify([edge.from, edge.to, edge.choice ?? null]);
78
+ requireValid(!seen.has(key), `Duplicate edge '${edge.from}' -> '${edge.to}'`);
79
+ seen.add(key);
80
+ const snapshot = {
81
+ from: edge.from,
82
+ to: edge.to,
83
+ ...(edge.choice !== undefined ? { choice: edge.choice } : {}),
84
+ };
85
+ edges.push(snapshot);
86
+ incoming.get(edge.to).push(snapshot);
87
+ outgoing.get(edge.from).push(snapshot);
88
+ }
89
+ // Kahn's algorithm is iterative, including for very deep graphs.
90
+ const nodes = [...byId.values()];
91
+ const remaining = new Map(nodes.map((node) => [node.id, incoming.get(node.id).length]));
92
+ const topologicalOrder = nodes.filter((node) => remaining.get(node.id) === 0);
93
+ for (let i = 0; i < topologicalOrder.length; i++) {
94
+ for (const edge of outgoing.get(topologicalOrder[i].id)) {
95
+ const count = remaining.get(edge.to) - 1;
96
+ remaining.set(edge.to, count);
97
+ if (count === 0)
98
+ topologicalOrder.push(byId.get(edge.to));
99
+ }
100
+ }
101
+ requireValid(topologicalOrder.length === nodes.length, "Graph contains a cycle");
102
+ return {
103
+ goal: input.goal,
104
+ nodes,
105
+ topologicalOrder,
106
+ edges,
107
+ incoming,
108
+ outgoing,
109
+ };
110
+ }
@@ -0,0 +1,29 @@
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. */
3
+ export declare class GitWorkspaces {
4
+ private readonly cwd;
5
+ private readonly onWorkspace?;
6
+ private snapshots;
7
+ private records;
8
+ private locations;
9
+ private allocated;
10
+ constructor(cwd: string, onWorkspace?: ((workspace: NodeWorkspace) => void) | undefined);
11
+ private report;
12
+ private snapshot;
13
+ prepare(request: ModelRequest): Promise<NodeWorkspace>;
14
+ all(): Record<string, NodeWorkspace>;
15
+ pending(): string[];
16
+ /** Checkpoint every file, including ignored node outputs, before releasing a worktree. */
17
+ private checkpoint;
18
+ private release;
19
+ /** Only archive/remove here. Choosing merge/cherry-pick/apply always belongs to the agent. */
20
+ archivePending(reason: string): Promise<void>;
21
+ close(): Promise<void>;
22
+ git(request: ModelRequest, args: string[], input?: string): Promise<GitResult>;
23
+ beginMerge(request: ModelRequest, sourceIds: string[]): Promise<{
24
+ sources: MergeSource[];
25
+ sourceStatus?: SourceCheckoutStatus;
26
+ finish: (dispositions: MergeDisposition[]) => Promise<void>;
27
+ complete: (success: boolean) => Promise<void>;
28
+ }>;
29
+ }