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.
- package/README.md +5 -1
- package/bin/guild.js +75 -1
- package/package.json +12 -5
- package/src/commands/doctor.js +70 -1
- package/src/commands/logs.js +63 -0
- package/src/commands/reset-learnings.js +44 -0
- package/src/commands/run.js +105 -0
- package/src/templates/agents/advisor.md +1 -0
- package/src/templates/agents/bugfix.md +1 -0
- package/src/templates/agents/code-reviewer.md +1 -0
- package/src/templates/agents/db-migration.md +1 -0
- package/src/templates/agents/developer.md +1 -0
- package/src/templates/agents/learnings-extractor.md +49 -0
- package/src/templates/agents/platform-expert.md +1 -0
- package/src/templates/agents/product-owner.md +1 -0
- package/src/templates/agents/qa.md +1 -0
- package/src/templates/agents/tech-lead.md +1 -0
- package/src/templates/skills/build-feature/SKILL.md +130 -26
- package/src/templates/skills/council/SKILL.md +51 -4
- package/src/templates/skills/create-pr/SKILL.md +32 -0
- package/src/templates/skills/dev-flow/SKILL.md +14 -0
- package/src/templates/skills/guild-specialize/SKILL.md +45 -3
- package/src/templates/skills/new-feature/SKILL.md +33 -0
- package/src/templates/skills/qa-cycle/SKILL.md +48 -5
- package/src/templates/skills/review/SKILL.md +22 -1
- package/src/templates/skills/session-end/SKILL.md +27 -0
- package/src/templates/skills/session-start/SKILL.md +32 -0
- package/src/templates/skills/status/SKILL.md +19 -0
- package/src/utils/dispatch-protocol.js +74 -0
- package/src/utils/dispatch.js +172 -0
- package/src/utils/executor.js +183 -0
- package/src/utils/learnings-io.js +76 -0
- package/src/utils/learnings.js +204 -0
- package/src/utils/orchestrator-io.js +356 -0
- package/src/utils/orchestrator.js +590 -0
- package/src/utils/providers/claude-code.js +43 -0
- package/src/utils/skill-loader.js +83 -0
- package/src/utils/trace.js +400 -0
- package/src/utils/version.js +90 -0
- 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
|
+
}
|