guild-agents 0.3.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +5 -1
  2. package/bin/guild.js +75 -1
  3. package/package.json +12 -5
  4. package/src/commands/doctor.js +70 -1
  5. package/src/commands/logs.js +63 -0
  6. package/src/commands/reset-learnings.js +44 -0
  7. package/src/commands/run.js +105 -0
  8. package/src/templates/agents/advisor.md +1 -0
  9. package/src/templates/agents/bugfix.md +1 -0
  10. package/src/templates/agents/code-reviewer.md +1 -0
  11. package/src/templates/agents/db-migration.md +1 -0
  12. package/src/templates/agents/developer.md +1 -0
  13. package/src/templates/agents/learnings-extractor.md +49 -0
  14. package/src/templates/agents/platform-expert.md +1 -0
  15. package/src/templates/agents/product-owner.md +1 -0
  16. package/src/templates/agents/qa.md +1 -0
  17. package/src/templates/agents/tech-lead.md +1 -0
  18. package/src/templates/skills/build-feature/SKILL.md +130 -26
  19. package/src/templates/skills/council/SKILL.md +51 -4
  20. package/src/templates/skills/create-pr/SKILL.md +32 -0
  21. package/src/templates/skills/dev-flow/SKILL.md +14 -0
  22. package/src/templates/skills/guild-specialize/SKILL.md +45 -3
  23. package/src/templates/skills/new-feature/SKILL.md +33 -0
  24. package/src/templates/skills/qa-cycle/SKILL.md +48 -5
  25. package/src/templates/skills/review/SKILL.md +22 -1
  26. package/src/templates/skills/session-end/SKILL.md +27 -0
  27. package/src/templates/skills/session-start/SKILL.md +32 -0
  28. package/src/templates/skills/status/SKILL.md +19 -0
  29. package/src/utils/dispatch-protocol.js +74 -0
  30. package/src/utils/dispatch.js +172 -0
  31. package/src/utils/executor.js +183 -0
  32. package/src/utils/learnings-io.js +76 -0
  33. package/src/utils/learnings.js +204 -0
  34. package/src/utils/orchestrator-io.js +356 -0
  35. package/src/utils/orchestrator.js +590 -0
  36. package/src/utils/providers/claude-code.js +43 -0
  37. package/src/utils/skill-loader.js +83 -0
  38. package/src/utils/trace.js +400 -0
  39. package/src/utils/version.js +90 -0
  40. package/src/utils/workflow-parser.js +225 -0
@@ -0,0 +1,590 @@
1
+ /**
2
+ * orchestrator.js — Pure logic for the Guild runtime orchestrator.
3
+ *
4
+ * Contains all state-machine logic for managing workflow execution plans:
5
+ * step state transitions, condition evaluation, retry logic, failure targets,
6
+ * and delegation expansion. Zero I/O — pure functions only.
7
+ */
8
+
9
+ import { resolveExecutionPlan } from './workflow-parser.js';
10
+ import { DEFAULT_FAILURE_STRATEGY } from './dispatch-protocol.js';
11
+
12
+ /**
13
+ * Maximum total steps that can be executed before the circuit breaker trips.
14
+ * Prevents infinite loops in runaway workflows.
15
+ * @type {number}
16
+ */
17
+ export const DEFAULT_CIRCUIT_BREAKER_LIMIT = 50;
18
+
19
+ /**
20
+ * Maximum depth for delegation expansion (sub-workflow embedding).
21
+ * Prevents infinite delegation chains.
22
+ * @type {number}
23
+ */
24
+ export const MAX_DELEGATION_DEPTH = 2;
25
+
26
+ /**
27
+ * @typedef {Object} StepState
28
+ * @property {string} id - Step identifier (matches the key in stepStates)
29
+ * @property {'pending'|'running'|'passed'|'failed'|'skipped'} status - Current state
30
+ * @property {number} attempts - Number of execution attempts so far
31
+ * @property {object|null} outcome - Produced values from step execution
32
+ * @property {string|null} error - Error message if step failed
33
+ * @property {number|null} startedAt - Timestamp (ms) when step started (populated by executor, null in plan-only mode)
34
+ * @property {number|null} finishedAt - Timestamp (ms) when step finished (populated by executor, null in plan-only mode)
35
+ */
36
+
37
+ /**
38
+ * @typedef {Object} ExecutionGroup
39
+ * @property {object[]} steps - Steps in this group
40
+ * @property {boolean} parallel - Whether steps run in parallel
41
+ */
42
+
43
+ /**
44
+ * @typedef {Object} ExecutionPlan
45
+ * @property {string} skillName - Name of the skill being executed
46
+ * @property {object} workflow - Parsed+validated workflow object
47
+ * @property {ExecutionGroup[]} groups - Step groups from resolveExecutionPlan
48
+ * @property {Object.<string, StepState>} stepStates - Keyed by step ID
49
+ * @property {number} currentGroupIndex - Index into groups array
50
+ * @property {number} totalStepsExecuted - Running total for circuit breaker
51
+ * @property {number} circuitBreakerLimit - Max steps before abort
52
+ * @property {'running'|'completed'|'aborted'|'circuit-breaker'} status - Plan status
53
+ * @property {number} totalSteps - Total number of steps in the plan
54
+ * @property {string|null} jumpToStepId - If set, getNextSteps returns only this step
55
+ */
56
+
57
+ /**
58
+ * @typedef {Object} StepResult
59
+ * @property {'passed'|'failed'|'skipped'} status - Result of step execution
60
+ * @property {object|null} outcome - Produced values (keyed by produces fields)
61
+ * @property {string|null} error - Error message on failure
62
+ */
63
+
64
+ /**
65
+ * Creates a new execution plan from a parsed+validated workflow.
66
+ *
67
+ * @param {object} workflow - Parsed workflow from parseSkill().workflow
68
+ * @param {object} [options={}] - Options
69
+ * @param {string} [options.skillName=''] - Name of the skill
70
+ * @param {number} [options.circuitBreakerLimit=DEFAULT_CIRCUIT_BREAKER_LIMIT] - Max total steps
71
+ * @returns {ExecutionPlan}
72
+ */
73
+ export function createExecutionPlan(workflow, options = {}) {
74
+ const { skillName = '', circuitBreakerLimit = DEFAULT_CIRCUIT_BREAKER_LIMIT } = options;
75
+
76
+ const { groups } = resolveExecutionPlan(workflow);
77
+
78
+ const stepStates = {};
79
+ for (const group of groups) {
80
+ for (const step of group.steps) {
81
+ stepStates[step.id] = {
82
+ id: step.id,
83
+ status: 'pending',
84
+ attempts: 0,
85
+ outcome: null,
86
+ error: null,
87
+ startedAt: null,
88
+ finishedAt: null,
89
+ };
90
+ }
91
+ }
92
+
93
+ return {
94
+ skillName,
95
+ workflow,
96
+ groups,
97
+ stepStates,
98
+ currentGroupIndex: 0,
99
+ totalStepsExecuted: 0,
100
+ circuitBreakerLimit,
101
+ status: 'running',
102
+ totalSteps: Object.keys(stepStates).length,
103
+ jumpToStepId: null,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Advances the plan with the result of a step execution.
109
+ * IMMUTABLE: returns a new plan object without mutating input.
110
+ *
111
+ * @param {ExecutionPlan} plan - Current plan
112
+ * @param {string} stepId - ID of the step that completed
113
+ * @param {StepResult} result - Execution result
114
+ * @returns {ExecutionPlan} New plan with updated state
115
+ * @throws {Error} If stepId doesn't exist in plan
116
+ * @throws {Error} If circuit breaker limit is exceeded
117
+ */
118
+ export function advanceStep(plan, stepId, result) {
119
+ if (!(stepId in plan.stepStates)) {
120
+ throw new Error(`Step "${stepId}" does not exist in plan for skill "${plan.skillName}"`);
121
+ }
122
+
123
+ const currentState = plan.stepStates[stepId];
124
+ const newTotalStepsExecuted = plan.totalStepsExecuted + 1;
125
+
126
+ // B2: if the step being advanced is the current jump target, clear the jump
127
+ const clearJump = plan.jumpToStepId === stepId;
128
+
129
+ if (newTotalStepsExecuted > plan.circuitBreakerLimit) {
130
+ return {
131
+ ...plan,
132
+ totalStepsExecuted: newTotalStepsExecuted,
133
+ status: 'circuit-breaker',
134
+ jumpToStepId: clearJump ? null : plan.jumpToStepId,
135
+ stepStates: {
136
+ ...plan.stepStates,
137
+ [stepId]: {
138
+ ...currentState,
139
+ status: result.status,
140
+ outcome: result.outcome || null,
141
+ error: result.error || null,
142
+ },
143
+ },
144
+ };
145
+ }
146
+
147
+ // Find the step definition
148
+ const step = findStepById(plan, stepId);
149
+
150
+ // If failed: check retry first, then failure target
151
+ if (result.status === 'failed') {
152
+ if (shouldRetry(step, currentState)) {
153
+ // Retry: increment attempts, reset to pending
154
+ return {
155
+ ...plan,
156
+ totalStepsExecuted: newTotalStepsExecuted,
157
+ jumpToStepId: clearJump ? null : plan.jumpToStepId,
158
+ stepStates: {
159
+ ...plan.stepStates,
160
+ [stepId]: {
161
+ ...currentState,
162
+ status: 'pending',
163
+ attempts: currentState.attempts + 1,
164
+ outcome: null,
165
+ error: null,
166
+ },
167
+ },
168
+ };
169
+ }
170
+
171
+ // No retry — apply failure target
172
+ const { action, targetId } = resolveFailureTarget(step, plan);
173
+
174
+ const updatedStepState = {
175
+ ...currentState,
176
+ status: 'failed',
177
+ outcome: result.outcome || null,
178
+ error: result.error || null,
179
+ };
180
+
181
+ if (action === 'abort') {
182
+ return {
183
+ ...plan,
184
+ totalStepsExecuted: newTotalStepsExecuted,
185
+ status: 'aborted',
186
+ jumpToStepId: clearJump ? null : plan.jumpToStepId,
187
+ stepStates: {
188
+ ...plan.stepStates,
189
+ [stepId]: updatedStepState,
190
+ },
191
+ };
192
+ }
193
+
194
+ if (action === 'goto') {
195
+ // B1: recalculate currentGroupIndex with updated step states
196
+ const newStepStates = { ...plan.stepStates, [stepId]: updatedStepState };
197
+ return {
198
+ ...plan,
199
+ totalStepsExecuted: newTotalStepsExecuted,
200
+ currentGroupIndex: calcCurrentGroupIndex(plan.groups, newStepStates, plan.currentGroupIndex),
201
+ jumpToStepId: targetId,
202
+ stepStates: newStepStates,
203
+ };
204
+ }
205
+
206
+ // 'continue' — just mark failed and keep running
207
+ const continueStepStates = { ...plan.stepStates, [stepId]: updatedStepState };
208
+ return {
209
+ ...plan,
210
+ totalStepsExecuted: newTotalStepsExecuted,
211
+ currentGroupIndex: calcCurrentGroupIndex(plan.groups, continueStepStates, plan.currentGroupIndex),
212
+ jumpToStepId: clearJump ? null : plan.jumpToStepId,
213
+ stepStates: continueStepStates,
214
+ };
215
+ }
216
+
217
+ // Passed or skipped
218
+ const newStepStates = {
219
+ ...plan.stepStates,
220
+ [stepId]: {
221
+ ...currentState,
222
+ status: result.status,
223
+ attempts: currentState.attempts + (result.status !== 'skipped' ? 1 : 0),
224
+ outcome: result.outcome || null,
225
+ error: result.error || null,
226
+ },
227
+ };
228
+
229
+ return {
230
+ ...plan,
231
+ totalStepsExecuted: newTotalStepsExecuted,
232
+ currentGroupIndex: calcCurrentGroupIndex(plan.groups, newStepStates, plan.currentGroupIndex),
233
+ jumpToStepId: clearJump ? null : plan.jumpToStepId,
234
+ stepStates: newStepStates,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Returns the next steps to execute from the plan, plus any steps whose conditions
240
+ * evaluated to false and should be marked as skipped by the caller.
241
+ *
242
+ * The caller is responsible for calling advanceStep(plan, id, { status: 'skipped' })
243
+ * for each ID in the returned `skipped` array so that isPlanComplete can work correctly.
244
+ *
245
+ * @param {ExecutionPlan} plan - Current plan
246
+ * @returns {{ steps: object[], skipped: string[] }}
247
+ * steps — step definitions ready to execute (may be empty)
248
+ * skipped — IDs of steps whose conditions were not met (caller must advance as skipped)
249
+ */
250
+ export function getNextSteps(plan) {
251
+ if (plan.status !== 'running') {
252
+ return { steps: [], skipped: [] };
253
+ }
254
+
255
+ // Handle jump (goto target from failure)
256
+ if (plan.jumpToStepId) {
257
+ const targetStep = findStepById(plan, plan.jumpToStepId);
258
+ if (!targetStep) {
259
+ return { steps: [], skipped: [] };
260
+ }
261
+ return { steps: [targetStep], skipped: [] };
262
+ }
263
+
264
+ // Find current group
265
+ if (plan.currentGroupIndex >= plan.groups.length) {
266
+ return { steps: [], skipped: [] };
267
+ }
268
+
269
+ let groupIndex = plan.currentGroupIndex;
270
+
271
+ // Scan forward through groups to find one with pending steps
272
+ while (groupIndex < plan.groups.length) {
273
+ const group = plan.groups[groupIndex];
274
+ const { pending, skipped } = getPendingStepsInGroup(group, plan);
275
+
276
+ const allDone = group.steps.every(step => {
277
+ const state = plan.stepStates[step.id];
278
+ return isTerminalStatus(state.status);
279
+ });
280
+
281
+ if (allDone) {
282
+ groupIndex++;
283
+ continue;
284
+ }
285
+
286
+ if (pending.length > 0 || skipped.length > 0) {
287
+ return { steps: pending, skipped };
288
+ }
289
+
290
+ // Steps in group are not all done, but no pending/skipped — they are in-flight
291
+ break;
292
+ }
293
+
294
+ return { steps: [], skipped: [] };
295
+ }
296
+
297
+ /**
298
+ * Returns true if the plan is complete (all steps terminal, or plan aborted/circuit-breaker).
299
+ *
300
+ * @param {ExecutionPlan} plan - Current plan
301
+ * @returns {boolean}
302
+ */
303
+ export function isPlanComplete(plan) {
304
+ if (plan.status === 'completed' || plan.status === 'aborted' || plan.status === 'circuit-breaker') {
305
+ return true;
306
+ }
307
+
308
+ // Check if all steps are in terminal state
309
+ for (const state of Object.values(plan.stepStates)) {
310
+ if (!isTerminalStatus(state.status)) {
311
+ return false;
312
+ }
313
+ }
314
+
315
+ return true;
316
+ }
317
+
318
+ /**
319
+ * Parses a condition string into a structured object.
320
+ * Supports three formats:
321
+ * - `step.<id>.<field>` — truthy check
322
+ * - `step.<id>.<field> == <value>` — equality check
323
+ * - `step.<id>.<field> != <value>` — inequality check
324
+ *
325
+ * @param {string} conditionString - Raw condition string
326
+ * @returns {{ stepId: string, field: string, operator: string|null, value: string|null } | null}
327
+ */
328
+ export function parseCondition(conditionString) {
329
+ if (!conditionString || typeof conditionString !== 'string') {
330
+ return null;
331
+ }
332
+
333
+ const re = /^step\.([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)(?:\s+(==|!=)\s+(.+))?$/;
334
+ const match = conditionString.trim().match(re);
335
+
336
+ if (!match) {
337
+ return null;
338
+ }
339
+
340
+ return {
341
+ stepId: match[1],
342
+ field: match[2],
343
+ operator: match[3] || null,
344
+ value: match[4] !== undefined ? match[4] : null,
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Evaluates a parsed condition against step states.
350
+ *
351
+ * @param {{ stepId: string, field: string, operator: string|null, value: string|null }} parsed - Parsed condition
352
+ * @param {Object.<string, StepState>} stepStates - Current step states
353
+ * @returns {boolean}
354
+ */
355
+ export function evaluateCondition(parsed, stepStates) {
356
+ if (!parsed) return false;
357
+
358
+ const state = stepStates[parsed.stepId];
359
+ if (!state || !state.outcome) {
360
+ return false;
361
+ }
362
+
363
+ const fieldValue = state.outcome[parsed.field];
364
+
365
+ if (parsed.operator === null) {
366
+ // Truthy check
367
+ return !!fieldValue;
368
+ }
369
+
370
+ if (parsed.operator === '==') {
371
+ return String(fieldValue) === String(parsed.value);
372
+ }
373
+
374
+ if (parsed.operator === '!=') {
375
+ return String(fieldValue) !== String(parsed.value);
376
+ }
377
+
378
+ return false;
379
+ }
380
+
381
+ /**
382
+ * Determines whether a failed step should be retried.
383
+ *
384
+ * @param {object} step - Step definition with optional retry config
385
+ * @param {StepState} stepState - Current state of the step
386
+ * @returns {boolean}
387
+ */
388
+ export function shouldRetry(step, stepState) {
389
+ if (!step.retry) {
390
+ return false;
391
+ }
392
+
393
+ const maxAttempts = step.retry.max;
394
+ if (maxAttempts === undefined || maxAttempts === null) {
395
+ return false;
396
+ }
397
+
398
+ return stepState.attempts < maxAttempts;
399
+ }
400
+
401
+ /**
402
+ * Resolves the failure target for a failed step.
403
+ * Parses on-failure config and returns a normalized action.
404
+ *
405
+ * @param {object} step - Step definition with optional onFailure config
406
+ * @param {ExecutionPlan} plan - Current plan (for goto target validation)
407
+ * @returns {{ action: 'abort'|'continue'|'goto', targetId: string|null }}
408
+ * @throws {Error} If goto target doesn't exist in plan
409
+ */
410
+ export function resolveFailureTarget(step, plan) {
411
+ const onFailure = step.onFailure || DEFAULT_FAILURE_STRATEGY;
412
+
413
+ if (onFailure === 'abort') {
414
+ return { action: 'abort', targetId: null };
415
+ }
416
+
417
+ if (onFailure === 'continue') {
418
+ return { action: 'continue', targetId: null };
419
+ }
420
+
421
+ if (onFailure.startsWith('goto:')) {
422
+ const targetId = onFailure.slice('goto:'.length);
423
+ if (!(targetId in plan.stepStates)) {
424
+ throw new Error(
425
+ `Step "${step.id}" on-failure goto target "${targetId}" does not exist in plan for skill "${plan.skillName}"`
426
+ );
427
+ }
428
+ return { action: 'goto', targetId };
429
+ }
430
+
431
+ // Unrecognized — default to abort
432
+ return { action: 'abort', targetId: null };
433
+ }
434
+
435
+ /**
436
+ * Expands a delegation step by prefixing all sub-step IDs and updating conditions.
437
+ * Used when a step delegates-to a sub-workflow.
438
+ *
439
+ * @param {object} step - Delegation step (with delegatesTo field)
440
+ * @param {object} subWorkflow - Parsed sub-workflow to embed
441
+ * @param {number} currentDepth - Current recursion depth (starts at 0)
442
+ * @param {object} [options={}] - Options
443
+ * @param {number} [options.maxDepth=MAX_DELEGATION_DEPTH] - Max allowed depth
444
+ * @returns {{ prefixedSteps: object[], subGroups: ExecutionGroup[] }}
445
+ * @throws {Error} If currentDepth >= maxDepth
446
+ */
447
+ export function expandDelegation(step, subWorkflow, currentDepth, options = {}) {
448
+ const maxDepth = options.maxDepth !== undefined ? options.maxDepth : MAX_DELEGATION_DEPTH;
449
+
450
+ if (currentDepth >= maxDepth) {
451
+ throw new Error(
452
+ `Delegation depth limit (${maxDepth}) exceeded when expanding step "${step.id}" delegating to "${step.delegatesTo}"`
453
+ );
454
+ }
455
+
456
+ const prefix = `${step.id}.`;
457
+
458
+ // Build a map from old ID to new prefixed ID
459
+ const idMap = {};
460
+ for (const subStep of subWorkflow.steps) {
461
+ idMap[subStep.id] = `${prefix}${subStep.id}`;
462
+ }
463
+
464
+ // Prefix step IDs and update any internal condition references
465
+ const prefixedSteps = subWorkflow.steps.map(subStep => {
466
+ const prefixedId = idMap[subStep.id];
467
+
468
+ let condition = subStep.condition;
469
+ if (condition) {
470
+ // Update step.<id>. references to use prefixed IDs
471
+ condition = condition.replace(
472
+ /\bstep\.([a-zA-Z0-9_-]+)\./g,
473
+ (_match, refId) => {
474
+ const prefixed = idMap[refId];
475
+ return prefixed ? `step.${prefixed}.` : _match;
476
+ }
477
+ );
478
+ }
479
+
480
+ let onFailure = subStep.onFailure;
481
+ if (onFailure && onFailure.startsWith('goto:')) {
482
+ const gotoTarget = onFailure.slice('goto:'.length);
483
+ if (idMap[gotoTarget]) {
484
+ onFailure = `goto:${idMap[gotoTarget]}`;
485
+ }
486
+ }
487
+
488
+ return {
489
+ ...subStep,
490
+ id: prefixedId,
491
+ condition,
492
+ onFailure,
493
+ };
494
+ });
495
+
496
+ const { groups: subGroups } = resolveExecutionPlan({ ...subWorkflow, steps: prefixedSteps });
497
+
498
+ return { prefixedSteps, subGroups };
499
+ }
500
+
501
+ // --- Private helpers ---
502
+
503
+ /**
504
+ * Finds a step definition by ID across all groups in a plan.
505
+ * @param {ExecutionPlan} plan
506
+ * @param {string} stepId
507
+ * @returns {object|null}
508
+ */
509
+ function findStepById(plan, stepId) {
510
+ for (const group of plan.groups) {
511
+ for (const step of group.steps) {
512
+ if (step.id === stepId) {
513
+ return step;
514
+ }
515
+ }
516
+ }
517
+ return null;
518
+ }
519
+
520
+ /**
521
+ * Returns true if the status is a terminal state (no further transitions expected).
522
+ * @param {string} status
523
+ * @returns {boolean}
524
+ */
525
+ function isTerminalStatus(status) {
526
+ return status === 'passed' || status === 'failed' || status === 'skipped';
527
+ }
528
+
529
+ /**
530
+ * Calculates the new currentGroupIndex after a step state change.
531
+ * Advances past any groups where all steps are already in a terminal state.
532
+ *
533
+ * @param {ExecutionGroup[]} groups - All groups in the plan
534
+ * @param {Object.<string, StepState>} stepStates - Updated step states
535
+ * @param {number} currentGroupIndex - Current group index (never go backwards)
536
+ * @returns {number} Updated group index
537
+ */
538
+ function calcCurrentGroupIndex(groups, stepStates, currentGroupIndex) {
539
+ let idx = currentGroupIndex;
540
+ while (idx < groups.length) {
541
+ const group = groups[idx];
542
+ const allDone = group.steps.every(s => {
543
+ const st = stepStates[s.id];
544
+ return st && isTerminalStatus(st.status);
545
+ });
546
+ if (!allDone) break;
547
+ idx++;
548
+ }
549
+ return idx;
550
+ }
551
+
552
+ /**
553
+ * Returns pending steps from a group and IDs of steps whose conditions were not met.
554
+ * Steps with unmet conditions are returned as skipped IDs so the caller can advance
555
+ * them as 'skipped', allowing isPlanComplete to work correctly.
556
+ *
557
+ * @param {ExecutionGroup} group
558
+ * @param {ExecutionPlan} plan
559
+ * @returns {{ pending: object[], skipped: string[] }}
560
+ */
561
+ function getPendingStepsInGroup(group, plan) {
562
+ const pending = [];
563
+ const skipped = [];
564
+
565
+ for (const step of group.steps) {
566
+ const state = plan.stepStates[step.id];
567
+
568
+ if (state.status !== 'pending') {
569
+ continue;
570
+ }
571
+
572
+ // Evaluate condition
573
+ if (step.condition) {
574
+ const parsed = parseCondition(step.condition);
575
+ if (parsed) {
576
+ const conditionMet = evaluateCondition(parsed, plan.stepStates);
577
+ if (!conditionMet) {
578
+ // Condition not met — report as skipped so caller can advance it
579
+ skipped.push(step.id);
580
+ continue;
581
+ }
582
+ }
583
+ // If condition is not in step.X.Y format (e.g., a simple flag), treat as truthy (v1)
584
+ }
585
+
586
+ pending.push(step);
587
+ }
588
+
589
+ return { pending, skipped };
590
+ }
@@ -0,0 +1,43 @@
1
+ import { execFile } from 'child_process';
2
+
3
+ const DEFAULT_TIMEOUT = 300000; // 5 minutes
4
+
5
+ function execFileAsync(cmd, args, opts) {
6
+ return new Promise((resolve, reject) => {
7
+ execFile(cmd, args, opts, (error, stdout, stderr) => {
8
+ if (error && error.code === 'ENOENT') {
9
+ reject(new Error(
10
+ 'Claude Code CLI not found. Install it with: npm install -g @anthropic-ai/claude-code'
11
+ ));
12
+ return;
13
+ }
14
+ resolve({
15
+ stdout: stdout || (error && error.stdout) || '',
16
+ stderr: stderr || (error && error.stderr) || '',
17
+ exitCode: error ? (typeof error.code === 'number' ? error.code : 1) : 0,
18
+ });
19
+ });
20
+ });
21
+ }
22
+
23
+ export function createClaudeCodeProvider(config) {
24
+ const { projectRoot, stepTimeout = DEFAULT_TIMEOUT } = config;
25
+
26
+ return async function claudeCodeProvider(step, dispatch, context) {
27
+ const args = ['-p', context];
28
+ if (dispatch.model) {
29
+ args.push('--model', dispatch.model);
30
+ }
31
+
32
+ const result = await execFileAsync('claude', args, {
33
+ cwd: projectRoot,
34
+ timeout: stepTimeout,
35
+ maxBuffer: 10 * 1024 * 1024,
36
+ });
37
+
38
+ return {
39
+ status: result.exitCode === 0 ? 'passed' : 'failed',
40
+ output: result.exitCode === 0 ? result.stdout : (result.stderr || result.stdout),
41
+ };
42
+ };
43
+ }
@@ -0,0 +1,83 @@
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
+ }