@jsleekr/graft 5.7.1 → 5.8.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/README.md CHANGED
@@ -1,71 +1,88 @@
1
- [![npm version](https://img.shields.io/npm/v/@graft-lang/graft.svg)](https://www.npmjs.com/package/@graft-lang/graft)
2
- [![CI](https://github.com/JSLEEKR/graft/actions/workflows/ci.yml/badge.svg)](https://github.com/JSLEEKR/graft/actions/workflows/ci.yml)
3
- [![Node.js](https://img.shields.io/node/v/@graft-lang/graft.svg)](https://nodejs.org)
1
+ [![npm version](https://img.shields.io/npm/v/@jsleekr/graft.svg)](https://www.npmjs.com/package/@jsleekr/graft)
2
+ [![Node.js](https://img.shields.io/node/v/@jsleekr/graft.svg)](https://nodejs.org)
4
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
5
4
 
6
5
  # Graft
7
6
 
8
- **A graph-native language for AI agent pipelines.**
7
+ **Define multi-agent pipelines in 10 lines. Compile to Claude Code in 1 second.**
9
8
 
10
- Graft compiles `.gft` files into [Claude Code](https://docs.anthropic.com/en/docs/claude-code) harness structures. Define your multi-agent pipeline declaratively, and the compiler generates the agents, hooks, orchestration, and settings — with compile-time token budget analysis.
9
+ Graft is a graph-native language that compiles `.gft` files into [Claude Code](https://docs.anthropic.com/en/docs/claude-code) harness structures agents, hooks, orchestration, and settings — with compile-time token budget analysis.
11
10
 
12
- ## Quick Start
11
+ ## 10-Minute Getting Started
12
+
13
+ ### 1. Install
13
14
 
14
15
  ```bash
15
- npm install -g @graft-lang/graft
16
+ npm install -g @jsleekr/graft
16
17
  ```
17
18
 
18
- Write a pipeline (`hello.gft`):
19
+ Requires Node.js 20+.
19
20
 
20
- ```graft
21
- context UserRequest(max_tokens: 500) {
22
- question: String
23
- }
21
+ ### 2. Create a project
24
22
 
25
- node Researcher(model: sonnet, budget: 2k/1k) {
26
- reads: [UserRequest]
27
- produces Research {
28
- findings: List<String>
29
- confidence: Float(0..1)
30
- }
31
- }
23
+ ```bash
24
+ graft init my-pipeline
25
+ cd my-pipeline
26
+ ```
32
27
 
33
- node Writer(model: haiku, budget: 1500/800) {
34
- reads: [Research.findings]
35
- produces Answer { response: String }
36
- }
28
+ This creates `pipeline.gft` — a simple two-node pipeline ready to compile.
37
29
 
38
- edge Researcher -> Writer | select(findings) | compact
30
+ ### 3. Compile
39
31
 
40
- graph SimpleQA(input: UserRequest, output: Answer, budget: 6k) {
41
- Researcher -> Writer -> done
42
- }
32
+ ```bash
33
+ graft compile pipeline.gft
43
34
  ```
44
35
 
45
- Compile it:
36
+ Output:
46
37
 
47
38
  ```
48
- $ graft compile hello.gft
49
-
50
39
  ✓ Parse OK
51
40
  ✓ Scope check OK
52
41
  ✓ Type check OK
53
42
  ✓ Token analysis:
54
- Researcher in ~ 500 out ~ 1,000
55
- Writer in ~ 63 out ~ 800
56
- Best path: 2,363 tokens ✓ within budget (6,000)
43
+ Analyst in ~ 500 out ~ 2,000
44
+ Reviewer in ~ 840 out ~ 1,000
45
+ Best path: 4,340 tokens ✓ within budget (10,000)
57
46
 
58
47
  Generated:
59
- .claude/agents/researcher.md ← agent definition
60
- .claude/agents/writer.md ← agent definition
61
- .claude/hooks/researcher-to-writer.js ← edge transform (Node.js)
62
- .claude/CLAUDE.md ← orchestration plan
63
- .claude/settings.json ← model routing + hooks
48
+ .claude/agents/analyst.md ← agent definition
49
+ .claude/agents/reviewer.md ← agent definition
50
+ .claude/hooks/analyst-to-reviewer.js ← edge transform
51
+ .claude/CLAUDE.md ← orchestration plan
52
+ .claude/settings.json ← model routing + hooks
53
+ ```
54
+
55
+ ### 4. Run in Claude Code
56
+
57
+ ```bash
58
+ # Create input
59
+ echo '{"question": "What is Graft?"}' > .graft/session/input.json
60
+
61
+ # Open in Claude Code
62
+ claude
63
+ ```
64
+
65
+ Then tell Claude Code:
66
+
67
+ > `.claude/CLAUDE.md`의 실행 계획을 따라서 파이프라인을 실행해줘. 입력은 `.graft/session/input.json`에 있어.
68
+
69
+ Claude Code reads the generated `.claude/` structure and runs the pipeline automatically.
70
+
71
+ ### 5. Check the results
72
+
73
+ ```bash
74
+ cat .graft/session/node_outputs/reviewer.json
64
75
  ```
65
76
 
66
- Open the project directory in Claude Code — it picks up the generated `.claude/` structure and runs the pipeline.
77
+ That's it. You have a working multi-agent pipeline.
67
78
 
68
- ## What Does Graft Generate?
79
+ ---
80
+
81
+ ## How It Works
82
+
83
+ ```
84
+ .gft Source → Graft Compiler → .claude/ output → Claude Code runs it
85
+ ```
69
86
 
70
87
  | Graft Source | Generated Output | Purpose |
71
88
  |-------------|-----------------|---------|
@@ -81,9 +98,53 @@ Multi-agent systems waste tokens passing full context between agents. Graft fixe
81
98
 
82
99
  - **Edge transforms** extract only what the next agent needs (`select`, `drop`, `compact`, `filter`)
83
100
  - **Compile-time token analysis** catches budget overruns before you spend API credits
84
- - **Typed output schemas** enforce structured JSON communication between agents
101
+ - **Typed output schemas** enforce structured JSON between agents
85
102
  - **Explicit `reads`** declarations prevent context leaks — the compiler verifies scope
86
103
 
104
+ ## Example: Code Review Pipeline
105
+
106
+ ```graft
107
+ context PullRequest(max_tokens: 2k) {
108
+ diff: String
109
+ description: String
110
+ }
111
+
112
+ node SecurityReviewer(model: sonnet, budget: 4k/2k) {
113
+ reads: [PullRequest]
114
+ produces SecurityAnalysis {
115
+ vulnerabilities: List<String>
116
+ risk_level: enum(low, medium, high, critical)
117
+ }
118
+ }
119
+
120
+ node LogicReviewer(model: sonnet, budget: 4k/2k) {
121
+ reads: [PullRequest]
122
+ produces LogicAnalysis {
123
+ issues: List<String>
124
+ complexity: Int
125
+ }
126
+ }
127
+
128
+ node SeniorReviewer(model: opus, budget: 6k/3k) {
129
+ reads: [SecurityAnalysis, LogicAnalysis, PullRequest]
130
+ produces FinalReview {
131
+ approved: Bool
132
+ summary: String
133
+ action_items: List<String>
134
+ }
135
+ }
136
+
137
+ edge SecurityReviewer -> SeniorReviewer | select(vulnerabilities, risk_level) | compact
138
+ edge LogicReviewer -> SeniorReviewer | select(issues) | compact
139
+
140
+ graph CodeReview(input: PullRequest, output: FinalReview, budget: 25k) {
141
+ parallel { SecurityReviewer LogicReviewer }
142
+ -> SeniorReviewer -> done
143
+ }
144
+ ```
145
+
146
+ This compiles to 3 agents running in parallel, with edge transforms that strip unnecessary data before the senior review.
147
+
87
148
  ## Language Features
88
149
 
89
150
  ### Contexts and Nodes
@@ -153,8 +214,6 @@ A -> let score = A.risk_score * 2
153
214
  -> B -> done
154
215
  ```
155
216
 
156
- Supported: arithmetic (`+`, `-`, `*`, `/`, `%`), comparison (`>`, `>=`, `==`, `!=`), logical (`&&`, `||`), null coalescing (`??`), string interpolation, builtins (`len`, `max`, `min`, `abs`, `round`, `keys`, `str`).
157
-
158
217
  ### Type System
159
218
 
160
219
  ```
@@ -172,63 +231,35 @@ graft compile <file.gft> [--out-dir <dir>] # Compile to .claude/ structure
172
231
  graft check <file.gft> # Parse + analyze only
173
232
  graft run <file.gft> --input <json> [--dry-run] [--verbose] # Compile and execute
174
233
  graft init <name> # Scaffold a new project
234
+ graft watch <file.gft> [--out-dir <dir>] # Watch and recompile on changes
235
+ graft visualize <file.gft> # Output pipeline DAG as Mermaid diagram
175
236
  ```
176
237
 
177
- ## Editor Support
178
-
179
- ### VS Code
180
-
181
- The [Graft VS Code extension](editors/vscode/) provides:
182
- - Syntax highlighting (TextMate grammar)
183
- - Real-time diagnostics
184
- - Hover information (types, token budgets)
185
- - Go-to-definition, find references, rename
186
- - Completions (keywords, declarations, imports)
187
- - Code actions (auto-import)
188
- - Document symbols
189
-
190
238
  ## Programmatic API
191
239
 
192
240
  ```typescript
193
- import { compileToProgram, compile } from '@graft-lang/graft/compiler';
194
- import { Executor } from '@graft-lang/graft/runtime';
195
- import type { Program, GraftErrorCode } from '@graft-lang/graft/types';
241
+ import { compile } from '@jsleekr/graft/compiler';
242
+ import { Executor } from '@jsleekr/graft/runtime';
243
+ import type { Program } from '@jsleekr/graft/types';
196
244
 
197
- const result = compileToProgram(source, 'pipeline.gft');
245
+ const result = compile(source, 'pipeline.gft');
198
246
  if (result.success) {
199
247
  console.log(`Parsed ${result.program.nodes.length} nodes`);
200
248
  }
201
249
  ```
202
250
 
251
+ ## Editor Support
252
+
253
+ The [Graft VS Code extension](editors/vscode/) provides syntax highlighting, real-time diagnostics, hover, go-to-definition, find references, rename, completions, and code actions.
254
+
203
255
  ## Development
204
256
 
205
257
  ```bash
206
258
  git clone https://github.com/JSLEEKR/graft.git
207
259
  cd graft && npm install
208
260
  npm run build # Compile TypeScript
209
- npm test # Run all 1,344 tests
210
- ```
211
-
212
- ## Version History
213
-
214
- | Version | Highlights |
215
- |---------|-----------|
216
- | **v5.3** | Parallel codegen test coverage, hook entry merging |
217
- | **v5.2** | Parallel→sequential edge transforms, agent input overrides, graceful hooks |
218
- | **v5.1** | Claude Code compatibility, `graft init`, CI/CD, README rewrite |
219
- | **v5.0** | Condition-to-Expr AST unification, strict equality, codegen expression display |
220
- | **v4.9** | Codegen expression display |
221
- | **v4.8** | LSP expression intelligence — hover, go-to-def, completions |
222
- | **v4.7** | Null coalescing (`??`), runtime expression hardening |
223
- | **v4.6** | Logical operators (`&&`, `||`), conditional type warnings |
224
- | **v4.5** | Comparison operators, conditional expressions |
225
- | **v4.4** | String interpolation, expression extraction |
226
- | **v4.0** | Variables, expressions, graph parameters, graph calls |
227
- | **v3.0** | Pluggable codegen backends, field-level writes, failure strategies |
228
- | **v2.0** | Import system, persistent memory |
229
- | **v1.0** | Full compiler pipeline, CLI |
230
-
231
- See [CHANGELOG.md](CHANGELOG.md) for full details.
261
+ npm test # Run all 1,574 tests
262
+ ```
232
263
 
233
264
  ## License
234
265
 
@@ -1,4 +1,4 @@
1
- import { GraftError } from '../errors/diagnostics.js';
1
+ import { GraftError, didYouMean } from '../errors/diagnostics.js';
2
2
  import { ProgramIndex } from '../program-index.js';
3
3
  import { checkVarCollision, checkExprSources, checkGraphCallArgs, checkGraphRecursion, } from './graph-checker.js';
4
4
  export class ScopeChecker {
@@ -56,17 +56,28 @@ export class ScopeChecker {
56
56
  const isProduces = this.index.producesFieldsMap.has(ref.context);
57
57
  const isMemory = this.index.memoryMap.has(ref.context);
58
58
  if (!isContext && !isProduces && !isMemory) {
59
- errors.push(new GraftError(`'${ref.context}' is not declared as a context, produces output, or memory`, ref.location, 'error', 'SCOPE_UNDEFINED_REF'));
59
+ const allNames = [
60
+ ...this.index.contextMap.keys(),
61
+ ...this.index.producesFieldsMap.keys(),
62
+ ...this.index.memoryMap.keys(),
63
+ ];
64
+ const suggestion = didYouMean(ref.context, allNames);
65
+ const help = suggestion
66
+ ? `did you mean '${suggestion}'?`
67
+ : undefined;
68
+ errors.push(new GraftError(`'${ref.context}' is not declared as a context, produces output, or memory`, ref.location, 'error', 'SCOPE_UNDEFINED_REF', help));
60
69
  continue;
61
70
  }
62
71
  // Check partial reference fields
63
72
  if (ref.field) {
64
73
  if (isContext) {
65
74
  const ctx = this.index.contextMap.get(ref.context);
66
- const fieldNames = new Set(ctx.fields.map(f => f.name));
75
+ const fieldNames = ctx.fields.map(f => f.name);
76
+ const fieldSet = new Set(fieldNames);
67
77
  for (const f of ref.field) {
68
- if (!fieldNames.has(f)) {
69
- errors.push(new GraftError(`Field '${f}' does not exist in context '${ref.context}'`, ref.location, 'error', 'SCOPE_FIELD_NOT_FOUND'));
78
+ if (!fieldSet.has(f)) {
79
+ const suggestion = didYouMean(f, fieldNames);
80
+ errors.push(new GraftError(`Field '${f}' does not exist in context '${ref.context}'`, ref.location, 'error', 'SCOPE_FIELD_NOT_FOUND', suggestion ? `did you mean '${suggestion}'?` : undefined));
70
81
  }
71
82
  }
72
83
  }
@@ -74,7 +85,8 @@ export class ScopeChecker {
74
85
  const fields = this.index.producesFieldsMap.get(ref.context);
75
86
  for (const f of ref.field) {
76
87
  if (!fields.has(f)) {
77
- errors.push(new GraftError(`Field '${f}' does not exist in produces '${ref.context}'`, ref.location, 'error', 'SCOPE_FIELD_NOT_FOUND'));
88
+ const suggestion = didYouMean(f, [...fields.keys()]);
89
+ errors.push(new GraftError(`Field '${f}' does not exist in produces '${ref.context}'`, ref.location, 'error', 'SCOPE_FIELD_NOT_FOUND', suggestion ? `did you mean '${suggestion}'?` : undefined));
78
90
  }
79
91
  }
80
92
  }
@@ -11,6 +11,7 @@ export interface CodegenBackend {
11
11
  readonly name: string;
12
12
  generateAgent(node: NodeDecl, memoryNames: Set<string>, ctx: CodegenContext): string;
13
13
  generateHook(edge: EdgeDecl, ctx: CodegenContext): string | null;
14
+ generateConditionalHook?(edge: EdgeDecl, ctx: CodegenContext): string | null;
14
15
  generateOrchestration(ctx: CodegenContext): string;
15
16
  generateSettings(ctx: CodegenContext): Record<string, unknown>;
16
17
  }
@@ -4,6 +4,7 @@ export declare class ClaudeCodeBackend implements CodegenBackend {
4
4
  readonly name = "claude";
5
5
  generateAgent(node: NodeDecl, memoryNames: Set<string>, ctx: CodegenContext): string;
6
6
  generateHook(edge: EdgeDecl, _ctx: CodegenContext): string | null;
7
+ generateConditionalHook(edge: EdgeDecl, _ctx: CodegenContext): string | null;
7
8
  generateOrchestration(ctx: CodegenContext): string;
8
9
  generateSettings(ctx: CodegenContext): Record<string, unknown>;
9
10
  }
@@ -1,5 +1,5 @@
1
1
  import { generateAgent } from './agents.js';
2
- import { generateHook } from './hooks.js';
2
+ import { generateHook, generateConditionalHook } from './hooks.js';
3
3
  import { generateOrchestration } from './orchestration.js';
4
4
  import { generateSettings } from './settings.js';
5
5
  export class ClaudeCodeBackend {
@@ -7,8 +7,9 @@ export class ClaudeCodeBackend {
7
7
  generateAgent(node, memoryNames, ctx) {
8
8
  // Compute input overrides: map produces names to actual file paths
9
9
  const inputOverrides = new Map();
10
- // 1. For edges with transforms: use the transformed output path
11
- // 2. For edges without transforms: use the source's raw output path
10
+ // 1. For direct edges with transforms: use the transformed output path
11
+ // 2. For direct edges without transforms: use the source's raw output path
12
+ // 3. For conditional edges targeting this node: use the source's raw output path
12
13
  for (const edge of ctx.program.edges) {
13
14
  if (edge.target.kind === 'direct' && edge.target.node === node.name) {
14
15
  const sourceNode = ctx.program.nodes.find(n => n.name === edge.source);
@@ -22,6 +23,18 @@ export class ClaudeCodeBackend {
22
23
  }
23
24
  }
24
25
  }
26
+ else if (edge.target.kind === 'conditional') {
27
+ const isTarget = edge.target.branches.some(b => b.target === node.name);
28
+ if (isTarget) {
29
+ const sourceNode = ctx.program.nodes.find(n => n.name === edge.source);
30
+ if (sourceNode) {
31
+ const producesName = sourceNode.produces.name;
32
+ if (!inputOverrides.has(producesName)) {
33
+ inputOverrides.set(producesName, `.graft/session/node_outputs/${edge.source.toLowerCase()}.json`);
34
+ }
35
+ }
36
+ }
37
+ }
25
38
  }
26
39
  // 3. For produces reads with no corresponding edge: resolve to producer's raw output
27
40
  for (const ref of node.reads) {
@@ -38,6 +51,9 @@ export class ClaudeCodeBackend {
38
51
  generateHook(edge, _ctx) {
39
52
  return generateHook(edge);
40
53
  }
54
+ generateConditionalHook(edge, _ctx) {
55
+ return generateConditionalHook(edge);
56
+ }
41
57
  generateOrchestration(ctx) {
42
58
  return generateOrchestration(ctx.program, ctx.report);
43
59
  }
@@ -18,14 +18,27 @@ export function generate(program, report, sourceFile, index, backend) {
18
18
  }
19
19
  // Hooks
20
20
  for (const edge of program.edges) {
21
- const hook = be.generateHook(edge, ctx);
22
- if (hook && edge.target.kind === 'direct') {
23
- const source = edge.source.toLowerCase();
24
- const target = edge.target.node.toLowerCase();
25
- files.push({
26
- path: `.claude/hooks/${source}-to-${target}.js`,
27
- content: hook,
28
- });
21
+ if (edge.target.kind === 'conditional') {
22
+ // Conditional edge router hook
23
+ const hook = be.generateConditionalHook?.(edge, ctx);
24
+ if (hook) {
25
+ files.push({
26
+ path: `.claude/hooks/${edge.source.toLowerCase()}-router.js`,
27
+ content: hook,
28
+ });
29
+ }
30
+ }
31
+ else {
32
+ // Direct edge → transform hook
33
+ const hook = be.generateHook(edge, ctx);
34
+ if (hook && edge.target.kind === 'direct') {
35
+ const source = edge.source.toLowerCase();
36
+ const target = edge.target.node.toLowerCase();
37
+ files.push({
38
+ path: `.claude/hooks/${source}-to-${target}.js`,
39
+ content: hook,
40
+ });
41
+ }
29
42
  }
30
43
  }
31
44
  // Orchestration
@@ -42,6 +55,11 @@ export function generate(program, report, sourceFile, index, backend) {
42
55
  // Runtime scaffold
43
56
  files.push({ path: '.graft/session/node_outputs/.gitkeep', content: '' });
44
57
  files.push({ path: '.graft/token_log.txt', content: '' });
58
+ // Routing scaffold (only when conditional edges exist)
59
+ const hasConditionalEdges = program.edges.some(e => e.target.kind === 'conditional');
60
+ if (hasConditionalEdges) {
61
+ files.push({ path: '.graft/session/routing/.gitkeep', content: '' });
62
+ }
45
63
  // Memory scaffold — conditional
46
64
  if (program.memories.length > 0) {
47
65
  files.push({ path: '.graft/memory/.gitkeep', content: '' });
@@ -1,2 +1,7 @@
1
1
  import { EdgeDecl } from '../parser/ast.js';
2
2
  export declare function generateHook(edge: EdgeDecl): string | null;
3
+ /**
4
+ * Generate a router hook for conditional edges.
5
+ * Evaluates branch conditions and writes routing decision.
6
+ */
7
+ export declare function generateConditionalHook(edge: EdgeDecl): string | null;
@@ -163,3 +163,86 @@ function filterToJs(t) {
163
163
  const op = condition.op === '==' ? '===' : condition.op === '!=' ? '!==' : condition.op;
164
164
  return `result[${JSON.stringify(field)}] = (result[${JSON.stringify(field)}] || []).filter(item => item[${JSON.stringify(fieldName)}] ${op} ${valueStr});`;
165
165
  }
166
+ /**
167
+ * Generate a router hook for conditional edges.
168
+ * Evaluates branch conditions and writes routing decision.
169
+ */
170
+ export function generateConditionalHook(edge) {
171
+ if (edge.target.kind !== 'conditional')
172
+ return null;
173
+ const source = edge.source.toLowerCase();
174
+ const branches = edge.target.branches;
175
+ const targets = branches.map(b => b.target).filter(t => t !== 'done');
176
+ const conditionCode = branchesToJs(branches);
177
+ return `#!/usr/bin/env node
178
+ // Auto-generated by Graft Compiler
179
+ // Conditional routing: ${edge.source} -> {${branches.map(b => b.target).join(', ')}}
180
+
181
+ const fs = require('fs');
182
+ const path = require('path');
183
+
184
+ const INPUT = path.resolve('.graft/session/node_outputs/${source}.json');
185
+ const ROUTE = path.resolve('.graft/session/routing/${source}_route.json');
186
+
187
+ if (!fs.existsSync(INPUT)) {
188
+ process.exit(0);
189
+ }
190
+
191
+ const data = JSON.parse(fs.readFileSync(INPUT, 'utf-8'));
192
+
193
+ ${conditionCode}
194
+
195
+ fs.mkdirSync(path.dirname(ROUTE), { recursive: true });
196
+ fs.writeFileSync(ROUTE, JSON.stringify({ target, from: ${JSON.stringify(edge.source)} }, null, 2));
197
+ `;
198
+ }
199
+ function branchesToJs(branches) {
200
+ const lines = [];
201
+ let first = true;
202
+ for (const branch of branches) {
203
+ if (!branch.condition) {
204
+ // else branch
205
+ if (first) {
206
+ lines.push(`let target = ${JSON.stringify(branch.target)};`);
207
+ }
208
+ else {
209
+ lines.push(`} else {`);
210
+ lines.push(` target = ${JSON.stringify(branch.target)};`);
211
+ lines.push(`}`);
212
+ }
213
+ }
214
+ else {
215
+ const jsCondition = exprToJs(branch.condition);
216
+ if (first) {
217
+ lines.push(`let target = null;`);
218
+ lines.push(`if (${jsCondition}) {`);
219
+ lines.push(` target = ${JSON.stringify(branch.target)};`);
220
+ first = false;
221
+ }
222
+ else {
223
+ lines.push(`} else if (${jsCondition}) {`);
224
+ lines.push(` target = ${JSON.stringify(branch.target)};`);
225
+ }
226
+ }
227
+ }
228
+ // Close the if chain if we have conditions but no else
229
+ if (!first && !branches.some(b => !b.condition)) {
230
+ lines.push(`}`);
231
+ }
232
+ return lines.join('\n');
233
+ }
234
+ function exprToJs(expr) {
235
+ if (expr.kind === 'binary') {
236
+ const left = exprToJs(expr.left);
237
+ const right = exprToJs(expr.right);
238
+ const op = expr.op === '==' ? '===' : expr.op === '!=' ? '!==' : expr.op;
239
+ return `${left} ${op} ${right}`;
240
+ }
241
+ if (expr.kind === 'field_access') {
242
+ return `data[${JSON.stringify(expr.segments[0])}]`;
243
+ }
244
+ if (expr.kind === 'literal') {
245
+ return JSON.stringify(expr.value);
246
+ }
247
+ return formatExpr(expr);
248
+ }
@@ -7,12 +7,16 @@ export function generateOrchestration(program, report) {
7
7
  const index = new ProgramIndex(program);
8
8
  const memoryNames = new Set(program.memories.map(m => m.name));
9
9
  const edgeMap = new Map();
10
+ const conditionalEdgeMap = new Map();
10
11
  for (const edge of program.edges) {
11
12
  if (edge.target.kind === 'direct' && edge.transforms.length > 0) {
12
13
  edgeMap.set(`${edge.source}->${edge.target.node}`, { transforms: edge.transforms });
13
14
  }
15
+ else if (edge.target.kind === 'conditional') {
16
+ conditionalEdgeMap.set(edge.source, edge);
17
+ }
14
18
  }
15
- const { text: steps } = generateSteps(graph.flow, report, edgeMap, 1, null, index.nodeMap, memoryNames);
19
+ const { text: steps } = generateSteps(graph.flow, report, edgeMap, conditionalEdgeMap, 1, null, index.nodeMap, memoryNames);
16
20
  // Memory preamble
17
21
  const memorySection = program.memories.length > 0
18
22
  ? `
@@ -70,7 +74,42 @@ function describeTransforms(transforms) {
70
74
  }
71
75
  return parts.join(', then ');
72
76
  }
73
- function generateSteps(flow, report, edgeMap, startStep, prevNode, nodeMap, memoryNames) {
77
+ function describeCondition(expr) {
78
+ if (expr.kind === 'binary') {
79
+ const left = expr.left.kind === 'field_access' ? `\`${expr.left.segments[0]}\`` : formatExpr(expr.left);
80
+ const right = expr.right.kind === 'literal' ? `\`${expr.right.value}\`` : formatExpr(expr.right);
81
+ return `${left} ${expr.op} ${right}`;
82
+ }
83
+ return formatExpr(expr);
84
+ }
85
+ function generateConditionalRoutingStep(stepNum, edge, nodeMap, report) {
86
+ if (edge.target.kind !== 'conditional')
87
+ return '';
88
+ const source = edge.source.toLowerCase();
89
+ const branches = edge.target.branches;
90
+ let text = `
91
+ ### Step ${stepNum}: Conditional routing from ${edge.source}
92
+ - **Automatic**: Router hook evaluates conditions on ${edge.source}'s output
93
+ - Routing file: \`.graft/session/routing/${source}_route.json\`
94
+ - Read the \`target\` field and proceed accordingly:
95
+ `;
96
+ for (const branch of branches) {
97
+ const label = branch.condition ? describeCondition(branch.condition) : 'else (default)';
98
+ if (branch.target === 'done') {
99
+ text += ` - If ${label}: **pipeline complete**\n`;
100
+ }
101
+ else {
102
+ const nodeReport = report.nodes.find(n => n.name === branch.target);
103
+ const tokenInfo = nodeReport
104
+ ? ` (tokens: input ~${nodeReport.estimatedIn.toLocaleString('en-US')} / output ~${nodeReport.estimatedOut.toLocaleString('en-US')})`
105
+ : '';
106
+ text += ` - If ${label}: run **${branch.target}** agent${tokenInfo}\n`;
107
+ }
108
+ }
109
+ text += `- Each branch agent reads from \`.graft/session/node_outputs/${source}.json\`\n`;
110
+ return text;
111
+ }
112
+ function generateSteps(flow, report, edgeMap, conditionalEdgeMap, startStep, prevNode, nodeMap, memoryNames) {
74
113
  let text = '';
75
114
  let stepNum = startStep;
76
115
  let prev = prevNode;
@@ -154,9 +193,15 @@ function generateSteps(flow, report, edgeMap, startStep, prevNode, nodeMap, memo
154
193
  - Completion: \`===NODE_COMPLETE:${lowerName}===\`
155
194
  - Output: \`.graft/session/node_outputs/${lowerName}.json\`
156
195
  `;
196
+ stepNum++;
197
+ // Conditional routing after this node
198
+ const condEdge = conditionalEdgeMap.get(step.name);
199
+ if (condEdge && condEdge.target.kind === 'conditional') {
200
+ text += generateConditionalRoutingStep(stepNum, condEdge, nodeMap, report);
201
+ stepNum++;
202
+ }
157
203
  prev = step.name;
158
204
  prevParallelBranches = [];
159
- stepNum++;
160
205
  break;
161
206
  }
162
207
  case 'parallel': {
@@ -42,17 +42,25 @@ export function generateSettings(program, sourceFile, index) {
42
42
  // Collect all hook commands, then merge into a single "Write" matcher entry
43
43
  const hookCommands = [];
44
44
  for (const edge of program.edges) {
45
- if (edge.transforms.length === 0)
46
- continue;
47
- if (edge.target.kind !== 'direct')
48
- continue;
49
- const source = edge.source.toLowerCase();
50
- const target = edge.target.node.toLowerCase();
51
- hookCommands.push({
52
- type: 'command',
53
- command: `node .claude/hooks/${source}-to-${target}.js`,
54
- if: `Write(.graft/session/node_outputs/${source}.json)`,
55
- });
45
+ if (edge.target.kind === 'conditional') {
46
+ // Conditional edge → router hook
47
+ const source = edge.source.toLowerCase();
48
+ hookCommands.push({
49
+ type: 'command',
50
+ command: `node .claude/hooks/${source}-router.js`,
51
+ if: `Write(.graft/session/node_outputs/${source}.json)`,
52
+ });
53
+ }
54
+ else if (edge.target.kind === 'direct' && edge.transforms.length > 0) {
55
+ // Direct edge with transforms → transform hook
56
+ const source = edge.source.toLowerCase();
57
+ const target = edge.target.node.toLowerCase();
58
+ hookCommands.push({
59
+ type: 'command',
60
+ command: `node .claude/hooks/${source}-to-${target}.js`,
61
+ if: `Write(.graft/session/node_outputs/${source}.json)`,
62
+ });
63
+ }
56
64
  }
57
65
  const hookEntries = [];
58
66
  if (hookCommands.length > 0) {
@@ -16,6 +16,12 @@ export declare class GraftError extends Error {
16
16
  readonly location: SourceLocation;
17
17
  readonly severity: 'error' | 'warning';
18
18
  readonly code?: GraftErrorCode | undefined;
19
- constructor(message: string, location: SourceLocation, severity?: 'error' | 'warning', code?: GraftErrorCode | undefined);
20
- format(source: string): string;
19
+ readonly help?: string | undefined;
20
+ constructor(message: string, location: SourceLocation, severity?: 'error' | 'warning', code?: GraftErrorCode | undefined, help?: string | undefined);
21
+ format(source: string, filename?: string): string;
21
22
  }
23
+ /**
24
+ * Find the closest match to `name` from `candidates` using Levenshtein distance.
25
+ * Returns the best match if distance <= maxDistance, otherwise undefined.
26
+ */
27
+ export declare function didYouMean(name: string, candidates: string[], maxDistance?: number): string | undefined;
@@ -2,24 +2,81 @@ export class GraftError extends Error {
2
2
  location;
3
3
  severity;
4
4
  code;
5
- constructor(message, location, severity = 'error', code) {
5
+ help;
6
+ constructor(message, location, severity = 'error', code, help) {
6
7
  super(message);
7
8
  this.location = location;
8
9
  this.severity = severity;
9
10
  this.code = code;
11
+ this.help = help;
10
12
  this.name = 'GraftError';
11
13
  }
12
- format(source) {
14
+ format(source, filename) {
13
15
  const lines = source.split('\n');
14
16
  const lineIdx = this.location.line - 1;
15
17
  const line = (lineIdx >= 0 && lineIdx < lines.length) ? lines[lineIdx] : '';
16
18
  const col = Math.max(0, this.location.column - 1);
17
- const pointer = ' '.repeat(col) + '^';
18
- return [
19
- `Error at line ${this.location.line}:${this.location.column}:`,
20
- ` ${line}`,
21
- ` ${pointer}`,
22
- ` ${this.message}`,
23
- ].join('\n');
19
+ const lineNumStr = String(this.location.line);
20
+ const gutter = ' '.repeat(lineNumStr.length);
21
+ // Underline: use length if available, otherwise single caret
22
+ const underlineLen = this.location.length && this.location.length > 0
23
+ ? this.location.length
24
+ : 1;
25
+ const underline = '^'.repeat(underlineLen);
26
+ const label = this.severity === 'warning' ? 'warning' : 'error';
27
+ const codeStr = this.code ? `[${this.code}]` : '';
28
+ const file = filename || '<source>';
29
+ const result = [
30
+ `${label}${codeStr}: ${this.message}`,
31
+ ` ${gutter}--> ${file}:${this.location.line}:${this.location.column}`,
32
+ ` ${gutter} |`,
33
+ ` ${lineNumStr} | ${line}`,
34
+ ` ${gutter} | ${' '.repeat(col)}${underline}`,
35
+ ];
36
+ if (this.help) {
37
+ result.push(` ${gutter} |`);
38
+ result.push(` ${gutter} = help: ${this.help}`);
39
+ }
40
+ return result.join('\n');
24
41
  }
25
42
  }
43
+ /**
44
+ * Find the closest match to `name` from `candidates` using Levenshtein distance.
45
+ * Returns the best match if distance <= maxDistance, otherwise undefined.
46
+ */
47
+ export function didYouMean(name, candidates, maxDistance = 3) {
48
+ let best;
49
+ let bestDist = maxDistance + 1;
50
+ for (const candidate of candidates) {
51
+ const dist = levenshtein(name.toLowerCase(), candidate.toLowerCase());
52
+ if (dist < bestDist) {
53
+ bestDist = dist;
54
+ best = candidate;
55
+ }
56
+ }
57
+ return bestDist <= maxDistance ? best : undefined;
58
+ }
59
+ function levenshtein(a, b) {
60
+ const m = a.length;
61
+ const n = b.length;
62
+ if (m === 0)
63
+ return n;
64
+ if (n === 0)
65
+ return m;
66
+ // Single-row DP
67
+ let prev = new Array(n + 1);
68
+ let curr = new Array(n + 1);
69
+ for (let j = 0; j <= n; j++)
70
+ prev[j] = j;
71
+ for (let i = 1; i <= m; i++) {
72
+ curr[0] = i;
73
+ for (let j = 1; j <= n; j++) {
74
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
75
+ curr[j] = Math.min(curr[j - 1] + 1, // insert
76
+ prev[j] + 1, // delete
77
+ prev[j - 1] + cost);
78
+ }
79
+ [prev, curr] = [curr, prev];
80
+ }
81
+ return prev[n];
82
+ }
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { Command } from 'commander';
3
3
  import * as fs from 'node:fs';
4
4
  import * as path from 'node:path';
5
- import { compile, compileAndWrite } from './compiler.js';
5
+ import { compile, compileAndWrite, compileToProgram } from './compiler.js';
6
6
  import { VERSION } from './version.js';
7
7
  import { formatTokenReport } from './format.js';
8
8
  const KNOWN_BACKENDS = new Set(['claude']);
@@ -34,7 +34,7 @@ program
34
34
  if (!result.success) {
35
35
  console.error('\n✗ Compilation failed:\n');
36
36
  for (const err of result.errors) {
37
- console.error(err.format(source));
37
+ console.error(err.format(source, file));
38
38
  console.error('');
39
39
  }
40
40
  process.exit(1);
@@ -67,7 +67,7 @@ program
67
67
  if (!result.success) {
68
68
  console.error('\n✗ Check failed:\n');
69
69
  for (const err of result.errors) {
70
- console.error(err.format(source));
70
+ console.error(err.format(source, file));
71
71
  console.error('');
72
72
  }
73
73
  process.exit(1);
@@ -170,6 +170,134 @@ graph ${safeName}(input: Input, output: Output, budget: 10k) {
170
170
  console.log(` # Open in Claude Code to run the pipeline`);
171
171
  console.log('');
172
172
  });
173
+ program
174
+ .command('watch')
175
+ .description('Watch .gft file and recompile on changes')
176
+ .argument('<file>', '.gft source file')
177
+ .option('--out-dir <dir>', 'output directory', '.')
178
+ .action((file, opts) => {
179
+ const resolved = path.resolve(file);
180
+ if (!fs.existsSync(resolved)) {
181
+ console.error(`Error: file not found: ${resolved}`);
182
+ process.exit(1);
183
+ }
184
+ function doCompile() {
185
+ const source = fs.readFileSync(resolved, 'utf-8');
186
+ try {
187
+ const result = compileAndWrite(source, resolved, path.resolve(opts.outDir));
188
+ if (!result.success) {
189
+ console.error('\n✗ Compilation failed:\n');
190
+ for (const err of result.errors) {
191
+ console.error(err.format(source, file));
192
+ console.error('');
193
+ }
194
+ }
195
+ else {
196
+ const timestamp = new Date().toLocaleTimeString();
197
+ const fileCount = result.files?.length || 0;
198
+ console.log(`[${timestamp}] ✓ Compiled ${file} → ${fileCount} files`);
199
+ for (const w of result.warnings) {
200
+ console.log(` ⚠ ${w.message}`);
201
+ }
202
+ }
203
+ }
204
+ catch (e) {
205
+ console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
206
+ }
207
+ }
208
+ // Initial compile
209
+ doCompile();
210
+ console.log(`\nWatching ${file} for changes... (Ctrl+C to stop)\n`);
211
+ // Watch for changes
212
+ let debounceTimer = null;
213
+ fs.watch(resolved, () => {
214
+ if (debounceTimer)
215
+ clearTimeout(debounceTimer);
216
+ debounceTimer = setTimeout(doCompile, 100);
217
+ });
218
+ // Also watch imported files in the same directory
219
+ const dir = path.dirname(resolved);
220
+ try {
221
+ fs.watch(dir, { recursive: false }, (_, filename) => {
222
+ if (filename && filename.endsWith('.gft') && filename !== path.basename(resolved)) {
223
+ if (debounceTimer)
224
+ clearTimeout(debounceTimer);
225
+ debounceTimer = setTimeout(doCompile, 100);
226
+ }
227
+ });
228
+ }
229
+ catch {
230
+ // Ignore if directory watch fails
231
+ }
232
+ });
233
+ program
234
+ .command('visualize')
235
+ .description('Output pipeline DAG as a Mermaid diagram')
236
+ .argument('<file>', '.gft source file')
237
+ .option('--format <fmt>', 'output format: mermaid', 'mermaid')
238
+ .action((file) => {
239
+ const source = readSource(file);
240
+ const result = compileToProgram(source, path.resolve(file));
241
+ if (!result.success || !result.program) {
242
+ console.error('\n✗ Compilation failed:\n');
243
+ for (const err of result.errors) {
244
+ console.error(err.format(source, file));
245
+ console.error('');
246
+ }
247
+ process.exit(1);
248
+ }
249
+ const { program: prog } = result;
250
+ const lines = ['graph TD'];
251
+ // Nodes
252
+ for (const node of prog.nodes) {
253
+ const model = node.model;
254
+ lines.push(` ${node.name}["${node.name}<br/><small>${model}</small>"]`);
255
+ }
256
+ // Direct edges
257
+ for (const edge of prog.edges) {
258
+ if (edge.target.kind === 'direct') {
259
+ const label = edge.transforms.length > 0
260
+ ? edge.transforms.map(t => t.type).join(' → ')
261
+ : '';
262
+ if (label) {
263
+ lines.push(` ${edge.source} -->|${label}| ${edge.target.node}`);
264
+ }
265
+ else {
266
+ lines.push(` ${edge.source} --> ${edge.target.node}`);
267
+ }
268
+ }
269
+ else if (edge.target.kind === 'conditional') {
270
+ for (const branch of edge.target.branches) {
271
+ const target = branch.target === 'done' ? 'done((done))' : branch.target;
272
+ const label = branch.condition
273
+ ? formatExprForMermaid(branch.condition)
274
+ : 'else';
275
+ lines.push(` ${edge.source} -->|${label}| ${target}`);
276
+ }
277
+ }
278
+ }
279
+ // Graph flow (parallel blocks)
280
+ if (prog.graphs[0]) {
281
+ for (const step of prog.graphs[0].flow) {
282
+ if (step.kind === 'parallel') {
283
+ lines.push(` subgraph parallel["parallel"]`);
284
+ for (const b of step.branches) {
285
+ lines.push(` ${b}`);
286
+ }
287
+ lines.push(` end`);
288
+ }
289
+ }
290
+ }
291
+ console.log(lines.join('\n'));
292
+ });
293
+ function formatExprForMermaid(expr) {
294
+ if (expr.kind === 'binary') {
295
+ const left = expr.left.kind === 'field_access' ? expr.left.segments[0] : '?';
296
+ const right = expr.right.kind === 'literal' ? String(expr.right.value) : '?';
297
+ return `${left} ${expr.op} ${right}`;
298
+ }
299
+ return '?';
300
+ }
173
301
  function readSource(file) {
174
302
  const resolved = path.resolve(file);
175
303
  if (!fs.existsSync(resolved)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsleekr/graft",
3
- "version": "5.7.1",
3
+ "version": "5.8.0",
4
4
  "description": "Graft compiler — compile .gft graph DSL to Claude Code harness structures",
5
5
  "type": "module",
6
6
  "license": "MIT",