guild-agents 1.2.0 → 1.4.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 (44) hide show
  1. package/README.md +16 -0
  2. package/bin/guild.js +73 -0
  3. package/package.json +5 -2
  4. package/src/commands/eval.js +225 -0
  5. package/src/commands/stats.js +147 -0
  6. package/src/commands/workspace.js +38 -1
  7. package/src/templates/skills/build-feature/evals/evals.json +53 -0
  8. package/src/templates/skills/build-feature/evals/triggers.json +16 -0
  9. package/src/templates/skills/council/SKILL.md +27 -6
  10. package/src/templates/skills/council/evals/evals.json +41 -0
  11. package/src/templates/skills/council/evals/triggers.json +16 -0
  12. package/src/templates/skills/create-pr/evals/evals.json +44 -0
  13. package/src/templates/skills/create-pr/evals/triggers.json +16 -0
  14. package/src/templates/skills/debug/SKILL.md +1 -1
  15. package/src/templates/skills/debug/evals/triggers.json +16 -0
  16. package/src/templates/skills/dev-flow/evals/evals.json +36 -0
  17. package/src/templates/skills/dev-flow/evals/triggers.json +16 -0
  18. package/src/templates/skills/guild-specialize/evals/evals.json +54 -0
  19. package/src/templates/skills/guild-specialize/evals/triggers.json +16 -0
  20. package/src/templates/skills/new-feature/evals/evals.json +41 -0
  21. package/src/templates/skills/new-feature/evals/triggers.json +16 -0
  22. package/src/templates/skills/qa-cycle/evals/evals.json +46 -0
  23. package/src/templates/skills/qa-cycle/evals/triggers.json +16 -0
  24. package/src/templates/skills/re-specialize/evals/evals.json +48 -0
  25. package/src/templates/skills/re-specialize/evals/triggers.json +16 -0
  26. package/src/templates/skills/review/evals/evals.json +43 -0
  27. package/src/templates/skills/review/evals/triggers.json +16 -0
  28. package/src/templates/skills/session-end/evals/evals.json +40 -0
  29. package/src/templates/skills/session-end/evals/triggers.json +16 -0
  30. package/src/templates/skills/session-start/evals/evals.json +50 -0
  31. package/src/templates/skills/session-start/evals/triggers.json +16 -0
  32. package/src/templates/skills/status/evals/evals.json +40 -0
  33. package/src/templates/skills/status/evals/triggers.json +16 -0
  34. package/src/templates/skills/tdd/evals/triggers.json +16 -0
  35. package/src/templates/skills/verify/evals/triggers.json +16 -0
  36. package/src/utils/accounting.js +139 -0
  37. package/src/utils/benchmark.js +128 -0
  38. package/src/utils/description-analyzer.js +92 -0
  39. package/src/utils/eval-runner.js +139 -0
  40. package/src/utils/pricing.js +28 -0
  41. package/src/utils/semantic-matcher.js +91 -0
  42. package/src/utils/trigger-matcher.js +64 -0
  43. package/src/utils/trigger-runner.js +132 -0
  44. package/src/utils/workspace.js +89 -0
@@ -0,0 +1,28 @@
1
+ /**
2
+ * pricing.js — Model pricing table and cost calculation.
3
+ *
4
+ * Prices per million tokens (USD).
5
+ * Source: https://docs.anthropic.com/en/docs/about-claude/models
6
+ */
7
+
8
+ export const DEFAULT_PRICING = {
9
+ 'claude-opus-4-6': { input: 15.00, output: 75.00 },
10
+ 'claude-sonnet-4-5': { input: 3.00, output: 15.00 },
11
+ 'claude-haiku-4-5': { input: 0.80, output: 4.00 },
12
+ };
13
+
14
+ const SHORT_NAMES = {
15
+ 'claude-opus-4-6': 'Opus',
16
+ 'claude-sonnet-4-5': 'Sonnet',
17
+ 'claude-haiku-4-5': 'Haiku',
18
+ };
19
+
20
+ export function estimateCost(model, inputTokens, outputTokens) {
21
+ const pricing = DEFAULT_PRICING[model];
22
+ if (!pricing) return 0;
23
+ return (inputTokens * pricing.input + outputTokens * pricing.output) / 1_000_000;
24
+ }
25
+
26
+ export function getModelShortName(model) {
27
+ return SHORT_NAMES[model] || model;
28
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * semantic-matcher.js — LLM-based trigger scoring via Anthropic Haiku.
3
+ *
4
+ * Calls the Anthropic Messages API to score how well a user prompt
5
+ * matches a skill. Optional complement to the keyword matcher.
6
+ */
7
+
8
+ export const SEMANTIC_MODEL_DEFAULT = 'claude-haiku-4-5-20251001';
9
+
10
+ const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages';
11
+
12
+ const SYSTEM_PROMPT = `You are a skill-routing classifier. Given a user prompt and a skill name + description, score how likely the user wants to trigger this skill.
13
+
14
+ Respond with ONLY a JSON object, no other text:
15
+ {"score": <0-100>, "reasoning": "<one sentence>"}
16
+
17
+ Score guide:
18
+ - 90-100: Clear, direct match
19
+ - 60-89: Likely match, related intent
20
+ - 30-59: Possible but ambiguous
21
+ - 0-29: Unrelated`;
22
+
23
+ /**
24
+ * Scores a prompt against a skill using the Anthropic Messages API.
25
+ * @param {string} prompt - User prompt to classify
26
+ * @param {string} skillName - Skill identifier
27
+ * @param {string} skillDescription - Skill description text
28
+ * @returns {Promise<{ score: number, reasoning: string, error?: boolean }>}
29
+ */
30
+ export async function scoreMatchSemantic(prompt, skillName, skillDescription) {
31
+ const apiKey = process.env.ANTHROPIC_API_KEY;
32
+ const model = process.env.GUILD_SEMANTIC_MODEL || SEMANTIC_MODEL_DEFAULT;
33
+
34
+ try {
35
+ const response = await fetch(ANTHROPIC_API_URL, {
36
+ method: 'POST',
37
+ headers: {
38
+ 'Content-Type': 'application/json',
39
+ 'x-api-key': apiKey,
40
+ 'anthropic-version': '2023-06-01',
41
+ },
42
+ body: JSON.stringify({
43
+ model,
44
+ max_tokens: 100,
45
+ system: SYSTEM_PROMPT,
46
+ messages: [
47
+ {
48
+ role: 'user',
49
+ content: `User prompt: "${prompt}"\nSkill: ${skillName}\nDescription: ${skillDescription}`,
50
+ },
51
+ ],
52
+ }),
53
+ });
54
+
55
+ if (!response.ok) {
56
+ return { score: 0, reasoning: `API error: ${response.status} ${response.statusText}`, error: true };
57
+ }
58
+
59
+ const data = await response.json();
60
+ const text = data.content[0].text;
61
+
62
+ return parseResponse(text);
63
+ } catch (err) {
64
+ return { score: 0, reasoning: err.message, error: true };
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Parses the LLM response, extracting JSON with fallback.
70
+ * @param {string} text
71
+ * @returns {{ score: number, reasoning: string, error?: boolean }}
72
+ */
73
+ function parseResponse(text) {
74
+ // Try direct parse first
75
+ try {
76
+ const parsed = JSON.parse(text);
77
+ return { score: parsed.score / 100, reasoning: parsed.reasoning };
78
+ } catch {
79
+ // Fallback: extract first JSON object from text
80
+ const match = text.match(/\{[^}]+\}/);
81
+ if (match) {
82
+ try {
83
+ const parsed = JSON.parse(match[0]);
84
+ return { score: parsed.score / 100, reasoning: parsed.reasoning };
85
+ } catch {
86
+ // Fall through
87
+ }
88
+ }
89
+ return { score: 0, reasoning: 'parse-error', error: true };
90
+ }
91
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * trigger-matcher.js — Scores prompts against skill descriptions.
3
+ *
4
+ * Uses keyword overlap scoring to determine how well a user prompt
5
+ * matches a skill's description. No LLM calls — purely programmatic.
6
+ */
7
+
8
+ /**
9
+ * Tokenizes text into lowercase words, stripping punctuation.
10
+ * @param {string} text
11
+ * @returns {string[]}
12
+ */
13
+ export function tokenize(text) {
14
+ return text
15
+ .toLowerCase()
16
+ .replace(/[—–\-/]/g, ' ')
17
+ .replace(/[^\w\s]/g, '')
18
+ .split(/\s+/)
19
+ .filter(w => w.length > 1);
20
+ }
21
+
22
+ const STOP_WORDS = new Set([
23
+ 'the', 'is', 'at', 'in', 'on', 'to', 'of', 'for', 'and', 'or', 'an',
24
+ 'it', 'by', 'as', 'be', 'do', 'if', 'no', 'so', 'up', 'we', 'my',
25
+ 'use', 'when', 'with', 'from', 'this', 'that', 'will', 'can', 'has',
26
+ 'not', 'are', 'was', 'but', 'all', 'any', 'its', 'you', 'your',
27
+ 'skill', 'discipline',
28
+ ]);
29
+
30
+ /**
31
+ * Scores how well a prompt matches a description.
32
+ * Returns 0-1.
33
+ */
34
+ export function scoreMatch(prompt, description) {
35
+ const promptTokens = tokenize(prompt).filter(w => !STOP_WORDS.has(w));
36
+ if (promptTokens.length === 0) return 0;
37
+
38
+ const descTokens = new Set(tokenize(description).filter(w => !STOP_WORDS.has(w)));
39
+
40
+ let matches = 0;
41
+ for (const token of promptTokens) {
42
+ if (descTokens.has(token)) {
43
+ matches++;
44
+ } else {
45
+ for (const dt of descTokens) {
46
+ if (dt.includes(token) || token.includes(dt)) {
47
+ matches += 0.5;
48
+ break;
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ return matches / promptTokens.length;
55
+ }
56
+
57
+ /**
58
+ * Ranks all skills by match score descending.
59
+ */
60
+ export function rankSkills(prompt, skills) {
61
+ return skills
62
+ .map(s => ({ ...s, score: scoreMatch(prompt, s.description) }))
63
+ .sort((a, b) => b.score - a.score);
64
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * trigger-runner.js — Loads and executes trigger tests for skills.
3
+ */
4
+
5
+ import { readFileSync, existsSync, readdirSync } from 'fs';
6
+ import { join, dirname } from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ import { rankSkills } from './trigger-matcher.js';
9
+ import { extractFrontmatterBlock, parseYamlFrontmatter } from './workflow-parser.js';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const TEMPLATES_DIR = join(__dirname, '..', 'templates', 'skills');
13
+
14
+ /**
15
+ * Loads triggers.json for a skill template.
16
+ * @param {string} skillName
17
+ * @returns {object|null}
18
+ */
19
+ export function loadTriggers(skillName) {
20
+ const triggersPath = join(TEMPLATES_DIR, skillName, 'evals', 'triggers.json');
21
+ if (!existsSync(triggersPath)) return null;
22
+ return JSON.parse(readFileSync(triggersPath, 'utf8'));
23
+ }
24
+
25
+ /**
26
+ * Loads all skill names and descriptions from templates.
27
+ * @returns {{ name: string, description: string }[]}
28
+ */
29
+ export function loadAllSkillDescriptions() {
30
+ const skillDirs = readdirSync(TEMPLATES_DIR, { withFileTypes: true })
31
+ .filter(d => d.isDirectory())
32
+ .map(d => d.name);
33
+
34
+ const skills = [];
35
+ for (const name of skillDirs) {
36
+ const skillPath = join(TEMPLATES_DIR, name, 'SKILL.md');
37
+ if (!existsSync(skillPath)) continue;
38
+ const content = readFileSync(skillPath, 'utf8');
39
+ const block = extractFrontmatterBlock(content);
40
+ if (!block) continue;
41
+ const fm = parseYamlFrontmatter(block.yaml);
42
+ if (fm.description) {
43
+ skills.push({ name, description: fm.description });
44
+ }
45
+ }
46
+ return skills;
47
+ }
48
+
49
+ /**
50
+ * Runs trigger tests for a skill.
51
+ *
52
+ * When matcherType is "keyword" and a test has keywordExpected defined,
53
+ * that value overrides shouldTrigger for accuracy calculation. This lets
54
+ * tests document the ideal (semantic) expectation while being honest
55
+ * about what keyword matching can achieve.
56
+ *
57
+ * @param {object} triggers - Trigger test config from triggers.json
58
+ * @param {Array} allSkills - All skill descriptions
59
+ * @param {object} [options] - Options
60
+ * @param {boolean} [options.semantic=false] - Use semantic matcher
61
+ * @param {Function} [options.scoreMatchSemantic] - Semantic scoring function (injected for testability)
62
+ */
63
+ export async function runTriggerTests(triggers, allSkills, options = {}) {
64
+ const { semantic = false, scoreMatchSemantic: semanticFn } = options;
65
+ const threshold = triggers.threshold || 0.3;
66
+ const isKeyword = !semantic && triggers.matcherType === 'keyword';
67
+ const results = [];
68
+
69
+ for (const test of triggers.tests) {
70
+ let actual, score, rank, reasoning;
71
+
72
+ if (semantic && semanticFn) {
73
+ const targetSkill = allSkills.find(s => s.name === triggers.skill);
74
+ const semanticResult = await semanticFn(test.prompt, triggers.skill, targetSkill?.description || triggers.description);
75
+ score = semanticResult.score;
76
+ actual = score >= threshold;
77
+ rank = null;
78
+ reasoning = semanticResult.reasoning;
79
+ } else {
80
+ const ranked = rankSkills(test.prompt, allSkills);
81
+ const targetRank = ranked.findIndex(s => s.name === triggers.skill);
82
+ score = targetRank >= 0 ? ranked[targetRank].score : 0;
83
+ actual = targetRank === 0 && score >= threshold;
84
+ rank = targetRank + 1;
85
+ }
86
+
87
+ const hasOverride = isKeyword && test.keywordExpected !== undefined;
88
+ const expected = hasOverride ? test.keywordExpected : test.shouldTrigger;
89
+
90
+ const result = {
91
+ prompt: test.prompt,
92
+ expected,
93
+ actual,
94
+ score,
95
+ rank,
96
+ matcherUsed: semantic ? 'semantic' : 'keyword',
97
+ };
98
+
99
+ if (reasoning) {
100
+ result.reasoning = reasoning;
101
+ }
102
+
103
+ if (hasOverride) {
104
+ result.semanticExpected = test.shouldTrigger;
105
+ }
106
+
107
+ results.push(result);
108
+ }
109
+
110
+ return results;
111
+ }
112
+
113
+ /**
114
+ * Computes precision, recall, and accuracy from trigger test results.
115
+ */
116
+ export function computeAccuracy(results) {
117
+ if (results.length === 0) return { precision: 0, recall: 0, accuracy: 0, total: 0, tp: 0, fp: 0, fn: 0, tn: 0 };
118
+
119
+ let tp = 0, fp = 0, fn = 0, tn = 0;
120
+ for (const r of results) {
121
+ if (r.expected && r.actual) tp++;
122
+ else if (!r.expected && r.actual) fp++;
123
+ else if (r.expected && !r.actual) fn++;
124
+ else tn++;
125
+ }
126
+
127
+ const precision = (tp + fp) > 0 ? tp / (tp + fp) : 0;
128
+ const recall = (tp + fn) > 0 ? tp / (tp + fn) : 0;
129
+ const accuracy = (tp + tn) / results.length;
130
+
131
+ return { precision, recall, accuracy, total: results.length, tp, fp, fn, tn };
132
+ }
@@ -1,8 +1,15 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'fs';
2
2
  import { join, dirname, resolve } from 'path';
3
+ import { execFileSync } from 'node:child_process';
3
4
 
4
5
  export const WORKSPACE_FILE = 'guild-workspace.json';
5
6
 
7
+ export const PRESET_COMMANDS = {
8
+ test: { cmd: 'npm', args: ['test'] },
9
+ lint: { cmd: 'npm', args: ['run', 'lint'] },
10
+ build: { cmd: 'npm', args: ['run', 'build'] },
11
+ };
12
+
6
13
  export function findWorkspaceRoot(startDir = process.cwd()) {
7
14
  let dir = resolve(startDir);
8
15
  while (true) {
@@ -80,3 +87,85 @@ export function generateWorkspaceContext(workspace, currentMemberName) {
80
87
 
81
88
  return lines.join('\n');
82
89
  }
90
+
91
+ export function collectMemberContext(workspace, currentMemberName) {
92
+ if (!workspace) return '';
93
+
94
+ const siblings = workspace.members.filter(m => m.name !== currentMemberName);
95
+ if (siblings.length === 0) return '';
96
+
97
+ const lines = [`## Workspace: ${workspace.name}`, ''];
98
+
99
+ for (const member of siblings) {
100
+ lines.push(`### ${member.name} (sibling — ${member.absolutePath})`);
101
+
102
+ const projectMdPath = join(member.absolutePath, 'PROJECT.md');
103
+ if (existsSync(projectMdPath)) {
104
+ const content = readFileSync(projectMdPath, 'utf8');
105
+ const stackMatch = content.match(/\*\*Stack:\*\*\s*(.+)/);
106
+ if (stackMatch) {
107
+ lines.push(`- **Stack:** ${stackMatch[1].trim()}`);
108
+ }
109
+ }
110
+
111
+ const claudeMdPath = join(member.absolutePath, 'CLAUDE.md');
112
+ if (existsSync(claudeMdPath)) {
113
+ const content = readFileSync(claudeMdPath, 'utf8');
114
+ const structureMatch = content.match(/## Project structure\n(.+)/);
115
+ if (structureMatch) {
116
+ lines.push(`- **Structure:** ${structureMatch[1].trim()}`);
117
+ }
118
+ }
119
+
120
+ const sessionMdPath = join(member.absolutePath, 'SESSION.md');
121
+ if (existsSync(sessionMdPath)) {
122
+ const content = readFileSync(sessionMdPath, 'utf8');
123
+ const taskMatch = content.match(/\*\*Current task:\*\*\s*(.+)/);
124
+ if (taskMatch) {
125
+ lines.push(`- **Current task:** ${taskMatch[1].trim()}`);
126
+ }
127
+ }
128
+
129
+ lines.push(`You can read any file under ${member.absolutePath}/ for deeper analysis.`);
130
+ lines.push('');
131
+ }
132
+
133
+ return lines.join('\n').trim();
134
+ }
135
+
136
+ export function runInMember(member, cmd, args) {
137
+ if (!existsSync(member.absolutePath)) {
138
+ return {
139
+ member: member.name,
140
+ status: 'failed',
141
+ output: `Directory not found: ${member.absolutePath}`,
142
+ duration: 0,
143
+ };
144
+ }
145
+
146
+ const start = Date.now();
147
+ try {
148
+ const stdout = execFileSync(cmd, args, {
149
+ cwd: member.absolutePath,
150
+ encoding: 'utf8',
151
+ stdio: ['pipe', 'pipe', 'pipe'],
152
+ });
153
+ const duration = Date.now() - start;
154
+ return {
155
+ member: member.name,
156
+ status: 'passed',
157
+ output: stdout.trim(),
158
+ duration,
159
+ };
160
+ } catch (error) {
161
+ const duration = Date.now() - start;
162
+ const stdout = error.stdout || '';
163
+ const stderr = error.stderr || '';
164
+ return {
165
+ member: member.name,
166
+ status: 'failed',
167
+ output: (stdout + stderr).trim(),
168
+ duration,
169
+ };
170
+ }
171
+ }