guild-agents 0.3.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +5 -1
  2. package/bin/guild.js +74 -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 +62 -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/learnings-io.js +76 -0
  32. package/src/utils/learnings.js +204 -0
  33. package/src/utils/orchestrator-io.js +356 -0
  34. package/src/utils/orchestrator.js +590 -0
  35. package/src/utils/skill-loader.js +83 -0
  36. package/src/utils/trace.js +400 -0
  37. package/src/utils/version.js +90 -0
  38. package/src/utils/workflow-parser.js +225 -0
@@ -0,0 +1,400 @@
1
+ /**
2
+ * trace.js — Structured trace file utilities for Guild workflows.
3
+ *
4
+ * Provides pure rendering functions (renderTrace, renderStep, renderSummary)
5
+ * that take data in and return strings out, with zero I/O. Also provides
6
+ * I/O functions (createTrace, finalizeTrace, listTraces, cleanTraces) for
7
+ * file operations on `.claude/guild/traces/`.
8
+ */
9
+
10
+ import { mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, unlinkSync, statSync } from 'fs';
11
+ import { join, dirname } from 'path';
12
+ import { estimateTokens } from './learnings.js';
13
+
14
+ /** @typedef {'default' | 'verbose' | 'debug'} TraceLevel */
15
+
16
+ /**
17
+ * @typedef {Object} TraceContext
18
+ * @property {string} filePath - Absolute path to the trace file
19
+ * @property {string} workflow - Skill/workflow name
20
+ * @property {TraceLevel} level - Logging level
21
+ * @property {Date} started - Workflow start time
22
+ * @property {Array<TraceStep>} steps - Recorded steps
23
+ * @property {number} totalTokens - Running token total
24
+ */
25
+
26
+ /**
27
+ * @typedef {Object} TraceStep
28
+ * @property {string} role - Agent role (e.g. 'tech-lead')
29
+ * @property {string} intent - Brief description of what the agent was asked
30
+ * @property {string} tier - Tier assignment (e.g. 'reasoning', 'execution')
31
+ * @property {string} model - Resolved model ID
32
+ * @property {boolean} fallback - Whether a fallback model was used
33
+ * @property {string} started - ISO-8601 timestamp
34
+ * @property {number} duration - Duration in seconds
35
+ * @property {number} tokens - Token count for this step
36
+ * @property {string} result - 'pass', 'fail', or 'skip'
37
+ * @property {string} [decision] - Verbose+: why this model was chosen
38
+ * @property {string} [reasoning] - Verbose+: agent's self-reported reasoning
39
+ * @property {string} [fullPrompt] - Debug only: complete prompt sent
40
+ * @property {string} [fullResponse] - Debug only: complete response received
41
+ */
42
+
43
+ /**
44
+ * @typedef {Object} TraceSummary
45
+ * @property {string} result - 'pass' or 'fail'
46
+ * @property {boolean} testsPass - Whether tests passed
47
+ * @property {boolean} lintPass - Whether lint passed
48
+ */
49
+
50
+ /**
51
+ * Resolves the trace level from CLI option flags.
52
+ * If both verbose and debug are set, debug wins (highest level).
53
+ * @param {Object} [options] - CLI options
54
+ * @param {boolean} [options.verbose] - Enable verbose logging
55
+ * @param {boolean} [options.debug] - Enable debug logging
56
+ * @returns {TraceLevel}
57
+ */
58
+ export function resolveTraceLevel(options = {}) {
59
+ if (options.debug) return 'debug';
60
+ if (options.verbose) return 'verbose';
61
+ return 'default';
62
+ }
63
+
64
+ /**
65
+ * Formats a duration in milliseconds to HH:MM:SS string.
66
+ * @param {number} ms - Duration in milliseconds
67
+ * @returns {string} Formatted as HH:MM:SS
68
+ */
69
+ export function formatDuration(ms) {
70
+ const totalSeconds = Math.floor(ms / 1000);
71
+ const hours = Math.floor(totalSeconds / 3600);
72
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
73
+ const seconds = totalSeconds % 60;
74
+ return [
75
+ String(hours).padStart(2, '0'),
76
+ String(minutes).padStart(2, '0'),
77
+ String(seconds).padStart(2, '0'),
78
+ ].join(':');
79
+ }
80
+
81
+ /**
82
+ * Creates a new trace context object. Creates the traces directory if needed.
83
+ * Does NOT write anything to disk — writing happens on finalize (lazy).
84
+ * @param {string} workflowName - Skill/workflow name (e.g. 'build-feature')
85
+ * @param {TraceLevel} level - Logging level
86
+ * @param {string} [tracesDir] - Directory for trace files (default: .claude/guild/traces/)
87
+ * @returns {TraceContext}
88
+ */
89
+ export function createTrace(workflowName, level, tracesDir) {
90
+ const dir = tracesDir || join('.claude', 'guild', 'traces');
91
+ mkdirSync(dir, { recursive: true });
92
+
93
+ // Ensure traces directory is gitignored (local-only artifacts)
94
+ const gitignorePath = join(dir, '.gitignore');
95
+ if (!existsSync(gitignorePath)) {
96
+ writeFileSync(gitignorePath, '*\n!.gitignore\n', 'utf8');
97
+ }
98
+
99
+ const now = new Date();
100
+ const timestamp = now.toISOString()
101
+ .replace(/[-:]/g, '')
102
+ .replace(/\.\d{3}Z$/, '')
103
+ .replace('T', 'T');
104
+ const filename = `guild-trace-${timestamp}.md`;
105
+
106
+ return {
107
+ filePath: join(dir, filename),
108
+ workflow: workflowName,
109
+ level,
110
+ started: now,
111
+ steps: [],
112
+ totalTokens: 0,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Records a step in the trace context. Pushes step data and updates token total.
118
+ * @param {TraceContext} traceCtx - The trace context from createTrace
119
+ * @param {TraceStep} stepData - The step data to record
120
+ * @returns {TraceContext} The same context (for chaining)
121
+ */
122
+ export function recordStep(traceCtx, stepData) {
123
+ traceCtx.steps.push(stepData);
124
+ traceCtx.totalTokens += stepData.tokens || 0;
125
+ return traceCtx;
126
+ }
127
+
128
+ /**
129
+ * Renders a single step section as Markdown. PURE function — no I/O.
130
+ * Only includes decision/reasoning for verbose+. Only includes
131
+ * fullPrompt/fullResponse for debug level.
132
+ * @param {TraceStep} step - Step data
133
+ * @param {number} stepNumber - 1-based step index
134
+ * @param {TraceLevel} level - Current trace level
135
+ * @returns {string} Markdown string for the step
136
+ */
137
+ export function renderStep(step, stepNumber, level) {
138
+ const lines = [];
139
+ lines.push(`### Step ${stepNumber} — ${step.role}: ${step.intent}`);
140
+ lines.push('');
141
+ lines.push('| Field | Value |');
142
+ lines.push('|-------|-------|');
143
+ lines.push(`| Tier | ${step.tier} |`);
144
+ lines.push(`| Model | ${step.model} |`);
145
+ lines.push(`| Fallback | ${step.fallback ? 'yes' : 'no'} |`);
146
+ lines.push(`| Started | ${step.started} |`);
147
+ lines.push(`| Duration | ${step.duration}s |`);
148
+ lines.push(`| Tokens | ${step.tokens} |`);
149
+ lines.push(`| Result | ${step.result} |`);
150
+
151
+ if ((level === 'verbose' || level === 'debug') && step.decision) {
152
+ lines.push('');
153
+ lines.push(`**Decision:** ${step.decision}`);
154
+ }
155
+ if ((level === 'verbose' || level === 'debug') && step.reasoning) {
156
+ lines.push(`**Reasoning:** ${step.reasoning}`);
157
+ }
158
+
159
+ if (level === 'debug' && step.fullPrompt) {
160
+ lines.push('');
161
+ lines.push('<details>');
162
+ lines.push('<summary>Full prompt</summary>');
163
+ lines.push('');
164
+ lines.push(step.fullPrompt);
165
+ lines.push('');
166
+ lines.push('</details>');
167
+ }
168
+
169
+ if (level === 'debug' && step.fullResponse) {
170
+ lines.push('');
171
+ lines.push('<details>');
172
+ lines.push('<summary>Full response</summary>');
173
+ lines.push('');
174
+ lines.push(step.fullResponse);
175
+ lines.push('');
176
+ lines.push('</details>');
177
+ }
178
+
179
+ return lines.join('\n');
180
+ }
181
+
182
+ /**
183
+ * Renders the summary section as Markdown. PURE function — no I/O.
184
+ * @param {TraceSummary} summary - Summary data
185
+ * @param {number} totalTokens - Total tokens across all steps
186
+ * @param {string} duration - Formatted duration string (HH:MM:SS)
187
+ * @returns {string} Markdown string for the summary
188
+ */
189
+ export function renderSummary(summary, totalTokens, duration) {
190
+ const lines = [];
191
+ lines.push('## Summary');
192
+ lines.push('');
193
+ lines.push(`- **Result**: ${summary.result}`);
194
+ lines.push(`- **Tests**: ${summary.testsPass ? 'pass' : 'fail'}`);
195
+ lines.push(`- **Lint**: ${summary.lintPass ? 'pass' : 'fail'}`);
196
+ lines.push(`- **Total tokens**: ${totalTokens}`);
197
+ lines.push(`- **Duration**: ${duration}`);
198
+ return lines.join('\n');
199
+ }
200
+
201
+ /**
202
+ * Renders a complete trace as a Markdown string. PURE function — no I/O.
203
+ * @param {TraceContext} traceCtx - Trace context with all steps
204
+ * @param {TraceSummary} summary - Final summary data
205
+ * @param {Date} [finished] - Finish time (default: new Date())
206
+ * @returns {string} Complete Markdown content
207
+ */
208
+ export function renderTrace(traceCtx, summary, finished) {
209
+ const end = finished || new Date();
210
+ const durationMs = end.getTime() - traceCtx.started.getTime();
211
+ const durationStr = formatDuration(durationMs);
212
+
213
+ const lines = [];
214
+
215
+ // Header
216
+ lines.push(`# Guild Trace — ${traceCtx.workflow}`);
217
+ lines.push('');
218
+ lines.push(`> Level: ${traceCtx.level}`);
219
+ lines.push(`> Started: ${traceCtx.started.toISOString()}`);
220
+ lines.push(`> Finished: ${end.toISOString()}`);
221
+ lines.push(`> Duration: ${durationStr}`);
222
+ lines.push(`> Total tokens: ${traceCtx.totalTokens}`);
223
+ lines.push(`> Result: ${summary.result}`);
224
+
225
+ // Steps
226
+ lines.push('');
227
+ lines.push('## Steps');
228
+
229
+ for (let i = 0; i < traceCtx.steps.length; i++) {
230
+ lines.push('');
231
+ lines.push(renderStep(traceCtx.steps[i], i + 1, traceCtx.level));
232
+ }
233
+
234
+ // Summary
235
+ lines.push('');
236
+ lines.push(renderSummary(summary, traceCtx.totalTokens, durationStr));
237
+
238
+ return lines.join('\n');
239
+ }
240
+
241
+ /** Maximum token budget for execution summaries. */
242
+ export const EXECUTION_SUMMARY_BUDGET = 500;
243
+
244
+ /**
245
+ * Generates a compact execution summary for context injection.
246
+ * Designed for the learnings-extractor agent which runs on tier routine (Haiku).
247
+ * Pure function — no I/O.
248
+ *
249
+ * Dependency direction: trace.js imports estimateTokens from learnings.js.
250
+ * learnings.js must NOT import from trace.js.
251
+ *
252
+ * @param {TraceContext} traceCtx - The trace context with recorded steps
253
+ * @param {TraceSummary} summary - Final summary data (result, testsPass, lintPass)
254
+ * @param {Date} [finished] - Finish time (default: new Date())
255
+ * @returns {string} Plain text summary, estimated at <= 500 tokens
256
+ */
257
+ export function generateExecutionSummary(traceCtx, summary, finished) {
258
+ const end = finished || new Date();
259
+ const durationMs = end.getTime() - traceCtx.started.getTime();
260
+ const durationStr = formatDuration(durationMs);
261
+
262
+ const headerLines = [
263
+ `Workflow: ${traceCtx.workflow}`,
264
+ `Result: ${summary.result}`,
265
+ `Steps: ${traceCtx.steps.length}`,
266
+ `Tokens: ${traceCtx.totalTokens}`,
267
+ `Duration: ${durationStr}`,
268
+ ];
269
+
270
+ const header = headerLines.join('\n') + '\n';
271
+
272
+ if (traceCtx.steps.length === 0) {
273
+ return header;
274
+ }
275
+
276
+ const stepLines = traceCtx.steps.map((step, i) =>
277
+ `${i + 1}. ${step.role}: ${step.intent} → ${step.result} (${step.tokens} tokens, ${step.duration}s)`
278
+ );
279
+
280
+ // Try full summary first
281
+ const full = header + '\n' + stepLines.join('\n');
282
+ if (estimateTokens(full) <= EXECUTION_SUMMARY_BUDGET) {
283
+ return full;
284
+ }
285
+
286
+ // Truncate step lines until within budget
287
+ const included = [];
288
+ for (let i = 0; i < stepLines.length; i++) {
289
+ const omissionNotice = `\n... (${traceCtx.steps.length - (i + 1)} steps omitted)`;
290
+ const candidate = header + '\n' + [...included, stepLines[i]].join('\n') + omissionNotice;
291
+ if (estimateTokens(candidate) > EXECUTION_SUMMARY_BUDGET) {
292
+ break;
293
+ }
294
+ included.push(stepLines[i]);
295
+ }
296
+
297
+ const omitted = traceCtx.steps.length - included.length;
298
+ if (included.length === 0) {
299
+ return header + `\n... (${omitted} steps omitted)`;
300
+ }
301
+ return header + '\n' + included.join('\n') + `\n... (${omitted} steps omitted)`;
302
+ }
303
+
304
+ /**
305
+ * Finalizes a trace: computes duration, renders markdown, writes to disk.
306
+ * @param {TraceContext} traceCtx - The trace context
307
+ * @param {TraceSummary} summary - Final summary data (result, testsPass, lintPass)
308
+ * @returns {{ filePath: string, duration: number, totalTokens: number, executionSummary: string }}
309
+ */
310
+ export function finalizeTrace(traceCtx, summary) {
311
+ const finished = new Date();
312
+ const durationMs = finished.getTime() - traceCtx.started.getTime();
313
+ const content = renderTrace(traceCtx, summary, finished);
314
+
315
+ // Ensure directory exists (may have been cleaned between create and finalize)
316
+ const dir = dirname(traceCtx.filePath);
317
+ mkdirSync(dir, { recursive: true });
318
+
319
+ writeFileSync(traceCtx.filePath, content, 'utf8');
320
+
321
+ const executionSummary = generateExecutionSummary(traceCtx, summary, finished);
322
+
323
+ return {
324
+ filePath: traceCtx.filePath,
325
+ duration: durationMs,
326
+ totalTokens: traceCtx.totalTokens,
327
+ executionSummary,
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Lists all trace files in the traces directory, sorted newest first.
333
+ * Parses the header of each file to extract metadata.
334
+ * Returns empty array if directory does not exist (no throw).
335
+ * @param {string} [tracesDir] - Directory to scan (default: .claude/guild/traces/)
336
+ * @returns {Array<{ filePath: string, workflow: string, date: string, level: string, result: string }>}
337
+ */
338
+ export function listTraces(tracesDir) {
339
+ const dir = tracesDir || join('.claude', 'guild', 'traces');
340
+ if (!existsSync(dir)) return [];
341
+
342
+ const files = readdirSync(dir)
343
+ .filter(f => f.startsWith('guild-trace-') && f.endsWith('.md'))
344
+ .sort()
345
+ .reverse();
346
+
347
+ return files.map(filename => {
348
+ const filePath = join(dir, filename);
349
+ const head = readFileSync(filePath, 'utf8').split('\n').slice(0, 10).join('\n');
350
+
351
+ const workflowMatch = head.match(/^# Guild Trace — (.+)$/m);
352
+ const levelMatch = head.match(/^> Level: (.+)$/m);
353
+ const resultMatch = head.match(/^> Result: (.+)$/m);
354
+ const startedMatch = head.match(/^> Started: (.+)$/m);
355
+
356
+ return {
357
+ filePath,
358
+ workflow: workflowMatch ? workflowMatch[1] : 'unknown',
359
+ date: startedMatch ? startedMatch[1] : 'unknown',
360
+ level: levelMatch ? levelMatch[1] : 'default',
361
+ result: resultMatch ? resultMatch[1] : 'unknown',
362
+ };
363
+ });
364
+ }
365
+
366
+ /**
367
+ * Deletes trace files older than maxAgeDays. If maxAgeDays is 0, deletes ALL traces.
368
+ * Returns count of deleted files. Returns 0 if directory does not exist (no throw).
369
+ * @param {number} maxAgeDays - Traces older than this many days are deleted (0 = delete all)
370
+ * @param {string} [tracesDir] - Directory to clean (default: .claude/guild/traces/)
371
+ * @returns {number} Count of deleted files
372
+ */
373
+ export function cleanTraces(maxAgeDays, tracesDir) {
374
+ const dir = tracesDir || join('.claude', 'guild', 'traces');
375
+ if (!existsSync(dir)) return 0;
376
+
377
+ const files = readdirSync(dir)
378
+ .filter(f => f.startsWith('guild-trace-') && f.endsWith('.md'));
379
+
380
+ const now = Date.now();
381
+ const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
382
+ let deleted = 0;
383
+
384
+ for (const filename of files) {
385
+ const filePath = join(dir, filename);
386
+ if (maxAgeDays === 0) {
387
+ unlinkSync(filePath);
388
+ deleted++;
389
+ } else {
390
+ const stat = statSync(filePath);
391
+ const ageMs = now - stat.mtimeMs;
392
+ if (ageMs > maxAgeMs) {
393
+ unlinkSync(filePath);
394
+ deleted++;
395
+ }
396
+ }
397
+ }
398
+
399
+ return deleted;
400
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * version.js — Version detection and computation utilities for Guild CLI.
3
+ *
4
+ * Provides pure functions for parsing semver strings, detecting pre-release
5
+ * channels, computing next snapshot/beta/stable versions, and generating
6
+ * user-facing warning messages.
7
+ */
8
+
9
+ /**
10
+ * Parses a semver string into its components.
11
+ * @param {string} version - The version string from package.json
12
+ * @returns {{ base: string, channel: string, prerelease: string|null }}
13
+ */
14
+ export function parseVersion(version) {
15
+ const match = version.match(/^(\d+\.\d+\.\d+)(?:-(.+))?$/);
16
+ if (!match) {
17
+ return { base: version, channel: 'stable', prerelease: null };
18
+ }
19
+
20
+ const base = match[1];
21
+ const prerelease = match[2] || null;
22
+
23
+ let channel = 'stable';
24
+ if (prerelease?.startsWith('beta')) channel = 'beta';
25
+ else if (prerelease?.startsWith('snapshot')) channel = 'snapshot';
26
+
27
+ return { base, channel, prerelease };
28
+ }
29
+
30
+ /**
31
+ * Returns a warning string for pre-release versions, or null for stable.
32
+ * @param {string} version
33
+ * @returns {string|null}
34
+ */
35
+ export function getPreReleaseWarning(version) {
36
+ const { channel } = parseVersion(version);
37
+
38
+ if (channel === 'snapshot') {
39
+ return 'Snapshot build: for development/testing only';
40
+ }
41
+ if (channel === 'beta') {
42
+ return 'Pre-release: may contain experimental changes';
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Computes the next snapshot version.
49
+ * @param {string} currentVersion - e.g. "0.3.1" or "0.4.0-snapshot.20260225.1"
50
+ * @param {Date} [now=new Date()] - injectable for testing
51
+ * @returns {string} - e.g. "0.3.1-snapshot.20260225.1"
52
+ */
53
+ export function computeSnapshotVersion(currentVersion, now = new Date()) {
54
+ const baseVersion = currentVersion.replace(/-.*$/, '');
55
+ const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '');
56
+
57
+ let buildNum = 1;
58
+ const currentPrerelease = currentVersion.match(/-snapshot\.(\d+)\.(\d+)$/);
59
+ if (currentPrerelease && currentPrerelease[1] === dateStr) {
60
+ buildNum = parseInt(currentPrerelease[2], 10) + 1;
61
+ }
62
+
63
+ return `${baseVersion}-snapshot.${dateStr}.${buildNum}`;
64
+ }
65
+
66
+ /**
67
+ * Computes the next beta version.
68
+ * @param {string} currentVersion - e.g. "0.4.0" or "0.4.0-beta.2"
69
+ * @returns {string} - e.g. "0.4.0-beta.1" or "0.4.0-beta.3"
70
+ */
71
+ export function computeBetaVersion(currentVersion) {
72
+ const baseVersion = currentVersion.replace(/-.*$/, '');
73
+
74
+ let betaNum = 1;
75
+ const currentBeta = currentVersion.match(/-beta\.(\d+)$/);
76
+ if (currentBeta) {
77
+ betaNum = parseInt(currentBeta[1], 10) + 1;
78
+ }
79
+
80
+ return `${baseVersion}-beta.${betaNum}`;
81
+ }
82
+
83
+ /**
84
+ * Computes the stable version by stripping any prerelease suffix.
85
+ * @param {string} currentVersion - e.g. "0.4.0-beta.3"
86
+ * @returns {string} - e.g. "0.4.0"
87
+ */
88
+ export function computeStableVersion(currentVersion) {
89
+ return currentVersion.replace(/-.*$/, '');
90
+ }