@jsleekr/graft 5.8.0 → 6.0.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 +106 -130
- package/dist/codegen/generic-backend.d.ts +17 -0
- package/dist/codegen/generic-backend.js +156 -0
- package/dist/compiler.d.ts +4 -3
- package/dist/compiler.js +6 -6
- package/dist/formatter.d.ts +5 -0
- package/dist/formatter.js +170 -0
- package/dist/generator.d.ts +28 -0
- package/dist/generator.js +255 -0
- package/dist/index.js +220 -20
- package/dist/runner.d.ts +3 -0
- package/dist/runner.js +7 -0
- package/dist/runtime/feedback.d.ts +20 -0
- package/dist/runtime/feedback.js +152 -0
- package/dist/runtime/result-formatter.d.ts +13 -0
- package/dist/runtime/result-formatter.js +142 -0
- package/dist/runtime/result-validator.d.ts +28 -0
- package/dist/runtime/result-validator.js +129 -0
- package/dist/test-runner.d.ts +33 -0
- package/dist/test-runner.js +223 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -5,7 +5,17 @@ import * as path from 'node:path';
|
|
|
5
5
|
import { compile, compileAndWrite, compileToProgram } from './compiler.js';
|
|
6
6
|
import { VERSION } from './version.js';
|
|
7
7
|
import { formatTokenReport } from './format.js';
|
|
8
|
-
|
|
8
|
+
import { formatProgram } from './formatter.js';
|
|
9
|
+
import { ClaudeCodeBackend } from './codegen/claude-backend.js';
|
|
10
|
+
import { GenericBackend } from './codegen/generic-backend.js';
|
|
11
|
+
const KNOWN_BACKENDS = new Set(['claude', 'generic']);
|
|
12
|
+
function resolveBackend(name) {
|
|
13
|
+
switch (name) {
|
|
14
|
+
case 'claude': return new ClaudeCodeBackend();
|
|
15
|
+
case 'generic': return new GenericBackend();
|
|
16
|
+
default: throw new Error(`Unknown backend: ${name}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
9
19
|
const program = new Command();
|
|
10
20
|
program
|
|
11
21
|
.name('graft')
|
|
@@ -23,9 +33,10 @@ program
|
|
|
23
33
|
process.exit(1);
|
|
24
34
|
}
|
|
25
35
|
const source = readSource(file);
|
|
36
|
+
const backend = resolveBackend(opts.backend);
|
|
26
37
|
let result;
|
|
27
38
|
try {
|
|
28
|
-
result = compileAndWrite(source, path.resolve(file), path.resolve(opts.outDir));
|
|
39
|
+
result = compileAndWrite(source, path.resolve(file), path.resolve(opts.outDir), backend);
|
|
29
40
|
}
|
|
30
41
|
catch (e) {
|
|
31
42
|
console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -91,10 +102,14 @@ program
|
|
|
91
102
|
.option('--input <file>', 'input JSON file')
|
|
92
103
|
.option('--dry-run', 'simulate execution without spawning subprocesses')
|
|
93
104
|
.option('--verbose', 'print execution details')
|
|
105
|
+
.option('--json', 'output results as JSON')
|
|
94
106
|
.option('--timeout <seconds>', 'subprocess timeout in seconds', '300')
|
|
95
107
|
.option('--work-dir <dir>', 'working directory for execution')
|
|
96
108
|
.action(async (file, opts) => {
|
|
97
|
-
const { run } = await import('./runner.js');
|
|
109
|
+
const { run, getProgram } = await import('./runner.js');
|
|
110
|
+
const { formatRunResult } = await import('./runtime/result-formatter.js');
|
|
111
|
+
const { validateResult, formatQualityReport } = await import('./runtime/result-validator.js');
|
|
112
|
+
const { generateFeedback, formatSuggestions } = await import('./runtime/feedback.js');
|
|
98
113
|
const result = await run({
|
|
99
114
|
sourceFile: file,
|
|
100
115
|
inputFile: opts.input,
|
|
@@ -103,29 +118,28 @@ program
|
|
|
103
118
|
verbose: opts.verbose,
|
|
104
119
|
timeoutMs: parseInt(opts.timeout, 10) * 1000,
|
|
105
120
|
});
|
|
121
|
+
// Get the program for validation
|
|
122
|
+
const prog = getProgram();
|
|
123
|
+
// Format result
|
|
124
|
+
console.log('\n' + formatRunResult(result, prog, { json: opts.json, verbose: opts.verbose }));
|
|
125
|
+
// Quality validation + feedback (skip for JSON mode)
|
|
126
|
+
if (!opts.json && prog) {
|
|
127
|
+
const report = validateResult(result, prog);
|
|
128
|
+
console.log('\n' + formatQualityReport(report));
|
|
129
|
+
const suggestions = generateFeedback(report, prog);
|
|
130
|
+
if (suggestions.length > 0) {
|
|
131
|
+
console.log(formatSuggestions(suggestions));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
106
134
|
if (!result.success) {
|
|
107
|
-
console.error('\nExecution failed:');
|
|
108
|
-
for (const err of result.errors)
|
|
109
|
-
console.error(` ${err}`);
|
|
110
135
|
process.exit(1);
|
|
111
136
|
}
|
|
112
|
-
console.log(`\nGraph '${result.graph}' completed in ${result.totalDurationMs}ms`);
|
|
113
|
-
console.log(`Nodes executed: ${result.nodeResults.length}`);
|
|
114
|
-
for (const nr of result.nodeResults) {
|
|
115
|
-
const status = nr.success ? 'OK' : 'FAILED';
|
|
116
|
-
console.log(` ${nr.node.padEnd(20)} ${status.padEnd(8)} ${nr.durationMs}ms`);
|
|
117
|
-
}
|
|
118
|
-
if (result.finalOutput !== null) {
|
|
119
|
-
console.log('\nFinal output:');
|
|
120
|
-
console.log(JSON.stringify(result.finalOutput, null, 2));
|
|
121
|
-
}
|
|
122
|
-
console.log('');
|
|
123
137
|
});
|
|
124
138
|
program
|
|
125
139
|
.command('init')
|
|
126
140
|
.description('Scaffold a new Graft project')
|
|
127
141
|
.argument('<name>', 'project name')
|
|
128
|
-
.action((name) => {
|
|
142
|
+
.action(async (name) => {
|
|
129
143
|
const dir = path.resolve(name);
|
|
130
144
|
if (fs.existsSync(dir)) {
|
|
131
145
|
console.error(`Error: directory '${name}' already exists`);
|
|
@@ -134,6 +148,7 @@ program
|
|
|
134
148
|
fs.mkdirSync(dir, { recursive: true });
|
|
135
149
|
const baseName = path.basename(name);
|
|
136
150
|
const safeName = baseName.replace(/[^a-zA-Z0-9]/g, '_').replace(/^_+|_+$/g, '') || 'pipeline';
|
|
151
|
+
// Generate pipeline.gft starter template
|
|
137
152
|
fs.writeFileSync(path.join(dir, 'pipeline.gft'), `// ${safeName} — a simple two-node pipeline
|
|
138
153
|
|
|
139
154
|
context Input(max_tokens: 500) {
|
|
@@ -161,13 +176,58 @@ edge Analyst -> Reviewer | select(answer, confidence) | compact
|
|
|
161
176
|
graph ${safeName}(input: Input, output: Output, budget: 10k) {
|
|
162
177
|
Analyst -> Reviewer -> done
|
|
163
178
|
}
|
|
179
|
+
`);
|
|
180
|
+
// Generate .claude/CLAUDE.md with .gft spec so Claude Code natively understands Graft
|
|
181
|
+
const claudeDir = path.join(dir, '.claude');
|
|
182
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
183
|
+
const { buildSystemPrompt } = await import('./generator.js');
|
|
184
|
+
const gftSpec = buildSystemPrompt();
|
|
185
|
+
fs.writeFileSync(path.join(claudeDir, 'CLAUDE.md'), `# ${safeName}
|
|
186
|
+
|
|
187
|
+
This project uses **Graft** (.gft) for defining multi-agent pipelines.
|
|
188
|
+
|
|
189
|
+
## Working with .gft files
|
|
190
|
+
|
|
191
|
+
When the user asks to create, modify, or manage pipelines:
|
|
192
|
+
1. Write or edit \`.gft\` files using the syntax below
|
|
193
|
+
2. Run \`graft compile <file.gft>\` to generate the \`.claude/\` harness structure
|
|
194
|
+
3. Run \`graft check <file.gft>\` to validate without generating files
|
|
195
|
+
|
|
196
|
+
## CLI Commands
|
|
197
|
+
|
|
198
|
+
\`\`\`bash
|
|
199
|
+
graft compile <file.gft> [--out-dir <dir>] # Compile to harness structure
|
|
200
|
+
graft check <file.gft> # Parse + analyze only
|
|
201
|
+
graft run <file.gft> --input <json> # Compile, execute, validate, suggest fixes
|
|
202
|
+
graft fmt <file.gft> [-w] # Format .gft source
|
|
203
|
+
graft visualize <file.gft> # Output pipeline DAG as Mermaid
|
|
204
|
+
graft watch <file.gft> # Watch and recompile on changes
|
|
205
|
+
\`\`\`
|
|
206
|
+
|
|
207
|
+
## After Pipeline Execution
|
|
208
|
+
|
|
209
|
+
When a pipeline run completes, \`graft run\` automatically:
|
|
210
|
+
1. Shows a formatted result summary (nodes, tokens, timing)
|
|
211
|
+
2. Validates output against the .gft schema (types, ranges, empty fields)
|
|
212
|
+
3. Suggests .gft modifications if quality issues are found
|
|
213
|
+
|
|
214
|
+
If the quality report shows issues:
|
|
215
|
+
- Empty fields → suggest increasing node output budget
|
|
216
|
+
- Budget exhaustion → suggest adding edge transforms (select, compact, truncate)
|
|
217
|
+
- Node failures → suggest adding on_failure: retry(N)
|
|
218
|
+
- Type mismatches → check the node's prompt and produces schema
|
|
219
|
+
|
|
220
|
+
Apply the suggested fixes to the .gft file, then run \`graft compile\` and \`graft run\` again.
|
|
221
|
+
|
|
222
|
+
${gftSpec}
|
|
164
223
|
`);
|
|
165
224
|
console.log(`\nCreated ${name}/`);
|
|
166
|
-
console.log(` pipeline.gft`);
|
|
225
|
+
console.log(` pipeline.gft — starter pipeline template`);
|
|
226
|
+
console.log(` .claude/CLAUDE.md — Graft spec for Claude Code`);
|
|
167
227
|
console.log(`\nNext steps:`);
|
|
168
228
|
console.log(` cd ${name}`);
|
|
169
229
|
console.log(` graft compile pipeline.gft`);
|
|
170
|
-
console.log(` # Open in Claude Code
|
|
230
|
+
console.log(` # Open in Claude Code — it already knows .gft syntax`);
|
|
171
231
|
console.log('');
|
|
172
232
|
});
|
|
173
233
|
program
|
|
@@ -290,6 +350,146 @@ program
|
|
|
290
350
|
}
|
|
291
351
|
console.log(lines.join('\n'));
|
|
292
352
|
});
|
|
353
|
+
program
|
|
354
|
+
.command('fmt')
|
|
355
|
+
.description('Format .gft source file')
|
|
356
|
+
.argument('<file>', '.gft source file')
|
|
357
|
+
.option('--check', 'check if file is already formatted (exit 1 if not)')
|
|
358
|
+
.option('-w, --write', 'write formatted output back to the file')
|
|
359
|
+
.action((file, opts) => {
|
|
360
|
+
const source = readSource(file);
|
|
361
|
+
const result = compileToProgram(source, path.resolve(file));
|
|
362
|
+
if (!result.success || !result.program) {
|
|
363
|
+
console.error('\n✗ Parse failed:\n');
|
|
364
|
+
for (const err of result.errors) {
|
|
365
|
+
console.error(err.format(source, file));
|
|
366
|
+
console.error('');
|
|
367
|
+
}
|
|
368
|
+
process.exit(1);
|
|
369
|
+
}
|
|
370
|
+
const formatted = formatProgram(result.program);
|
|
371
|
+
if (opts.check) {
|
|
372
|
+
if (source === formatted) {
|
|
373
|
+
console.log(`✓ ${file} is already formatted`);
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
console.log(`✗ ${file} needs formatting`);
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (opts.write) {
|
|
382
|
+
fs.writeFileSync(path.resolve(file), formatted, 'utf-8');
|
|
383
|
+
console.log(`✓ Formatted ${file}`);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
// Default: print to stdout
|
|
387
|
+
process.stdout.write(formatted);
|
|
388
|
+
});
|
|
389
|
+
program
|
|
390
|
+
.command('test')
|
|
391
|
+
.description('Test a .gft pipeline with mock data (dry-run + validation)')
|
|
392
|
+
.argument('<file>', '.gft source file')
|
|
393
|
+
.option('--input <json>', 'input JSON string or file path')
|
|
394
|
+
.option('--verbose', 'print detailed node outputs')
|
|
395
|
+
.action(async (file, opts) => {
|
|
396
|
+
const { runTest } = await import('./test-runner.js');
|
|
397
|
+
const source = readSource(file);
|
|
398
|
+
let input;
|
|
399
|
+
if (opts.input) {
|
|
400
|
+
// Try as JSON string first, then as file path
|
|
401
|
+
try {
|
|
402
|
+
input = JSON.parse(opts.input);
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
const inputPath = path.resolve(opts.input);
|
|
406
|
+
if (fs.existsSync(inputPath)) {
|
|
407
|
+
try {
|
|
408
|
+
input = JSON.parse(fs.readFileSync(inputPath, 'utf-8'));
|
|
409
|
+
}
|
|
410
|
+
catch (e) {
|
|
411
|
+
console.error(`Error: failed to parse input file: ${e instanceof Error ? e.message : String(e)}`);
|
|
412
|
+
process.exit(1);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
console.error(`Error: --input is not valid JSON and file not found: ${opts.input}`);
|
|
417
|
+
process.exit(1);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const result = await runTest({
|
|
422
|
+
source,
|
|
423
|
+
sourceFile: path.resolve(file),
|
|
424
|
+
input,
|
|
425
|
+
verbose: opts.verbose,
|
|
426
|
+
});
|
|
427
|
+
if (result.compileErrors.length > 0) {
|
|
428
|
+
console.error('\nCompilation failed:');
|
|
429
|
+
for (const err of result.compileErrors)
|
|
430
|
+
console.error(` ${err}`);
|
|
431
|
+
process.exit(1);
|
|
432
|
+
}
|
|
433
|
+
console.log(`\nTest input: ${JSON.stringify(result.inputUsed)}`);
|
|
434
|
+
console.log('');
|
|
435
|
+
let allPassed = true;
|
|
436
|
+
for (const nr of result.nodeResults) {
|
|
437
|
+
const status = nr.passed ? 'PASS' : 'FAIL';
|
|
438
|
+
const icon = nr.passed ? '+' : 'x';
|
|
439
|
+
console.log(` [${icon}] ${nr.node.padEnd(20)} ${status}`);
|
|
440
|
+
if (!nr.passed) {
|
|
441
|
+
allPassed = false;
|
|
442
|
+
for (const err of nr.validationErrors) {
|
|
443
|
+
console.log(` ${err}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (opts.verbose && nr.output) {
|
|
447
|
+
console.log(` output: ${JSON.stringify(nr.output)}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
console.log('');
|
|
451
|
+
if (allPassed) {
|
|
452
|
+
console.log(`All ${result.nodeResults.length} nodes passed validation.`);
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
const failed = result.nodeResults.filter(r => !r.passed).length;
|
|
456
|
+
console.log(`${failed} of ${result.nodeResults.length} nodes failed validation.`);
|
|
457
|
+
process.exit(1);
|
|
458
|
+
}
|
|
459
|
+
console.log('');
|
|
460
|
+
});
|
|
461
|
+
program
|
|
462
|
+
.command('generate')
|
|
463
|
+
.description('Generate .gft from a natural language description (requires Claude Code CLI)')
|
|
464
|
+
.argument('<description>', 'what the pipeline should do')
|
|
465
|
+
.option('--output <file>', 'write .gft to file (default: stdout)')
|
|
466
|
+
.action(async (description, opts) => {
|
|
467
|
+
const { generateGft } = await import('./generator.js');
|
|
468
|
+
try {
|
|
469
|
+
const result = await generateGft(description, { output: opts.output });
|
|
470
|
+
if (result.errors.length > 0) {
|
|
471
|
+
console.error('\nGeneration completed with validation errors:\n');
|
|
472
|
+
for (const err of result.errors) {
|
|
473
|
+
console.error(err.format(result.source, 'generated.gft'));
|
|
474
|
+
console.error('');
|
|
475
|
+
}
|
|
476
|
+
console.error('--- Raw output (fix manually) ---\n');
|
|
477
|
+
console.log(result.source);
|
|
478
|
+
process.exit(1);
|
|
479
|
+
}
|
|
480
|
+
if (opts.output) {
|
|
481
|
+
fs.writeFileSync(path.resolve(opts.output), result.source, 'utf-8');
|
|
482
|
+
console.error(`✓ Generated ${opts.output}`);
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
process.stdout.write(result.source);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
catch (e) {
|
|
489
|
+
console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
490
|
+
process.exit(1);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
293
493
|
function formatExprForMermaid(expr) {
|
|
294
494
|
if (expr.kind === 'binary') {
|
|
295
495
|
const left = expr.left.kind === 'field_access' ? expr.left.segments[0] : '?';
|
package/dist/runner.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { RunResult, SpawnerFn } from './runtime/executor.js';
|
|
2
|
+
import { Program } from './parser/ast.js';
|
|
2
3
|
export type { RunResult, RunOptions, SpawnerFn } from './runtime/executor.js';
|
|
4
|
+
/** Get the Program from the last run() call (for validation/feedback). */
|
|
5
|
+
export declare function getProgram(): Program | undefined;
|
|
3
6
|
export interface RunInput {
|
|
4
7
|
sourceFile: string;
|
|
5
8
|
inputFile?: string;
|
package/dist/runner.js
CHANGED
|
@@ -2,6 +2,11 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { compile } from './compiler.js';
|
|
4
4
|
import { Executor } from './runtime/executor.js';
|
|
5
|
+
let _lastProgram;
|
|
6
|
+
/** Get the Program from the last run() call (for validation/feedback). */
|
|
7
|
+
export function getProgram() {
|
|
8
|
+
return _lastProgram;
|
|
9
|
+
}
|
|
5
10
|
export async function run(opts) {
|
|
6
11
|
const sourceFile = path.resolve(opts.sourceFile);
|
|
7
12
|
if (!fs.existsSync(sourceFile)) {
|
|
@@ -11,8 +16,10 @@ export async function run(opts) {
|
|
|
11
16
|
const workDir = opts.workDir ?? path.dirname(sourceFile);
|
|
12
17
|
const compileResult = compile(source, sourceFile);
|
|
13
18
|
if (!compileResult.success || !compileResult.program) {
|
|
19
|
+
_lastProgram = undefined;
|
|
14
20
|
return { success: false, graph: '', nodeResults: [], finalOutput: null, totalDurationMs: 0, errors: compileResult.errors.map(e => e.message) };
|
|
15
21
|
}
|
|
22
|
+
_lastProgram = compileResult.program;
|
|
16
23
|
const programIndex = compileResult.index;
|
|
17
24
|
let input;
|
|
18
25
|
if (opts.input) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feedback generator — produces .gft modification suggestions from quality reports.
|
|
3
|
+
*/
|
|
4
|
+
import { Program } from '../parser/ast.js';
|
|
5
|
+
import { QualityReport } from './result-validator.js';
|
|
6
|
+
export interface Suggestion {
|
|
7
|
+
type: 'budget' | 'retry' | 'transform' | 'schema';
|
|
8
|
+
severity: 'info' | 'warning' | 'error';
|
|
9
|
+
node: string;
|
|
10
|
+
message: string;
|
|
11
|
+
fix?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Generate .gft modification suggestions from a quality report.
|
|
15
|
+
*/
|
|
16
|
+
export declare function generateFeedback(report: QualityReport, program: Program): Suggestion[];
|
|
17
|
+
/**
|
|
18
|
+
* Format suggestions for human-readable output.
|
|
19
|
+
*/
|
|
20
|
+
export declare function formatSuggestions(suggestions: Suggestion[]): string;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { ProgramIndex } from '../program-index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generate .gft modification suggestions from a quality report.
|
|
4
|
+
*/
|
|
5
|
+
export function generateFeedback(report, program) {
|
|
6
|
+
const suggestions = [];
|
|
7
|
+
const index = new ProgramIndex(program);
|
|
8
|
+
for (const check of report.checks) {
|
|
9
|
+
if (check.status === 'pass')
|
|
10
|
+
continue;
|
|
11
|
+
const suggestion = checkToSuggestion(check, index);
|
|
12
|
+
if (suggestion)
|
|
13
|
+
suggestions.push(suggestion);
|
|
14
|
+
}
|
|
15
|
+
return suggestions;
|
|
16
|
+
}
|
|
17
|
+
function checkToSuggestion(check, index) {
|
|
18
|
+
switch (check.category) {
|
|
19
|
+
case 'budget':
|
|
20
|
+
return budgetSuggestion(check, index);
|
|
21
|
+
case 'empty':
|
|
22
|
+
return emptySuggestion(check, index);
|
|
23
|
+
case 'schema':
|
|
24
|
+
return schemaSuggestion(check, index);
|
|
25
|
+
case 'type':
|
|
26
|
+
return typeSuggestion(check);
|
|
27
|
+
case 'range':
|
|
28
|
+
return rangeSuggestion(check);
|
|
29
|
+
default:
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function budgetSuggestion(check, index) {
|
|
34
|
+
if (check.status === 'fail') {
|
|
35
|
+
// Budget nearly exhausted — suggest adding edge transforms
|
|
36
|
+
const edges = [...index.edgesBySource.entries()];
|
|
37
|
+
const edgeHints = edges.length > 0
|
|
38
|
+
? `Add edge transforms to reduce token flow. Example:\n edge ${edges[0][0]} -> ... | truncate(500)`
|
|
39
|
+
: 'Increase graph budget or reduce node budgets.';
|
|
40
|
+
return {
|
|
41
|
+
type: 'transform',
|
|
42
|
+
severity: 'error',
|
|
43
|
+
node: '*',
|
|
44
|
+
message: 'Token budget nearly exhausted.',
|
|
45
|
+
fix: edgeHints,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
type: 'budget',
|
|
50
|
+
severity: 'warning',
|
|
51
|
+
node: '*',
|
|
52
|
+
message: 'Token budget usage is high. Consider adding compact or truncate transforms.',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function emptySuggestion(check, index) {
|
|
56
|
+
const nodeDecl = check.node !== '*' ? index.nodeMap.get(check.node) : undefined;
|
|
57
|
+
if (nodeDecl) {
|
|
58
|
+
const currentOut = nodeDecl.budgetOut;
|
|
59
|
+
const suggestedOut = currentOut * 2;
|
|
60
|
+
return {
|
|
61
|
+
type: 'budget',
|
|
62
|
+
severity: 'warning',
|
|
63
|
+
node: check.node,
|
|
64
|
+
message: `Field '${check.field}' is empty. The node may need more output tokens.`,
|
|
65
|
+
fix: `Increase ${check.node} output budget: budget: ${formatTokens(nodeDecl.budgetIn)}/${formatTokens(suggestedOut)}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
type: 'budget',
|
|
70
|
+
severity: 'warning',
|
|
71
|
+
node: check.node,
|
|
72
|
+
message: `Field '${check.field}' is empty.`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function schemaSuggestion(check, index) {
|
|
76
|
+
if (check.message.includes('failed') || check.message.includes('Failed')) {
|
|
77
|
+
const nodeDecl = check.node !== '*' ? index.nodeMap.get(check.node) : undefined;
|
|
78
|
+
if (nodeDecl && !nodeDecl.onFailure) {
|
|
79
|
+
return {
|
|
80
|
+
type: 'retry',
|
|
81
|
+
severity: 'error',
|
|
82
|
+
node: check.node,
|
|
83
|
+
message: `Node '${check.node}' failed. Add a failure strategy.`,
|
|
84
|
+
fix: `Add to ${check.node}: on_failure: retry(2)`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
type: 'retry',
|
|
89
|
+
severity: 'error',
|
|
90
|
+
node: check.node,
|
|
91
|
+
message: `Node '${check.node}' failed.`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (check.message.includes('Missing field')) {
|
|
95
|
+
return {
|
|
96
|
+
type: 'schema',
|
|
97
|
+
severity: 'error',
|
|
98
|
+
node: check.node,
|
|
99
|
+
message: check.message,
|
|
100
|
+
fix: `Check that ${check.node}'s prompt clearly requests the '${check.field}' field in its output.`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (check.message.includes('null')) {
|
|
104
|
+
return {
|
|
105
|
+
type: 'schema',
|
|
106
|
+
severity: 'error',
|
|
107
|
+
node: check.node,
|
|
108
|
+
message: `Node '${check.node}' produced null output. It may have silently failed.`,
|
|
109
|
+
fix: `Add to ${check.node}: on_failure: retry(2)`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
function typeSuggestion(check) {
|
|
115
|
+
return {
|
|
116
|
+
type: 'schema',
|
|
117
|
+
severity: 'error',
|
|
118
|
+
node: check.node,
|
|
119
|
+
message: `Type mismatch in ${check.node}.${check.field}: ${check.message}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function rangeSuggestion(check) {
|
|
123
|
+
return {
|
|
124
|
+
type: 'schema',
|
|
125
|
+
severity: 'error',
|
|
126
|
+
node: check.node,
|
|
127
|
+
message: `Range violation in ${check.node}.${check.field}: ${check.message}`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function formatTokens(n) {
|
|
131
|
+
if (n >= 1000)
|
|
132
|
+
return `${Math.round(n / 1000)}k`;
|
|
133
|
+
return String(n);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Format suggestions for human-readable output.
|
|
137
|
+
*/
|
|
138
|
+
export function formatSuggestions(suggestions) {
|
|
139
|
+
if (suggestions.length === 0)
|
|
140
|
+
return '';
|
|
141
|
+
const lines = [];
|
|
142
|
+
lines.push('\n\u2500\u2500 Suggestions ' + '\u2500'.repeat(35));
|
|
143
|
+
for (const s of suggestions) {
|
|
144
|
+
const icon = s.severity === 'error' ? '\u2717' : s.severity === 'warning' ? '\u26a0' : '\u2139';
|
|
145
|
+
lines.push(` ${icon} ${s.message}`);
|
|
146
|
+
if (s.fix) {
|
|
147
|
+
lines.push(` \u2192 ${s.fix}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
lines.push('\u2500'.repeat(48));
|
|
151
|
+
return lines.join('\n');
|
|
152
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result formatter — formats RunResult into human-readable output.
|
|
3
|
+
*/
|
|
4
|
+
import { RunResult } from './executor.js';
|
|
5
|
+
import { Program } from '../parser/ast.js';
|
|
6
|
+
export interface FormatOptions {
|
|
7
|
+
json?: boolean;
|
|
8
|
+
verbose?: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Format a RunResult for human-readable CLI output.
|
|
12
|
+
*/
|
|
13
|
+
export declare function formatRunResult(result: RunResult, program?: Program, options?: FormatOptions): string;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { ProgramIndex } from '../program-index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Format a RunResult for human-readable CLI output.
|
|
4
|
+
*/
|
|
5
|
+
export function formatRunResult(result, program, options) {
|
|
6
|
+
if (options?.json) {
|
|
7
|
+
return JSON.stringify({
|
|
8
|
+
success: result.success,
|
|
9
|
+
graph: result.graph,
|
|
10
|
+
duration_ms: result.totalDurationMs,
|
|
11
|
+
nodes: result.nodeResults.map(nr => ({
|
|
12
|
+
node: nr.node,
|
|
13
|
+
success: nr.success,
|
|
14
|
+
duration_ms: nr.durationMs,
|
|
15
|
+
tokens: nr.tokenUsage
|
|
16
|
+
? { input: nr.tokenUsage.inputTokens, output: nr.tokenUsage.outputTokens }
|
|
17
|
+
: undefined,
|
|
18
|
+
error: nr.error,
|
|
19
|
+
})),
|
|
20
|
+
token_usage: result.tokenUsage,
|
|
21
|
+
output: result.finalOutput,
|
|
22
|
+
errors: result.errors,
|
|
23
|
+
}, null, 2);
|
|
24
|
+
}
|
|
25
|
+
const lines = [];
|
|
26
|
+
const durationSec = (result.totalDurationMs / 1000).toFixed(1);
|
|
27
|
+
if (result.success) {
|
|
28
|
+
lines.push(`Graph '${result.graph}' completed in ${durationSec}s\n`);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
lines.push(`Graph '${result.graph}' FAILED after ${durationSec}s\n`);
|
|
32
|
+
}
|
|
33
|
+
// Node results table
|
|
34
|
+
const index = program ? new ProgramIndex(program) : undefined;
|
|
35
|
+
for (const nr of result.nodeResults) {
|
|
36
|
+
const icon = nr.success ? '\u2713' : '\u2717';
|
|
37
|
+
const nodeName = nr.node.padEnd(20);
|
|
38
|
+
const model = getModelShortName(nr.node, index);
|
|
39
|
+
const modelStr = model.padEnd(8);
|
|
40
|
+
const duration = `${(nr.durationMs / 1000).toFixed(1)}s`.padStart(6);
|
|
41
|
+
const tokens = formatNodeTokens(nr);
|
|
42
|
+
lines.push(` ${icon} ${nodeName} ${modelStr} ${duration} ${tokens}`);
|
|
43
|
+
if (!nr.success && nr.error) {
|
|
44
|
+
lines.push(` Error: ${nr.error}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Token usage summary
|
|
48
|
+
if (result.tokenUsage) {
|
|
49
|
+
const { budget, consumed, fraction } = result.tokenUsage;
|
|
50
|
+
const pct = Math.round(fraction * 100);
|
|
51
|
+
const bar = renderBar(fraction, 30);
|
|
52
|
+
lines.push('');
|
|
53
|
+
lines.push(`Token usage: ${consumed.toLocaleString()} / ${budget.toLocaleString()} (${pct}%)`);
|
|
54
|
+
lines.push(` ${bar}`);
|
|
55
|
+
if (fraction > 0.9) {
|
|
56
|
+
lines.push(' \u26a0 WARNING: >90% of budget consumed');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Final output summary
|
|
60
|
+
if (result.finalOutput !== null && result.finalOutput !== undefined) {
|
|
61
|
+
lines.push('');
|
|
62
|
+
const outputName = getOutputName(result, program);
|
|
63
|
+
lines.push(`\u2500\u2500 Final Output (${outputName}) ${'─'.repeat(Math.max(0, 40 - outputName.length))}`);
|
|
64
|
+
lines.push(formatOutput(result.finalOutput, options?.verbose));
|
|
65
|
+
lines.push('─'.repeat(48));
|
|
66
|
+
}
|
|
67
|
+
// Errors
|
|
68
|
+
if (result.errors.length > 0) {
|
|
69
|
+
lines.push('');
|
|
70
|
+
lines.push('Errors:');
|
|
71
|
+
for (const err of result.errors) {
|
|
72
|
+
lines.push(` \u2717 ${err}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return lines.join('\n');
|
|
76
|
+
}
|
|
77
|
+
function getModelShortName(nodeName, index) {
|
|
78
|
+
if (!index)
|
|
79
|
+
return '';
|
|
80
|
+
const node = index.nodeMap.get(nodeName);
|
|
81
|
+
if (!node)
|
|
82
|
+
return '';
|
|
83
|
+
return node.model;
|
|
84
|
+
}
|
|
85
|
+
function formatNodeTokens(nr) {
|
|
86
|
+
if (!nr.tokenUsage)
|
|
87
|
+
return '';
|
|
88
|
+
const total = nr.tokenUsage.inputTokens + nr.tokenUsage.outputTokens;
|
|
89
|
+
return `${total.toLocaleString()} tok`;
|
|
90
|
+
}
|
|
91
|
+
function renderBar(fraction, width) {
|
|
92
|
+
const filled = Math.round(fraction * width);
|
|
93
|
+
const empty = width - filled;
|
|
94
|
+
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty);
|
|
95
|
+
return `[${bar}]`;
|
|
96
|
+
}
|
|
97
|
+
function getOutputName(result, program) {
|
|
98
|
+
if (program && program.graphs.length > 0) {
|
|
99
|
+
return program.graphs[0].output;
|
|
100
|
+
}
|
|
101
|
+
return 'Result';
|
|
102
|
+
}
|
|
103
|
+
function formatOutput(output, verbose) {
|
|
104
|
+
if (output === null || output === undefined)
|
|
105
|
+
return ' (empty)';
|
|
106
|
+
if (typeof output === 'string') {
|
|
107
|
+
return verbose ? output : truncate(output, 200);
|
|
108
|
+
}
|
|
109
|
+
if (typeof output !== 'object') {
|
|
110
|
+
return String(output);
|
|
111
|
+
}
|
|
112
|
+
const lines = [];
|
|
113
|
+
const obj = output;
|
|
114
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
115
|
+
if (Array.isArray(value)) {
|
|
116
|
+
lines.push(` ${key}: ${value.length} items`);
|
|
117
|
+
if (verbose) {
|
|
118
|
+
for (const item of value.slice(0, 5)) {
|
|
119
|
+
lines.push(` - ${truncate(String(item), 100)}`);
|
|
120
|
+
}
|
|
121
|
+
if (value.length > 5)
|
|
122
|
+
lines.push(` ... and ${value.length - 5} more`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else if (typeof value === 'object' && value !== null) {
|
|
126
|
+
const keys = Object.keys(value);
|
|
127
|
+
lines.push(` ${key}: {${keys.length} fields}`);
|
|
128
|
+
}
|
|
129
|
+
else if (typeof value === 'string') {
|
|
130
|
+
lines.push(` ${key}: ${truncate(value, verbose ? 500 : 80)}`);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
lines.push(` ${key}: ${value}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return lines.join('\n');
|
|
137
|
+
}
|
|
138
|
+
function truncate(s, max) {
|
|
139
|
+
if (s.length <= max)
|
|
140
|
+
return s;
|
|
141
|
+
return s.slice(0, max - 3) + '...';
|
|
142
|
+
}
|