Skip to content

Commit 34ff957

Browse files
committed
feat: Implement workflow generation command with YAML validation and LLM integration
- Added `workflow.schema.json` for defining the structure of SkillFlow workflows. - Created `workflow.ts` to handle the `gitclaw workflow generate` command, including parsing flags and invoking the LLM. - Introduced `schemas.ts` for loading and validating workflows against the defined schema. - Developed `workflow-generator.ts` to manage LLM interactions and generate workflows based on user prompts. - Implemented tests for workflow generation and validation to ensure functionality and correctness. - Enhanced `package.json` test script for improved testing capabilities.
1 parent a9c65d0 commit 34ff957

9 files changed

Lines changed: 1073 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"build": "tsc && cp src/voice/ui.html dist/voice/",
4141
"dev": "tsc --watch",
4242
"start": "node dist/index.js",
43-
"test": "node --test test/*.test.ts --experimental-strip-types"
43+
"test": "node --experimental-strip-types --experimental-loader=./test/ts-resolve-hook.mjs --no-warnings --test test/*.test.ts"
4444
},
4545
"engines": {
4646
"node": ">=20"

spec/schemas/workflow.schema.json

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"$id": "https://gitagent.dev/spec/workflow.schema.json",
4+
"title": "Gitagent SkillFlow Workflow",
5+
"description": "A SkillFlow workflow: a named sequence of steps, where each step invokes a skill with a prompt. Runtime semantics are defined by src/workflows.ts.",
6+
"type": "object",
7+
"additionalProperties": false,
8+
"required": ["name", "description", "steps"],
9+
"properties": {
10+
"name": {
11+
"type": "string",
12+
"description": "Kebab-case identifier for the workflow. Used as the file name.",
13+
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
14+
},
15+
"description": {
16+
"type": "string",
17+
"description": "One-line description of what the workflow does.",
18+
"minLength": 1
19+
},
20+
"steps": {
21+
"type": "array",
22+
"description": "Ordered list of steps. Steps execute top-to-bottom.",
23+
"minItems": 1,
24+
"items": { "$ref": "#/definitions/step" }
25+
}
26+
},
27+
"definitions": {
28+
"step": {
29+
"type": "object",
30+
"additionalProperties": false,
31+
"required": ["skill", "prompt"],
32+
"properties": {
33+
"id": {
34+
"type": "string",
35+
"description": "Optional snake_case identifier for the step. Used when other steps reference this one via depends_on.",
36+
"pattern": "^[a-z0-9]+(_[a-z0-9]+)*$"
37+
},
38+
"skill": {
39+
"type": "string",
40+
"description": "Name of an installed skill (kebab-case) the step will invoke. Must match an entry in the agent's skills/ directory, or 'approval' for a human-review step.",
41+
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
42+
},
43+
"prompt": {
44+
"type": "string",
45+
"description": "The natural-language instruction passed to the skill for this step.",
46+
"minLength": 1
47+
},
48+
"channel": {
49+
"type": "string",
50+
"description": "Optional channel/destination for the step output (e.g. a Slack channel name).",
51+
"minLength": 1
52+
},
53+
"depends_on": {
54+
"type": "array",
55+
"description": "Optional list of step ids that must complete before this step runs.",
56+
"items": {
57+
"type": "string",
58+
"pattern": "^[a-z0-9]+(_[a-z0-9]+)*$"
59+
},
60+
"uniqueItems": true
61+
},
62+
"requires_approval": {
63+
"type": "boolean",
64+
"description": "If true, the workflow pauses for human approval before this step runs."
65+
}
66+
}
67+
}
68+
}
69+
}

src/commands/workflow.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { mkdir, readFile, writeFile } from "fs/promises";
2+
import { join, resolve } from "path";
3+
import { discoverSkills } from "../skills.js";
4+
import { validateWorkflow } from "../utils/schemas.js";
5+
import { generateWorkflow, type LlmClient } from "../utils/workflow-generator.js";
6+
7+
interface GenerateFlags {
8+
dir: string;
9+
prompt?: string;
10+
refine?: string;
11+
model?: string;
12+
apiKey?: string;
13+
dryRun: boolean;
14+
}
15+
16+
const RED = (s: string) => `\x1b[31m${s}\x1b[0m`;
17+
const GREEN = (s: string) => `\x1b[32m${s}\x1b[0m`;
18+
const DIM = (s: string) => `\x1b[2m${s}\x1b[0m`;
19+
const BOLD = (s: string) => `\x1b[1m${s}\x1b[0m`;
20+
21+
const MAX_RETRIES = 2;
22+
23+
function printHelp(): void {
24+
console.log(`${BOLD("gitagent workflow")} — generate SkillFlow workflows from natural language
25+
26+
Usage:
27+
gitagent workflow generate [options]
28+
29+
Options:
30+
-d, --dir <path> Agent directory (default: current directory)
31+
-p, --prompt <text> Natural-language description of the workflow (required)
32+
--refine <file> Refine an existing workflow YAML by applying --prompt as an instruction
33+
-m, --model <spec> LLM model in provider:model form (default: openai:gpt-4o)
34+
--api-key <key> API key for the provider (falls back to OPENAI_API_KEY or <PROVIDER>_API_KEY)
35+
--dry-run Print the generated YAML to stdout instead of writing a file
36+
-h, --help Show this help message
37+
38+
Examples:
39+
gitagent workflow generate -p "every morning summarize unread emails and post to Slack"
40+
gitagent workflow generate -p "add a human approval step before the Slack post" --refine workflows/morning-digest.yaml
41+
`);
42+
}
43+
44+
function parseFlags(argv: string[]): GenerateFlags {
45+
const flags: GenerateFlags = { dir: process.cwd(), dryRun: false };
46+
for (let i = 0; i < argv.length; i++) {
47+
const a = argv[i];
48+
switch (a) {
49+
case "-d":
50+
case "--dir":
51+
flags.dir = argv[++i];
52+
break;
53+
case "-p":
54+
case "--prompt":
55+
flags.prompt = argv[++i];
56+
break;
57+
case "--refine":
58+
flags.refine = argv[++i];
59+
break;
60+
case "-m":
61+
case "--model":
62+
flags.model = argv[++i];
63+
break;
64+
case "--api-key":
65+
flags.apiKey = argv[++i];
66+
break;
67+
case "--dry-run":
68+
flags.dryRun = true;
69+
break;
70+
case "-h":
71+
case "--help":
72+
printHelp();
73+
process.exit(0);
74+
break;
75+
default:
76+
if (!a.startsWith("-") && flags.prompt === undefined) {
77+
flags.prompt = a;
78+
} else {
79+
console.error(RED(`Unknown option: ${a}`));
80+
process.exit(2);
81+
}
82+
}
83+
}
84+
return flags;
85+
}
86+
87+
function slugify(name: string): string {
88+
const cleaned = name
89+
.toLowerCase()
90+
.trim()
91+
.replace(/[^a-z0-9-]+/g, "-")
92+
.replace(/^-+|-+$/g, "")
93+
.replace(/-+/g, "-");
94+
return cleaned || "workflow";
95+
}
96+
97+
export interface RunGenerateOptions {
98+
flags: GenerateFlags;
99+
llm?: LlmClient;
100+
}
101+
102+
export async function runGenerate(opts: RunGenerateOptions): Promise<{ filePath?: string; yaml: string; }> {
103+
const { flags } = opts;
104+
if (!flags.prompt || !flags.prompt.trim()) {
105+
throw new Error("--prompt is required");
106+
}
107+
108+
const agentDir = resolve(flags.dir);
109+
const skills = await discoverSkills(agentDir);
110+
111+
let previousWorkflow: string | undefined;
112+
if (flags.refine) {
113+
const refinePath = resolve(agentDir, flags.refine);
114+
previousWorkflow = await readFile(refinePath, "utf-8");
115+
}
116+
117+
let promptForLlm = flags.prompt.trim();
118+
let lastErrors: string[] = [];
119+
let yaml = "";
120+
121+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
122+
console.error(DIM(attempt === 0 ? "Generating workflow..." : `Retry ${attempt}/${MAX_RETRIES} — fixing validation errors...`));
123+
yaml = await generateWorkflow({
124+
prompt: promptForLlm,
125+
skills,
126+
previousWorkflow,
127+
model: flags.model,
128+
apiKey: flags.apiKey,
129+
llm: opts.llm,
130+
});
131+
const result = validateWorkflow(yaml);
132+
if (result.valid) {
133+
lastErrors = [];
134+
break;
135+
}
136+
lastErrors = result.errors;
137+
if (attempt < MAX_RETRIES) {
138+
promptForLlm =
139+
`${flags.prompt.trim()}\n\nThe previous attempt failed schema validation. Fix these errors and return the full YAML again:\n` +
140+
result.errors.map((e) => `- ${e}`).join("\n");
141+
}
142+
}
143+
144+
if (lastErrors.length > 0) {
145+
console.error(RED("\nWorkflow validation failed after retries:"));
146+
for (const e of lastErrors) console.error(RED(` - ${e}`));
147+
console.error(DIM("\nLast generated YAML:\n"));
148+
console.error(yaml);
149+
throw new Error("Validation failed after retries");
150+
}
151+
152+
if (flags.dryRun) {
153+
process.stdout.write(yaml.endsWith("\n") ? yaml : yaml + "\n");
154+
return { yaml };
155+
}
156+
157+
// Parse the validated YAML to get the workflow name for the file path.
158+
const validated = validateWorkflow(yaml).data!;
159+
const slug = slugify(validated.name);
160+
const workflowsDir = join(agentDir, "workflows");
161+
await mkdir(workflowsDir, { recursive: true });
162+
const filePath = join(workflowsDir, `${slug}.yaml`);
163+
await writeFile(filePath, yaml.endsWith("\n") ? yaml : yaml + "\n", "utf-8");
164+
console.error(GREEN(`\nWrote workflow to ${filePath}`));
165+
return { filePath, yaml };
166+
}
167+
168+
export async function handleWorkflowCommand(argv: string[]): Promise<void> {
169+
// argv is the raw process.argv tail starting at the 'workflow' token.
170+
// argv[0] === "workflow"; argv[1] is the sub-command.
171+
const sub = argv[1];
172+
if (!sub || sub === "-h" || sub === "--help") {
173+
printHelp();
174+
return;
175+
}
176+
if (sub !== "generate") {
177+
console.error(RED(`Unknown subcommand: ${sub}`));
178+
printHelp();
179+
process.exit(2);
180+
}
181+
const flags = parseFlags(argv.slice(2));
182+
try {
183+
await runGenerate({ flags });
184+
} catch (err: any) {
185+
console.error(RED(`\nError: ${err?.message ?? String(err)}`));
186+
process.exit(1);
187+
}
188+
}

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { initLocalSession } from "./session.js";
2222
import type { LocalSession } from "./session.js";
2323
import { startVoiceServer } from "./voice/server.js";
2424
import { handlePluginCommand } from "./plugin-cli.js";
25+
import { handleWorkflowCommand } from "./commands/workflow.js";
2526
import { context as otelContext } from "@opentelemetry/api";
2627
import {
2728
initTelemetry,
@@ -301,6 +302,12 @@ async function ensureRepo(dir: string, model?: string): Promise<string> {
301302
}
302303

303304
async function main(): Promise<void> {
305+
// Handle workflow subcommand: gitagent workflow <generate|...>
306+
if (process.argv[2] === "workflow") {
307+
await handleWorkflowCommand(process.argv.slice(2));
308+
return;
309+
}
310+
304311
// Handle plugin subcommand: gitagent plugin <install|list|remove|...>
305312
if (process.argv[2] === "plugin") {
306313
const allArgs = process.argv.slice(3);

0 commit comments

Comments
 (0)