guild-agents 1.4.0 → 2.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.
Files changed (58) hide show
  1. package/README.md +69 -68
  2. package/bin/guild.js +4 -85
  3. package/package.json +2 -2
  4. package/src/commands/doctor.js +11 -33
  5. package/src/commands/init.js +1 -1
  6. package/src/templates/agents/advisor.md +0 -1
  7. package/src/templates/agents/developer.md +2 -2
  8. package/src/templates/agents/qa.md +1 -1
  9. package/src/templates/agents/tech-lead.md +2 -2
  10. package/src/templates/skills/build-feature/SKILL.md +59 -117
  11. package/src/templates/skills/build-feature/evals/evals.json +3 -4
  12. package/src/templates/skills/council/SKILL.md +6 -16
  13. package/src/templates/skills/council/evals/evals.json +3 -13
  14. package/src/templates/skills/create-pr/SKILL.md +2 -5
  15. package/src/templates/skills/guild-specialize/SKILL.md +2 -9
  16. package/src/templates/skills/qa-cycle/SKILL.md +0 -7
  17. package/src/templates/skills/re-specialize/SKILL.md +0 -3
  18. package/src/templates/skills/session-end/SKILL.md +77 -30
  19. package/src/templates/skills/session-start/SKILL.md +51 -20
  20. package/src/utils/eval-runner.js +2 -8
  21. package/src/utils/generators.js +3 -4
  22. package/src/utils/skill-parser.js +83 -0
  23. package/src/utils/trigger-runner.js +1 -1
  24. package/src/commands/logs.js +0 -63
  25. package/src/commands/reset-learnings.js +0 -44
  26. package/src/commands/run.js +0 -105
  27. package/src/commands/stats.js +0 -147
  28. package/src/templates/agents/db-migration.md +0 -51
  29. package/src/templates/agents/learnings-extractor.md +0 -49
  30. package/src/templates/agents/platform-expert.md +0 -92
  31. package/src/templates/agents/product-owner.md +0 -52
  32. package/src/templates/skills/dev-flow/SKILL.md +0 -83
  33. package/src/templates/skills/dev-flow/evals/evals.json +0 -36
  34. package/src/templates/skills/dev-flow/evals/triggers.json +0 -16
  35. package/src/templates/skills/new-feature/SKILL.md +0 -119
  36. package/src/templates/skills/new-feature/evals/evals.json +0 -41
  37. package/src/templates/skills/new-feature/evals/triggers.json +0 -16
  38. package/src/templates/skills/review/SKILL.md +0 -97
  39. package/src/templates/skills/review/evals/evals.json +0 -43
  40. package/src/templates/skills/review/evals/triggers.json +0 -16
  41. package/src/templates/skills/status/SKILL.md +0 -100
  42. package/src/templates/skills/status/evals/evals.json +0 -40
  43. package/src/templates/skills/status/evals/triggers.json +0 -16
  44. package/src/templates/skills/verify/SKILL.md +0 -114
  45. package/src/templates/skills/verify/evals/triggers.json +0 -16
  46. package/src/utils/accounting.js +0 -139
  47. package/src/utils/dispatch-protocol.js +0 -74
  48. package/src/utils/dispatch.js +0 -172
  49. package/src/utils/executor.js +0 -183
  50. package/src/utils/learnings-io.js +0 -76
  51. package/src/utils/learnings.js +0 -204
  52. package/src/utils/orchestrator-io.js +0 -356
  53. package/src/utils/orchestrator.js +0 -590
  54. package/src/utils/pricing.js +0 -28
  55. package/src/utils/providers/claude-code.js +0 -43
  56. package/src/utils/skill-loader.js +0 -83
  57. package/src/utils/trace.js +0 -400
  58. package/src/utils/workflow-parser.js +0 -225
@@ -1,83 +0,0 @@
1
- /**
2
- * skill-loader.js — Loads and validates Guild skills from disk.
3
- *
4
- * Reads SKILL.md files from .claude/skills/ directories, parses them
5
- * using the workflow parser, and returns structured skill objects ready
6
- * for consumption by the dispatcher or doctor checks.
7
- */
8
-
9
- import { existsSync, readFileSync, readdirSync } from 'fs';
10
- import { join } from 'path';
11
- import { parseSkill, validateWorkflow } from './workflow-parser.js';
12
-
13
- /**
14
- * Loads a skill by name from the skills directory.
15
- * Resolves the SKILL.md file, parses it, and validates the workflow if present.
16
- *
17
- * @param {string} skillName - Name of the skill (directory name)
18
- * @param {string} [basePath='.claude/skills'] - Base path for skills
19
- * @returns {{ name: string, description: string, userInvocable: boolean, workflow: object|null, body: string, errors: string[] }}
20
- * @throws {Error} If skill directory or SKILL.md not found
21
- */
22
- export function loadSkill(skillName, basePath = join('.claude', 'skills')) {
23
- const skillPath = join(basePath, skillName, 'SKILL.md');
24
-
25
- if (!existsSync(skillPath)) {
26
- throw new Error(`Skill not found: ${skillPath}`);
27
- }
28
-
29
- const content = readFileSync(skillPath, 'utf8');
30
- const skill = parseSkill(content);
31
-
32
- const errors = [];
33
- if (skill.workflow) {
34
- const validationErrors = validateWorkflow(skill.workflow);
35
- errors.push(...validationErrors);
36
- }
37
-
38
- return { ...skill, errors };
39
- }
40
-
41
- /**
42
- * Loads all skills from the skills directory.
43
- * Skills with invalid workflows are included with their errors populated.
44
- * Missing SKILL.md files are logged as warnings but not included.
45
- *
46
- * @param {string} [basePath='.claude/skills'] - Base path for skills
47
- * @returns {Map<string, { name: string, description: string, userInvocable: boolean, workflow: object|null, body: string, errors: string[] }>}
48
- */
49
- export function loadAllSkills(basePath = join('.claude', 'skills')) {
50
- const skills = new Map();
51
-
52
- if (!existsSync(basePath)) {
53
- return skills;
54
- }
55
-
56
- const dirs = readdirSync(basePath, { withFileTypes: true })
57
- .filter(d => d.isDirectory())
58
- .map(d => d.name);
59
-
60
- for (const dir of dirs) {
61
- const skillPath = join(basePath, dir, 'SKILL.md');
62
- if (!existsSync(skillPath)) {
63
- continue;
64
- }
65
-
66
- try {
67
- const skill = loadSkill(dir, basePath);
68
- skills.set(dir, skill);
69
- } catch (_err) {
70
- // Skip skills that fail to load entirely (malformed YAML, etc.)
71
- skills.set(dir, {
72
- name: dir,
73
- description: '',
74
- userInvocable: false,
75
- workflow: null,
76
- body: '',
77
- errors: [`Failed to load: ${_err.message}`],
78
- });
79
- }
80
- }
81
-
82
- return skills;
83
- }
@@ -1,400 +0,0 @@
1
- /**
2
- * trace.js — Structured trace file utilities for Guild workflows.
3
- *
4
- * Provides pure rendering functions (renderTrace, renderStep, renderSummary)
5
- * that take data in and return strings out, with zero I/O. Also provides
6
- * I/O functions (createTrace, finalizeTrace, listTraces, cleanTraces) for
7
- * file operations on `.claude/guild/traces/`.
8
- */
9
-
10
- import { mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, unlinkSync, statSync } from 'fs';
11
- import { join, dirname } from 'path';
12
- import { estimateTokens } from './learnings.js';
13
-
14
- /** @typedef {'default' | 'verbose' | 'debug'} TraceLevel */
15
-
16
- /**
17
- * @typedef {Object} TraceContext
18
- * @property {string} filePath - Absolute path to the trace file
19
- * @property {string} workflow - Skill/workflow name
20
- * @property {TraceLevel} level - Logging level
21
- * @property {Date} started - Workflow start time
22
- * @property {Array<TraceStep>} steps - Recorded steps
23
- * @property {number} totalTokens - Running token total
24
- */
25
-
26
- /**
27
- * @typedef {Object} TraceStep
28
- * @property {string} role - Agent role (e.g. 'tech-lead')
29
- * @property {string} intent - Brief description of what the agent was asked
30
- * @property {string} tier - Tier assignment (e.g. 'reasoning', 'execution')
31
- * @property {string} model - Resolved model ID
32
- * @property {boolean} fallback - Whether a fallback model was used
33
- * @property {string} started - ISO-8601 timestamp
34
- * @property {number} duration - Duration in seconds
35
- * @property {number} tokens - Token count for this step
36
- * @property {string} result - 'pass', 'fail', or 'skip'
37
- * @property {string} [decision] - Verbose+: why this model was chosen
38
- * @property {string} [reasoning] - Verbose+: agent's self-reported reasoning
39
- * @property {string} [fullPrompt] - Debug only: complete prompt sent
40
- * @property {string} [fullResponse] - Debug only: complete response received
41
- */
42
-
43
- /**
44
- * @typedef {Object} TraceSummary
45
- * @property {string} result - 'pass' or 'fail'
46
- * @property {boolean} testsPass - Whether tests passed
47
- * @property {boolean} lintPass - Whether lint passed
48
- */
49
-
50
- /**
51
- * Resolves the trace level from CLI option flags.
52
- * If both verbose and debug are set, debug wins (highest level).
53
- * @param {Object} [options] - CLI options
54
- * @param {boolean} [options.verbose] - Enable verbose logging
55
- * @param {boolean} [options.debug] - Enable debug logging
56
- * @returns {TraceLevel}
57
- */
58
- export function resolveTraceLevel(options = {}) {
59
- if (options.debug) return 'debug';
60
- if (options.verbose) return 'verbose';
61
- return 'default';
62
- }
63
-
64
- /**
65
- * Formats a duration in milliseconds to HH:MM:SS string.
66
- * @param {number} ms - Duration in milliseconds
67
- * @returns {string} Formatted as HH:MM:SS
68
- */
69
- export function formatDuration(ms) {
70
- const totalSeconds = Math.floor(ms / 1000);
71
- const hours = Math.floor(totalSeconds / 3600);
72
- const minutes = Math.floor((totalSeconds % 3600) / 60);
73
- const seconds = totalSeconds % 60;
74
- return [
75
- String(hours).padStart(2, '0'),
76
- String(minutes).padStart(2, '0'),
77
- String(seconds).padStart(2, '0'),
78
- ].join(':');
79
- }
80
-
81
- /**
82
- * Creates a new trace context object. Creates the traces directory if needed.
83
- * Does NOT write anything to disk — writing happens on finalize (lazy).
84
- * @param {string} workflowName - Skill/workflow name (e.g. 'build-feature')
85
- * @param {TraceLevel} level - Logging level
86
- * @param {string} [tracesDir] - Directory for trace files (default: .claude/guild/traces/)
87
- * @returns {TraceContext}
88
- */
89
- export function createTrace(workflowName, level, tracesDir) {
90
- const dir = tracesDir || join('.claude', 'guild', 'traces');
91
- mkdirSync(dir, { recursive: true });
92
-
93
- // Ensure traces directory is gitignored (local-only artifacts)
94
- const gitignorePath = join(dir, '.gitignore');
95
- if (!existsSync(gitignorePath)) {
96
- writeFileSync(gitignorePath, '*\n!.gitignore\n', 'utf8');
97
- }
98
-
99
- const now = new Date();
100
- const timestamp = now.toISOString()
101
- .replace(/[-:]/g, '')
102
- .replace(/\.\d{3}Z$/, '')
103
- .replace('T', 'T');
104
- const filename = `guild-trace-${timestamp}.md`;
105
-
106
- return {
107
- filePath: join(dir, filename),
108
- workflow: workflowName,
109
- level,
110
- started: now,
111
- steps: [],
112
- totalTokens: 0,
113
- };
114
- }
115
-
116
- /**
117
- * Records a step in the trace context. Pushes step data and updates token total.
118
- * @param {TraceContext} traceCtx - The trace context from createTrace
119
- * @param {TraceStep} stepData - The step data to record
120
- * @returns {TraceContext} The same context (for chaining)
121
- */
122
- export function recordStep(traceCtx, stepData) {
123
- traceCtx.steps.push(stepData);
124
- traceCtx.totalTokens += stepData.tokens || 0;
125
- return traceCtx;
126
- }
127
-
128
- /**
129
- * Renders a single step section as Markdown. PURE function — no I/O.
130
- * Only includes decision/reasoning for verbose+. Only includes
131
- * fullPrompt/fullResponse for debug level.
132
- * @param {TraceStep} step - Step data
133
- * @param {number} stepNumber - 1-based step index
134
- * @param {TraceLevel} level - Current trace level
135
- * @returns {string} Markdown string for the step
136
- */
137
- export function renderStep(step, stepNumber, level) {
138
- const lines = [];
139
- lines.push(`### Step ${stepNumber} — ${step.role}: ${step.intent}`);
140
- lines.push('');
141
- lines.push('| Field | Value |');
142
- lines.push('|-------|-------|');
143
- lines.push(`| Tier | ${step.tier} |`);
144
- lines.push(`| Model | ${step.model} |`);
145
- lines.push(`| Fallback | ${step.fallback ? 'yes' : 'no'} |`);
146
- lines.push(`| Started | ${step.started} |`);
147
- lines.push(`| Duration | ${step.duration}s |`);
148
- lines.push(`| Tokens | ${step.tokens} |`);
149
- lines.push(`| Result | ${step.result} |`);
150
-
151
- if ((level === 'verbose' || level === 'debug') && step.decision) {
152
- lines.push('');
153
- lines.push(`**Decision:** ${step.decision}`);
154
- }
155
- if ((level === 'verbose' || level === 'debug') && step.reasoning) {
156
- lines.push(`**Reasoning:** ${step.reasoning}`);
157
- }
158
-
159
- if (level === 'debug' && step.fullPrompt) {
160
- lines.push('');
161
- lines.push('<details>');
162
- lines.push('<summary>Full prompt</summary>');
163
- lines.push('');
164
- lines.push(step.fullPrompt);
165
- lines.push('');
166
- lines.push('</details>');
167
- }
168
-
169
- if (level === 'debug' && step.fullResponse) {
170
- lines.push('');
171
- lines.push('<details>');
172
- lines.push('<summary>Full response</summary>');
173
- lines.push('');
174
- lines.push(step.fullResponse);
175
- lines.push('');
176
- lines.push('</details>');
177
- }
178
-
179
- return lines.join('\n');
180
- }
181
-
182
- /**
183
- * Renders the summary section as Markdown. PURE function — no I/O.
184
- * @param {TraceSummary} summary - Summary data
185
- * @param {number} totalTokens - Total tokens across all steps
186
- * @param {string} duration - Formatted duration string (HH:MM:SS)
187
- * @returns {string} Markdown string for the summary
188
- */
189
- export function renderSummary(summary, totalTokens, duration) {
190
- const lines = [];
191
- lines.push('## Summary');
192
- lines.push('');
193
- lines.push(`- **Result**: ${summary.result}`);
194
- lines.push(`- **Tests**: ${summary.testsPass ? 'pass' : 'fail'}`);
195
- lines.push(`- **Lint**: ${summary.lintPass ? 'pass' : 'fail'}`);
196
- lines.push(`- **Total tokens**: ${totalTokens}`);
197
- lines.push(`- **Duration**: ${duration}`);
198
- return lines.join('\n');
199
- }
200
-
201
- /**
202
- * Renders a complete trace as a Markdown string. PURE function — no I/O.
203
- * @param {TraceContext} traceCtx - Trace context with all steps
204
- * @param {TraceSummary} summary - Final summary data
205
- * @param {Date} [finished] - Finish time (default: new Date())
206
- * @returns {string} Complete Markdown content
207
- */
208
- export function renderTrace(traceCtx, summary, finished) {
209
- const end = finished || new Date();
210
- const durationMs = end.getTime() - traceCtx.started.getTime();
211
- const durationStr = formatDuration(durationMs);
212
-
213
- const lines = [];
214
-
215
- // Header
216
- lines.push(`# Guild Trace — ${traceCtx.workflow}`);
217
- lines.push('');
218
- lines.push(`> Level: ${traceCtx.level}`);
219
- lines.push(`> Started: ${traceCtx.started.toISOString()}`);
220
- lines.push(`> Finished: ${end.toISOString()}`);
221
- lines.push(`> Duration: ${durationStr}`);
222
- lines.push(`> Total tokens: ${traceCtx.totalTokens}`);
223
- lines.push(`> Result: ${summary.result}`);
224
-
225
- // Steps
226
- lines.push('');
227
- lines.push('## Steps');
228
-
229
- for (let i = 0; i < traceCtx.steps.length; i++) {
230
- lines.push('');
231
- lines.push(renderStep(traceCtx.steps[i], i + 1, traceCtx.level));
232
- }
233
-
234
- // Summary
235
- lines.push('');
236
- lines.push(renderSummary(summary, traceCtx.totalTokens, durationStr));
237
-
238
- return lines.join('\n');
239
- }
240
-
241
- /** Maximum token budget for execution summaries. */
242
- export const EXECUTION_SUMMARY_BUDGET = 500;
243
-
244
- /**
245
- * Generates a compact execution summary for context injection.
246
- * Designed for the learnings-extractor agent which runs on tier routine (Haiku).
247
- * Pure function — no I/O.
248
- *
249
- * Dependency direction: trace.js imports estimateTokens from learnings.js.
250
- * learnings.js must NOT import from trace.js.
251
- *
252
- * @param {TraceContext} traceCtx - The trace context with recorded steps
253
- * @param {TraceSummary} summary - Final summary data (result, testsPass, lintPass)
254
- * @param {Date} [finished] - Finish time (default: new Date())
255
- * @returns {string} Plain text summary, estimated at <= 500 tokens
256
- */
257
- export function generateExecutionSummary(traceCtx, summary, finished) {
258
- const end = finished || new Date();
259
- const durationMs = end.getTime() - traceCtx.started.getTime();
260
- const durationStr = formatDuration(durationMs);
261
-
262
- const headerLines = [
263
- `Workflow: ${traceCtx.workflow}`,
264
- `Result: ${summary.result}`,
265
- `Steps: ${traceCtx.steps.length}`,
266
- `Tokens: ${traceCtx.totalTokens}`,
267
- `Duration: ${durationStr}`,
268
- ];
269
-
270
- const header = headerLines.join('\n') + '\n';
271
-
272
- if (traceCtx.steps.length === 0) {
273
- return header;
274
- }
275
-
276
- const stepLines = traceCtx.steps.map((step, i) =>
277
- `${i + 1}. ${step.role}: ${step.intent} → ${step.result} (${step.tokens} tokens, ${step.duration}s)`
278
- );
279
-
280
- // Try full summary first
281
- const full = header + '\n' + stepLines.join('\n');
282
- if (estimateTokens(full) <= EXECUTION_SUMMARY_BUDGET) {
283
- return full;
284
- }
285
-
286
- // Truncate step lines until within budget
287
- const included = [];
288
- for (let i = 0; i < stepLines.length; i++) {
289
- const omissionNotice = `\n... (${traceCtx.steps.length - (i + 1)} steps omitted)`;
290
- const candidate = header + '\n' + [...included, stepLines[i]].join('\n') + omissionNotice;
291
- if (estimateTokens(candidate) > EXECUTION_SUMMARY_BUDGET) {
292
- break;
293
- }
294
- included.push(stepLines[i]);
295
- }
296
-
297
- const omitted = traceCtx.steps.length - included.length;
298
- if (included.length === 0) {
299
- return header + `\n... (${omitted} steps omitted)`;
300
- }
301
- return header + '\n' + included.join('\n') + `\n... (${omitted} steps omitted)`;
302
- }
303
-
304
- /**
305
- * Finalizes a trace: computes duration, renders markdown, writes to disk.
306
- * @param {TraceContext} traceCtx - The trace context
307
- * @param {TraceSummary} summary - Final summary data (result, testsPass, lintPass)
308
- * @returns {{ filePath: string, duration: number, totalTokens: number, executionSummary: string }}
309
- */
310
- export function finalizeTrace(traceCtx, summary) {
311
- const finished = new Date();
312
- const durationMs = finished.getTime() - traceCtx.started.getTime();
313
- const content = renderTrace(traceCtx, summary, finished);
314
-
315
- // Ensure directory exists (may have been cleaned between create and finalize)
316
- const dir = dirname(traceCtx.filePath);
317
- mkdirSync(dir, { recursive: true });
318
-
319
- writeFileSync(traceCtx.filePath, content, 'utf8');
320
-
321
- const executionSummary = generateExecutionSummary(traceCtx, summary, finished);
322
-
323
- return {
324
- filePath: traceCtx.filePath,
325
- duration: durationMs,
326
- totalTokens: traceCtx.totalTokens,
327
- executionSummary,
328
- };
329
- }
330
-
331
- /**
332
- * Lists all trace files in the traces directory, sorted newest first.
333
- * Parses the header of each file to extract metadata.
334
- * Returns empty array if directory does not exist (no throw).
335
- * @param {string} [tracesDir] - Directory to scan (default: .claude/guild/traces/)
336
- * @returns {Array<{ filePath: string, workflow: string, date: string, level: string, result: string }>}
337
- */
338
- export function listTraces(tracesDir) {
339
- const dir = tracesDir || join('.claude', 'guild', 'traces');
340
- if (!existsSync(dir)) return [];
341
-
342
- const files = readdirSync(dir)
343
- .filter(f => f.startsWith('guild-trace-') && f.endsWith('.md'))
344
- .sort()
345
- .reverse();
346
-
347
- return files.map(filename => {
348
- const filePath = join(dir, filename);
349
- const head = readFileSync(filePath, 'utf8').split('\n').slice(0, 10).join('\n');
350
-
351
- const workflowMatch = head.match(/^# Guild Trace — (.+)$/m);
352
- const levelMatch = head.match(/^> Level: (.+)$/m);
353
- const resultMatch = head.match(/^> Result: (.+)$/m);
354
- const startedMatch = head.match(/^> Started: (.+)$/m);
355
-
356
- return {
357
- filePath,
358
- workflow: workflowMatch ? workflowMatch[1] : 'unknown',
359
- date: startedMatch ? startedMatch[1] : 'unknown',
360
- level: levelMatch ? levelMatch[1] : 'default',
361
- result: resultMatch ? resultMatch[1] : 'unknown',
362
- };
363
- });
364
- }
365
-
366
- /**
367
- * Deletes trace files older than maxAgeDays. If maxAgeDays is 0, deletes ALL traces.
368
- * Returns count of deleted files. Returns 0 if directory does not exist (no throw).
369
- * @param {number} maxAgeDays - Traces older than this many days are deleted (0 = delete all)
370
- * @param {string} [tracesDir] - Directory to clean (default: .claude/guild/traces/)
371
- * @returns {number} Count of deleted files
372
- */
373
- export function cleanTraces(maxAgeDays, tracesDir) {
374
- const dir = tracesDir || join('.claude', 'guild', 'traces');
375
- if (!existsSync(dir)) return 0;
376
-
377
- const files = readdirSync(dir)
378
- .filter(f => f.startsWith('guild-trace-') && f.endsWith('.md'));
379
-
380
- const now = Date.now();
381
- const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
382
- let deleted = 0;
383
-
384
- for (const filename of files) {
385
- const filePath = join(dir, filename);
386
- if (maxAgeDays === 0) {
387
- unlinkSync(filePath);
388
- deleted++;
389
- } else {
390
- const stat = statSync(filePath);
391
- const ageMs = now - stat.mtimeMs;
392
- if (ageMs > maxAgeMs) {
393
- unlinkSync(filePath);
394
- deleted++;
395
- }
396
- }
397
- }
398
-
399
- return deleted;
400
- }
@@ -1,225 +0,0 @@
1
- /**
2
- * workflow-parser.js — Parser and validator for Guild declarative workflows.
3
- *
4
- * Parses SKILL.md files with nested YAML frontmatter (workflow definitions)
5
- * using a full YAML parser. Validates workflow structure against the schema.
6
- * Backward compatible: skills without a `workflow` key return workflow: null.
7
- */
8
-
9
- import YAML from 'yaml';
10
- import { MODEL_TIERS, FAILURE_STRATEGIES, DEFAULT_FAILURE_STRATEGY } from './dispatch-protocol.js';
11
-
12
- /**
13
- * Extracts the raw YAML frontmatter string and body from markdown content.
14
- * @param {string} content - Raw markdown content
15
- * @returns {{ yaml: string, body: string } | null} Null if no frontmatter found
16
- */
17
- export function extractFrontmatterBlock(content) {
18
- const match = content.match(/^---\n([\s\S]*?)\n---/);
19
- if (!match) return null;
20
- return {
21
- yaml: match[1],
22
- body: content.slice(match[0].length).trim(),
23
- };
24
- }
25
-
26
- /**
27
- * Parses YAML frontmatter with full nested structure support.
28
- * Uses the `yaml` npm package for spec-compliant parsing.
29
- * @param {string} yamlString - Raw YAML frontmatter
30
- * @returns {object} Parsed frontmatter object
31
- */
32
- export function parseYamlFrontmatter(yamlString) {
33
- return YAML.parse(yamlString) || {};
34
- }
35
-
36
- /**
37
- * Normalizes a raw workflow step from YAML into a consistent object shape.
38
- * Converts kebab-case keys to camelCase where appropriate.
39
- * @param {object} raw - Raw step object from YAML
40
- * @returns {object} Normalized step
41
- */
42
- function normalizeStep(raw) {
43
- return {
44
- id: raw.id,
45
- role: raw.role,
46
- intent: raw.intent,
47
- requires: raw.requires || [],
48
- produces: raw.produces || [],
49
- modelTier: raw['model-tier'] || undefined,
50
- blocking: raw.blocking !== undefined ? raw.blocking : true,
51
- onFailure: raw['on-failure'] || DEFAULT_FAILURE_STRATEGY,
52
- gate: raw.gate || false,
53
- retry: raw.retry || undefined,
54
- condition: raw.condition || undefined,
55
- parallel: raw.parallel || undefined,
56
- commands: raw.commands || undefined,
57
- delegatesTo: raw['delegates-to'] || undefined,
58
- };
59
- }
60
-
61
- /**
62
- * Parses a SKILL.md file and extracts the workflow definition.
63
- * If no `workflow` key exists in frontmatter, returns { workflow: null }
64
- * to indicate the Skill uses prose-based execution (backward compatible).
65
- *
66
- * @param {string} content - Raw content of SKILL.md
67
- * @returns {{ name: string, description: string, userInvocable: boolean, workflow: object|null, body: string }}
68
- * @throws {Error} If YAML frontmatter is malformed
69
- */
70
- export function parseSkill(content) {
71
- const block = extractFrontmatterBlock(content);
72
- if (!block) {
73
- return {
74
- name: '',
75
- description: '',
76
- userInvocable: false,
77
- workflow: null,
78
- body: content,
79
- };
80
- }
81
-
82
- const frontmatter = parseYamlFrontmatter(block.yaml);
83
-
84
- const skill = {
85
- name: frontmatter.name || '',
86
- description: frontmatter.description || '',
87
- userInvocable: frontmatter['user-invocable'] === true,
88
- workflow: null,
89
- body: block.body,
90
- };
91
-
92
- if (frontmatter.workflow) {
93
- const raw = frontmatter.workflow;
94
- skill.workflow = {
95
- version: raw.version,
96
- steps: Array.isArray(raw.steps)
97
- ? raw.steps.map(normalizeStep)
98
- : [],
99
- };
100
- }
101
-
102
- return skill;
103
- }
104
-
105
- /**
106
- * Validates a workflow definition against the schema.
107
- * Returns an array of validation errors (empty if valid).
108
- *
109
- * @param {object} workflow - Parsed workflow object with version and steps
110
- * @returns {string[]} Array of error messages
111
- */
112
- export function validateWorkflow(workflow) {
113
- const errors = [];
114
-
115
- if (workflow.version !== 1) {
116
- errors.push(`Unsupported workflow version: ${workflow.version}. This Guild version supports workflow version 1.`);
117
- }
118
-
119
- if (!Array.isArray(workflow.steps) || workflow.steps.length === 0) {
120
- errors.push('Workflow must have at least one step.');
121
- return errors;
122
- }
123
-
124
- const ids = new Set();
125
-
126
- for (const step of workflow.steps) {
127
- // Required fields
128
- if (!step.id) {
129
- errors.push('Step missing required field: id');
130
- continue;
131
- }
132
-
133
- if (ids.has(step.id)) {
134
- errors.push(`Duplicate step id: "${step.id}"`);
135
- }
136
- ids.add(step.id);
137
-
138
- if (!step.role) {
139
- errors.push(`Step "${step.id}" missing required field: role`);
140
- }
141
-
142
- if (!step.intent) {
143
- errors.push(`Step "${step.id}" missing required field: intent`);
144
- }
145
-
146
- // Valid model-tier
147
- if (step.modelTier && !MODEL_TIERS.includes(step.modelTier)) {
148
- errors.push(`Step "${step.id}" has invalid model-tier: "${step.modelTier}"`);
149
- }
150
-
151
- // Valid on-failure
152
- if (step.onFailure) {
153
- const isGoto = step.onFailure.startsWith('goto:');
154
- if (!FAILURE_STRATEGIES.includes(step.onFailure) && !isGoto) {
155
- errors.push(`Step "${step.id}" has invalid on-failure: "${step.onFailure}"`);
156
- }
157
- }
158
-
159
- // Retry limits
160
- if (step.retry) {
161
- if (step.retry.max !== undefined && step.retry.max > 10) {
162
- errors.push(`Step "${step.id}" retry.max exceeds limit of 10`);
163
- }
164
- if (step.retry.max !== undefined && step.retry.max < 1) {
165
- errors.push(`Step "${step.id}" retry.max must be at least 1`);
166
- }
167
- }
168
-
169
- // System step validation
170
- if (step.role === 'system') {
171
- if (!step.commands && !step.delegatesTo && !step.gate) {
172
- errors.push(`System step "${step.id}" must have commands, delegates-to, or gate`);
173
- }
174
- }
175
- }
176
-
177
- // Validate goto targets exist
178
- for (const step of workflow.steps) {
179
- if (step.onFailure && step.onFailure.startsWith('goto:')) {
180
- const target = step.onFailure.split(':')[1];
181
- if (!ids.has(target)) {
182
- errors.push(`Step "${step.id}" on-failure goto target "${target}" does not exist`);
183
- }
184
- }
185
- }
186
-
187
- return errors;
188
- }
189
-
190
- /**
191
- * Resolves the execution order of steps considering conditions,
192
- * dependencies (requires/produces), and parallel groups.
193
- * Returns a flat execution plan with groupings.
194
- *
195
- * @param {object} workflow - Validated workflow
196
- * @returns {{ groups: Array<{ steps: object[], parallel: boolean }> }} Ordered execution plan
197
- */
198
- export function resolveExecutionPlan(workflow) {
199
- const groups = [];
200
- let i = 0;
201
- const steps = workflow.steps;
202
-
203
- while (i < steps.length) {
204
- const step = steps[i];
205
-
206
- // Check if this step has parallel peers
207
- if (step.parallel && step.parallel.length > 0) {
208
- const parallelIds = new Set([step.id, ...step.parallel]);
209
- const parallelSteps = [];
210
-
211
- // Collect all steps in this parallel group
212
- while (i < steps.length && parallelIds.has(steps[i].id)) {
213
- parallelSteps.push(steps[i]);
214
- i++;
215
- }
216
-
217
- groups.push({ steps: parallelSteps, parallel: true });
218
- } else {
219
- groups.push({ steps: [step], parallel: false });
220
- i++;
221
- }
222
- }
223
-
224
- return { groups };
225
- }