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,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* learnings.js — Pure functions for compound learning.
|
|
3
|
+
*
|
|
4
|
+
* Constants, parsing, rendering, token estimation, and context injection.
|
|
5
|
+
* Zero I/O, zero external dependencies — leaf module.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Fixed section names for the learnings file (spec section 3.3). */
|
|
9
|
+
export const LEARNINGS_SECTIONS = [
|
|
10
|
+
'Project Patterns',
|
|
11
|
+
'Architecture Decisions',
|
|
12
|
+
'Past Issues & Resolutions',
|
|
13
|
+
'User Preferences',
|
|
14
|
+
'Agent Notes',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/** Maximum token budget for the learnings file. */
|
|
18
|
+
export const TOKEN_BUDGET = 2000;
|
|
19
|
+
|
|
20
|
+
/** Default path to the learnings file, relative to project root. */
|
|
21
|
+
export const GUILD_LEARNINGS_PATH = '.claude/guild/learnings.md';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Estimates token count from text using a word-count heuristic.
|
|
25
|
+
* Approximation: tokens ≈ ceil(words × 1.35).
|
|
26
|
+
* @param {string | null | undefined} text
|
|
27
|
+
* @returns {number}
|
|
28
|
+
*/
|
|
29
|
+
export function estimateTokens(text) {
|
|
30
|
+
if (!text || !text.trim()) return 0;
|
|
31
|
+
const words = text.trim().split(/\s+/).length;
|
|
32
|
+
return Math.ceil(words * 1.35);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns true if the estimated token count is within the budget.
|
|
37
|
+
* @param {string | null | undefined} text
|
|
38
|
+
* @param {number} [budget=TOKEN_BUDGET]
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
export function isWithinBudget(text, budget = TOKEN_BUDGET) {
|
|
42
|
+
return estimateTokens(text) <= budget;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Renders the initial/empty learnings file content.
|
|
47
|
+
* @param {string} [projectName='Project']
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export function renderEmptyLearnings(projectName = 'Project') {
|
|
51
|
+
const lines = [];
|
|
52
|
+
lines.push(`# Guild Learnings — ${projectName}`);
|
|
53
|
+
lines.push('');
|
|
54
|
+
lines.push(`> Auto-generated by Guild. Last updated: ${new Date().toISOString()}`);
|
|
55
|
+
lines.push('> Total executions: 0');
|
|
56
|
+
lines.push('> Token budget: this file should stay under 2000 tokens');
|
|
57
|
+
lines.push('');
|
|
58
|
+
|
|
59
|
+
for (const section of LEARNINGS_SECTIONS) {
|
|
60
|
+
lines.push(`## ${section}`);
|
|
61
|
+
lines.push('');
|
|
62
|
+
lines.push('_No learnings yet._');
|
|
63
|
+
lines.push('');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return lines.join('\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const PLACEHOLDER_RE = /^_No learnings yet\._$/;
|
|
70
|
+
const ENTRY_RE = /^- (.+)$/;
|
|
71
|
+
const DISCOVERED_RE = /\(discovered: ([^)]+)\)/;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parses a learnings.md file into a structured object.
|
|
75
|
+
* @param {string | null | undefined} content
|
|
76
|
+
* @returns {{ header: { project: string, lastUpdated: string, totalExecutions: number }, sections: Record<string, Array<{ text: string, discovered: string }>> }}
|
|
77
|
+
*/
|
|
78
|
+
export function parseLearnings(content) {
|
|
79
|
+
const empty = {
|
|
80
|
+
header: { project: '', lastUpdated: '', totalExecutions: 0 },
|
|
81
|
+
sections: {},
|
|
82
|
+
};
|
|
83
|
+
for (const s of LEARNINGS_SECTIONS) empty.sections[s] = [];
|
|
84
|
+
|
|
85
|
+
if (!content || !content.trim()) return empty;
|
|
86
|
+
|
|
87
|
+
const lines = content.split('\n');
|
|
88
|
+
const result = { header: { project: '', lastUpdated: '', totalExecutions: 0 }, sections: {} };
|
|
89
|
+
for (const s of LEARNINGS_SECTIONS) result.sections[s] = [];
|
|
90
|
+
|
|
91
|
+
let currentSection = null;
|
|
92
|
+
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
// Parse H1 header
|
|
95
|
+
const h1Match = line.match(/^# Guild Learnings — (.+)$/);
|
|
96
|
+
if (h1Match) {
|
|
97
|
+
result.header.project = h1Match[1].trim();
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Parse header metadata
|
|
102
|
+
const updatedMatch = line.match(/^> Auto-generated by Guild\. Last updated: (.+)$/);
|
|
103
|
+
if (updatedMatch) {
|
|
104
|
+
result.header.lastUpdated = updatedMatch[1].trim();
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const execMatch = line.match(/^> Total executions: (\d+)$/);
|
|
108
|
+
if (execMatch) {
|
|
109
|
+
result.header.totalExecutions = parseInt(execMatch[1], 10);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Parse section headers
|
|
114
|
+
const h2Match = line.match(/^## (.+)$/);
|
|
115
|
+
if (h2Match) {
|
|
116
|
+
const sectionName = h2Match[1].trim();
|
|
117
|
+
if (LEARNINGS_SECTIONS.includes(sectionName)) {
|
|
118
|
+
currentSection = sectionName;
|
|
119
|
+
} else {
|
|
120
|
+
currentSection = null;
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Skip placeholders and blank lines
|
|
126
|
+
if (!currentSection) continue;
|
|
127
|
+
if (PLACEHOLDER_RE.test(line.trim())) continue;
|
|
128
|
+
if (!line.trim()) continue;
|
|
129
|
+
|
|
130
|
+
// Parse entries (lines starting with "- ")
|
|
131
|
+
const entryMatch = line.match(ENTRY_RE);
|
|
132
|
+
if (entryMatch) {
|
|
133
|
+
const text = entryMatch[1].trim();
|
|
134
|
+
const discoveredMatch = text.match(DISCOVERED_RE);
|
|
135
|
+
result.sections[currentSection].push({
|
|
136
|
+
text,
|
|
137
|
+
discovered: discoveredMatch ? discoveredMatch[1] : '',
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Renders a parsed learnings structure back to markdown.
|
|
147
|
+
* @param {{ header: { project: string, lastUpdated: string, totalExecutions: number }, sections: Record<string, Array<{ text: string, discovered: string }>> }} parsed
|
|
148
|
+
* @returns {string}
|
|
149
|
+
*/
|
|
150
|
+
export function renderLearnings(parsed) {
|
|
151
|
+
const lines = [];
|
|
152
|
+
const project = parsed.header.project || 'Project';
|
|
153
|
+
const lastUpdated = parsed.header.lastUpdated || new Date().toISOString();
|
|
154
|
+
const totalExecutions = parsed.header.totalExecutions || 0;
|
|
155
|
+
|
|
156
|
+
lines.push(`# Guild Learnings — ${project}`);
|
|
157
|
+
lines.push('');
|
|
158
|
+
lines.push(`> Auto-generated by Guild. Last updated: ${lastUpdated}`);
|
|
159
|
+
lines.push(`> Total executions: ${totalExecutions}`);
|
|
160
|
+
lines.push('> Token budget: this file should stay under 2000 tokens');
|
|
161
|
+
lines.push('');
|
|
162
|
+
|
|
163
|
+
for (const section of LEARNINGS_SECTIONS) {
|
|
164
|
+
lines.push(`## ${section}`);
|
|
165
|
+
lines.push('');
|
|
166
|
+
const entries = parsed.sections[section] || [];
|
|
167
|
+
if (entries.length === 0) {
|
|
168
|
+
lines.push('_No learnings yet._');
|
|
169
|
+
} else {
|
|
170
|
+
for (const entry of entries) {
|
|
171
|
+
lines.push(`- ${entry.text}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
lines.push('');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return lines.join('\n');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Builds a context injection string from raw learnings content.
|
|
182
|
+
* Wraps in <guild-learnings> tags per spec section 4.2.
|
|
183
|
+
* Returns empty string for null/empty input.
|
|
184
|
+
* @param {string | null | undefined} content
|
|
185
|
+
* @returns {string}
|
|
186
|
+
*/
|
|
187
|
+
export function buildContextInjection(content) {
|
|
188
|
+
if (!content || !content.trim()) return '';
|
|
189
|
+
|
|
190
|
+
const parsed = parseLearnings(content);
|
|
191
|
+
const hasEntries = LEARNINGS_SECTIONS.some(s => (parsed.sections[s] || []).length > 0);
|
|
192
|
+
if (!hasEntries) return '';
|
|
193
|
+
|
|
194
|
+
const lines = [];
|
|
195
|
+
lines.push('<guild-learnings>');
|
|
196
|
+
lines.push(content.trim());
|
|
197
|
+
lines.push('</guild-learnings>');
|
|
198
|
+
lines.push('');
|
|
199
|
+
lines.push('These are accumulated learnings from previous Guild executions on this project.');
|
|
200
|
+
lines.push('Use them to inform your work. Do not contradict established patterns unless');
|
|
201
|
+
lines.push('explicitly asked to change them.');
|
|
202
|
+
|
|
203
|
+
return lines.join('\n');
|
|
204
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orchestrator-io.js — I/O layer for the Guild runtime orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Handles file I/O, skill loading, dispatch resolution, learnings injection,
|
|
5
|
+
* and trace management. Depends on the pure orchestrator.js for plan logic.
|
|
6
|
+
*
|
|
7
|
+
* Re-exports all public functions from orchestrator.js for a single import point.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
import { loadSkill } from './skill-loader.js';
|
|
12
|
+
import { resolveAgentMetadata, resolveEffectiveTier, resolveModel } from './dispatch.js';
|
|
13
|
+
import { createTrace, recordStep, finalizeTrace } from './trace.js';
|
|
14
|
+
import { buildContextInjection } from './learnings.js';
|
|
15
|
+
import { readLearnings } from './learnings-io.js';
|
|
16
|
+
import {
|
|
17
|
+
createExecutionPlan,
|
|
18
|
+
advanceStep,
|
|
19
|
+
getNextSteps,
|
|
20
|
+
isPlanComplete,
|
|
21
|
+
parseCondition,
|
|
22
|
+
evaluateCondition,
|
|
23
|
+
shouldRetry,
|
|
24
|
+
resolveFailureTarget,
|
|
25
|
+
expandDelegation,
|
|
26
|
+
} from './orchestrator.js';
|
|
27
|
+
|
|
28
|
+
// Re-export all public functions from orchestrator.js
|
|
29
|
+
export {
|
|
30
|
+
createExecutionPlan,
|
|
31
|
+
advanceStep,
|
|
32
|
+
getNextSteps,
|
|
33
|
+
isPlanComplete,
|
|
34
|
+
parseCondition,
|
|
35
|
+
evaluateCondition,
|
|
36
|
+
shouldRetry,
|
|
37
|
+
resolveFailureTarget,
|
|
38
|
+
expandDelegation,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Also re-export constants
|
|
42
|
+
export { DEFAULT_CIRCUIT_BREAKER_LIMIT, MAX_DELEGATION_DEPTH } from './orchestrator.js';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {Object} StepDispatchInfo
|
|
46
|
+
* @property {'system'|'agent'} role - Whether this is a system or agent step
|
|
47
|
+
* @property {string|null} tier - Resolved model tier (null for system steps)
|
|
48
|
+
* @property {string|null} model - Resolved concrete model ID (null for system steps)
|
|
49
|
+
* @property {boolean} fallback - Whether a fallback model was used
|
|
50
|
+
* @property {object|null} agentMetadata - Agent frontmatter metadata (null for system steps)
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolves dispatch information for a workflow step.
|
|
55
|
+
* System role steps return minimal dispatch info (no model).
|
|
56
|
+
* Agent steps resolve tier and model using the dispatch utilities.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} step - Workflow step definition
|
|
59
|
+
* @param {object} [options={}] - Options
|
|
60
|
+
* @param {string} [options.profile='max'] - Model profile name
|
|
61
|
+
* @param {string} [options.projectRoot=process.cwd()] - Project root for agent lookup
|
|
62
|
+
* @returns {StepDispatchInfo}
|
|
63
|
+
*/
|
|
64
|
+
export function resolveStepDispatch(step, options = {}) {
|
|
65
|
+
const { profile = 'max', projectRoot = process.cwd() } = options;
|
|
66
|
+
|
|
67
|
+
if (step.role === 'system') {
|
|
68
|
+
return {
|
|
69
|
+
role: 'system',
|
|
70
|
+
tier: null,
|
|
71
|
+
model: null,
|
|
72
|
+
fallback: false,
|
|
73
|
+
agentMetadata: null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const agentMetadata = resolveAgentMetadata(step.role, projectRoot);
|
|
78
|
+
|
|
79
|
+
// Build a step config object compatible with resolveEffectiveTier
|
|
80
|
+
const stepConfig = {
|
|
81
|
+
role: step.role,
|
|
82
|
+
'model-tier': step.modelTier,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const tier = resolveEffectiveTier(stepConfig, agentMetadata);
|
|
86
|
+
|
|
87
|
+
// W4: resolveModel already handles the fallback chain internally;
|
|
88
|
+
// a single try-catch is sufficient here.
|
|
89
|
+
let model;
|
|
90
|
+
let fallback;
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
model = resolveModel(tier, profile);
|
|
94
|
+
fallback = false;
|
|
95
|
+
} catch {
|
|
96
|
+
model = null;
|
|
97
|
+
fallback = false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
role: 'agent',
|
|
102
|
+
tier,
|
|
103
|
+
model,
|
|
104
|
+
fallback,
|
|
105
|
+
agentMetadata,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Loads and validates a skill workflow from disk.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} skillName - Name of the skill directory
|
|
113
|
+
* @param {object} [options={}] - Options
|
|
114
|
+
* @param {string} [options.basePath] - Override base path for skills directory
|
|
115
|
+
* @returns {{ workflow: object, body: string, name: string }}
|
|
116
|
+
* @throws {Error} If skill not found, has no workflow, or has validation errors
|
|
117
|
+
*/
|
|
118
|
+
export function loadWorkflow(skillName, options = {}) {
|
|
119
|
+
const skill = options.basePath
|
|
120
|
+
? loadSkill(skillName, options.basePath)
|
|
121
|
+
: loadSkill(skillName);
|
|
122
|
+
|
|
123
|
+
if (!skill.workflow) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Skill "${skillName}" has no workflow definition. Only skills with a workflow block can be orchestrated.`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (skill.errors && skill.errors.length > 0) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Skill "${skillName}" has workflow validation errors:\n${skill.errors.join('\n')}`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
workflow: skill.workflow,
|
|
137
|
+
body: skill.body,
|
|
138
|
+
name: skill.name || skillName,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Builds the context string for a step's execution.
|
|
144
|
+
* Combines step intent, learnings injection, skill body, and required step outcomes.
|
|
145
|
+
*
|
|
146
|
+
* @param {object} step - Workflow step definition
|
|
147
|
+
* @param {import('./orchestrator.js').ExecutionPlan} plan - Current execution plan
|
|
148
|
+
* @param {object} [options={}] - Options
|
|
149
|
+
* @param {string} [options.learningsPath] - Override path for learnings file
|
|
150
|
+
* @param {string} [options.skillBody=''] - Skill body (markdown prose)
|
|
151
|
+
* @returns {string} Context string for the step
|
|
152
|
+
*/
|
|
153
|
+
export function buildStepContext(step, plan, options = {}) {
|
|
154
|
+
const { skillBody = '' } = options;
|
|
155
|
+
const parts = [];
|
|
156
|
+
|
|
157
|
+
// Step intent
|
|
158
|
+
parts.push(`## Task: ${step.intent}`);
|
|
159
|
+
parts.push('');
|
|
160
|
+
|
|
161
|
+
// Learnings injection (graceful degradation if reading fails)
|
|
162
|
+
try {
|
|
163
|
+
const learningsContent = readLearnings(options.learningsPath || undefined);
|
|
164
|
+
if (learningsContent) {
|
|
165
|
+
const injection = buildContextInjection(learningsContent);
|
|
166
|
+
if (injection) {
|
|
167
|
+
parts.push(injection);
|
|
168
|
+
parts.push('');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
// Learnings reading failed — continue without them
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Skill body (prose instructions)
|
|
176
|
+
if (skillBody && skillBody.trim()) {
|
|
177
|
+
parts.push('## Skill Instructions');
|
|
178
|
+
parts.push('');
|
|
179
|
+
parts.push(skillBody.trim());
|
|
180
|
+
parts.push('');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Outcomes from required steps
|
|
184
|
+
if (step.requires && step.requires.length > 0) {
|
|
185
|
+
const requiredOutcomes = [];
|
|
186
|
+
for (const requirement of step.requires) {
|
|
187
|
+
// Find which step produces this requirement
|
|
188
|
+
for (const group of plan.groups) {
|
|
189
|
+
for (const s of group.steps) {
|
|
190
|
+
const state = plan.stepStates[s.id];
|
|
191
|
+
if (state && state.outcome && s.produces && s.produces.includes(requirement)) {
|
|
192
|
+
if (state.outcome[requirement] !== undefined) {
|
|
193
|
+
requiredOutcomes.push({ field: requirement, value: state.outcome[requirement], fromStep: s.id });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (requiredOutcomes.length > 0) {
|
|
201
|
+
parts.push('## Inputs from previous steps');
|
|
202
|
+
parts.push('');
|
|
203
|
+
for (const item of requiredOutcomes) {
|
|
204
|
+
parts.push(`### ${item.field} (from step: ${item.fromStep})`);
|
|
205
|
+
parts.push('');
|
|
206
|
+
parts.push(String(item.value));
|
|
207
|
+
parts.push('');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return parts.join('\n').trimEnd();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Records a step execution in the trace context.
|
|
217
|
+
*
|
|
218
|
+
* @param {import('./trace.js').TraceContext} traceCtx - Active trace context
|
|
219
|
+
* @param {object} step - Step definition
|
|
220
|
+
* @param {import('./orchestrator.js').StepState} stepState - Final state of the step
|
|
221
|
+
* @param {StepDispatchInfo} dispatchInfo - Resolved dispatch information
|
|
222
|
+
* @returns {import('./trace.js').TraceContext} Updated trace context (same reference)
|
|
223
|
+
*/
|
|
224
|
+
export function recordStepTrace(traceCtx, step, stepState, dispatchInfo) {
|
|
225
|
+
const started = new Date().toISOString();
|
|
226
|
+
|
|
227
|
+
return recordStep(traceCtx, {
|
|
228
|
+
role: step.role,
|
|
229
|
+
intent: step.intent,
|
|
230
|
+
tier: dispatchInfo.tier || 'system',
|
|
231
|
+
model: dispatchInfo.model || 'none',
|
|
232
|
+
fallback: dispatchInfo.fallback,
|
|
233
|
+
started,
|
|
234
|
+
// TODO(v2): capture actual wall-clock duration and token usage from step executor
|
|
235
|
+
duration: 0,
|
|
236
|
+
tokens: 0,
|
|
237
|
+
result: stepState.status === 'passed' ? 'pass' : stepState.status === 'skipped' ? 'skip' : 'fail',
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Finalizes a workflow trace and produces an execution summary.
|
|
243
|
+
*
|
|
244
|
+
* testsPass and lintPass are derived from gate steps whose intent contains
|
|
245
|
+
* the keywords "test" or "lint" respectively. They can be overridden via
|
|
246
|
+
* options when the caller has better information.
|
|
247
|
+
*
|
|
248
|
+
* @param {import('./trace.js').TraceContext} traceCtx - Completed trace context
|
|
249
|
+
* @param {import('./orchestrator.js').ExecutionPlan} plan - Final execution plan
|
|
250
|
+
* @param {object} [options={}] - Optional overrides
|
|
251
|
+
* @param {boolean} [options.testsPass] - Override for testsPass (derived from gate steps if omitted)
|
|
252
|
+
* @param {boolean} [options.lintPass] - Override for lintPass (derived from gate steps if omitted)
|
|
253
|
+
* @returns {{ trace: object, executionSummary: string }}
|
|
254
|
+
*/
|
|
255
|
+
export function finalizeWorkflowTrace(traceCtx, plan, options = {}) {
|
|
256
|
+
const allStates = Object.values(plan.stepStates);
|
|
257
|
+
const anyFailed = allStates.some(s => s.status === 'failed');
|
|
258
|
+
|
|
259
|
+
// Derive testsPass / lintPass from gate steps when not explicitly provided
|
|
260
|
+
let testsPass = options.testsPass;
|
|
261
|
+
let lintPass = options.lintPass;
|
|
262
|
+
|
|
263
|
+
if (testsPass === undefined || lintPass === undefined) {
|
|
264
|
+
for (const group of plan.groups) {
|
|
265
|
+
for (const step of group.steps) {
|
|
266
|
+
if (!step.gate) continue;
|
|
267
|
+
const state = plan.stepStates[step.id];
|
|
268
|
+
const passed = state && state.status === 'passed';
|
|
269
|
+
const intent = (step.intent || '').toLowerCase();
|
|
270
|
+
if (testsPass === undefined && intent.includes('test')) {
|
|
271
|
+
testsPass = passed;
|
|
272
|
+
}
|
|
273
|
+
if (lintPass === undefined && intent.includes('lint')) {
|
|
274
|
+
lintPass = passed;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// Fall back to true when no matching gate step found (v1 behaviour)
|
|
279
|
+
if (testsPass === undefined) testsPass = true;
|
|
280
|
+
if (lintPass === undefined) lintPass = true;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const summary = {
|
|
284
|
+
result: anyFailed || plan.status === 'aborted' || plan.status === 'circuit-breaker' ? 'fail' : 'pass',
|
|
285
|
+
testsPass,
|
|
286
|
+
lintPass,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const trace = finalizeTrace(traceCtx, summary);
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
trace,
|
|
293
|
+
executionSummary: trace.executionSummary,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Main entry point for the orchestrator.
|
|
299
|
+
* Loads the workflow, creates an execution plan, resolves dispatch for all steps,
|
|
300
|
+
* and creates a trace context. In v1, this produces the plan only (no step execution).
|
|
301
|
+
*
|
|
302
|
+
* @param {string} skillName - Name of the skill to orchestrate
|
|
303
|
+
* @param {string} [input=''] - User input / feature description
|
|
304
|
+
* @param {object} [options={}] - Options
|
|
305
|
+
* @param {string} [options.profile='max'] - Model profile
|
|
306
|
+
* @param {string} [options.projectRoot=process.cwd()] - Project root
|
|
307
|
+
* @param {string} [options.basePath] - Override skills base path
|
|
308
|
+
* @param {string} [options.tracesDir] - Override traces directory
|
|
309
|
+
* @param {string} [options.traceLevel='default'] - Trace level
|
|
310
|
+
* @param {number} [options.circuitBreakerLimit] - Override circuit breaker limit
|
|
311
|
+
* @returns {Promise<{
|
|
312
|
+
* plan: import('./orchestrator.js').ExecutionPlan,
|
|
313
|
+
* trace: import('./trace.js').TraceContext,
|
|
314
|
+
* dispatchInfoMap: Object.<string, StepDispatchInfo>,
|
|
315
|
+
* input: string
|
|
316
|
+
* }>}
|
|
317
|
+
*/
|
|
318
|
+
export async function orchestrate(skillName, input = '', options = {}) {
|
|
319
|
+
const {
|
|
320
|
+
profile = 'max',
|
|
321
|
+
projectRoot = process.cwd(),
|
|
322
|
+
tracesDir,
|
|
323
|
+
traceLevel = 'default',
|
|
324
|
+
circuitBreakerLimit,
|
|
325
|
+
} = options;
|
|
326
|
+
|
|
327
|
+
// Load workflow
|
|
328
|
+
const loadOpts = {};
|
|
329
|
+
if (options.basePath) {
|
|
330
|
+
loadOpts.basePath = options.basePath;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const { workflow, name } = loadWorkflow(skillName, loadOpts);
|
|
334
|
+
|
|
335
|
+
// Create execution plan
|
|
336
|
+
const planOptions = { skillName: name || skillName };
|
|
337
|
+
if (circuitBreakerLimit !== undefined) {
|
|
338
|
+
planOptions.circuitBreakerLimit = circuitBreakerLimit;
|
|
339
|
+
}
|
|
340
|
+
const plan = createExecutionPlan(workflow, planOptions);
|
|
341
|
+
|
|
342
|
+
// Resolve dispatch info for all steps (for plan introspection/display)
|
|
343
|
+
const dispatchInfoMap = {};
|
|
344
|
+
for (const group of plan.groups) {
|
|
345
|
+
for (const step of group.steps) {
|
|
346
|
+
dispatchInfoMap[step.id] = resolveStepDispatch(step, { profile, projectRoot });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// W8: return dispatchInfoMap and input as separate result fields instead of
|
|
351
|
+
// mutating traceCtx, keeping the trace context pure.
|
|
352
|
+
const traceDirResolved = tracesDir || join(projectRoot, '.claude', 'guild', 'traces');
|
|
353
|
+
const traceCtx = createTrace(skillName, traceLevel, traceDirResolved);
|
|
354
|
+
|
|
355
|
+
return { plan, trace: traceCtx, dispatchInfoMap, input };
|
|
356
|
+
}
|