guild-agents 1.4.0 → 2.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 (58) hide show
  1. package/README.md +69 -68
  2. package/bin/guild.js +4 -85
  3. package/package.json +2 -2
  4. package/src/commands/doctor.js +11 -33
  5. package/src/commands/init.js +1 -1
  6. package/src/templates/agents/advisor.md +0 -1
  7. package/src/templates/agents/developer.md +2 -2
  8. package/src/templates/agents/qa.md +1 -1
  9. package/src/templates/agents/tech-lead.md +2 -2
  10. package/src/templates/skills/build-feature/SKILL.md +59 -117
  11. package/src/templates/skills/build-feature/evals/evals.json +3 -4
  12. package/src/templates/skills/council/SKILL.md +6 -16
  13. package/src/templates/skills/council/evals/evals.json +3 -13
  14. package/src/templates/skills/create-pr/SKILL.md +2 -5
  15. package/src/templates/skills/guild-specialize/SKILL.md +2 -9
  16. package/src/templates/skills/qa-cycle/SKILL.md +0 -7
  17. package/src/templates/skills/re-specialize/SKILL.md +0 -3
  18. package/src/templates/skills/session-end/SKILL.md +77 -30
  19. package/src/templates/skills/session-start/SKILL.md +51 -20
  20. package/src/utils/eval-runner.js +2 -8
  21. package/src/utils/generators.js +3 -4
  22. package/src/utils/skill-parser.js +83 -0
  23. package/src/utils/trigger-runner.js +1 -1
  24. package/src/commands/logs.js +0 -63
  25. package/src/commands/reset-learnings.js +0 -44
  26. package/src/commands/run.js +0 -105
  27. package/src/commands/stats.js +0 -147
  28. package/src/templates/agents/db-migration.md +0 -51
  29. package/src/templates/agents/learnings-extractor.md +0 -49
  30. package/src/templates/agents/platform-expert.md +0 -92
  31. package/src/templates/agents/product-owner.md +0 -52
  32. package/src/templates/skills/dev-flow/SKILL.md +0 -83
  33. package/src/templates/skills/dev-flow/evals/evals.json +0 -36
  34. package/src/templates/skills/dev-flow/evals/triggers.json +0 -16
  35. package/src/templates/skills/new-feature/SKILL.md +0 -119
  36. package/src/templates/skills/new-feature/evals/evals.json +0 -41
  37. package/src/templates/skills/new-feature/evals/triggers.json +0 -16
  38. package/src/templates/skills/review/SKILL.md +0 -97
  39. package/src/templates/skills/review/evals/evals.json +0 -43
  40. package/src/templates/skills/review/evals/triggers.json +0 -16
  41. package/src/templates/skills/status/SKILL.md +0 -100
  42. package/src/templates/skills/status/evals/evals.json +0 -40
  43. package/src/templates/skills/status/evals/triggers.json +0 -16
  44. package/src/templates/skills/verify/SKILL.md +0 -114
  45. package/src/templates/skills/verify/evals/triggers.json +0 -16
  46. package/src/utils/accounting.js +0 -139
  47. package/src/utils/dispatch-protocol.js +0 -74
  48. package/src/utils/dispatch.js +0 -172
  49. package/src/utils/executor.js +0 -183
  50. package/src/utils/learnings-io.js +0 -76
  51. package/src/utils/learnings.js +0 -204
  52. package/src/utils/orchestrator-io.js +0 -356
  53. package/src/utils/orchestrator.js +0 -590
  54. package/src/utils/pricing.js +0 -28
  55. package/src/utils/providers/claude-code.js +0 -43
  56. package/src/utils/skill-loader.js +0 -83
  57. package/src/utils/trace.js +0 -400
  58. package/src/utils/workflow-parser.js +0 -225
@@ -1,172 +0,0 @@
1
- /**
2
- * dispatch.js — Validation and resolution utilities for the Guild dispatch protocol.
3
- *
4
- * Provides functions to validate workflow step configurations, resolve agent
5
- * metadata from frontmatter, determine effective model tiers, and resolve
6
- * tiers to concrete model IDs.
7
- */
8
-
9
- import { existsSync, readFileSync } from 'fs';
10
- import { join } from 'path';
11
- import { parseFrontmatter } from './files.js';
12
- import { parseSkill } from './workflow-parser.js';
13
- import {
14
- MODEL_TIERS,
15
- FAILURE_STRATEGIES,
16
- DEFAULT_AGENT_TIERS,
17
- DEFAULT_MODEL_PROFILES,
18
- FALLBACK_CHAIN,
19
- } from './dispatch-protocol.js';
20
-
21
- /**
22
- * Validates a workflow step configuration object.
23
- * @param {object} config - Step config with role, intent, model-tier, etc.
24
- * @returns {string[]} Array of error messages (empty means valid)
25
- */
26
- export function validateStepConfig(config) {
27
- const errors = [];
28
-
29
- if (!config.role) {
30
- errors.push('Missing required field: role');
31
- }
32
-
33
- if (!config.intent) {
34
- errors.push('Missing required field: intent');
35
- }
36
-
37
- if (config['model-tier'] && !MODEL_TIERS.includes(config['model-tier'])) {
38
- errors.push(`Invalid model-tier: "${config['model-tier']}". Must be one of: ${MODEL_TIERS.join(', ')}`);
39
- }
40
-
41
- if (config['on-failure']) {
42
- const isGoto = config['on-failure'].startsWith('goto:');
43
- if (!FAILURE_STRATEGIES.includes(config['on-failure']) && !isGoto) {
44
- errors.push(`Invalid on-failure: "${config['on-failure']}". Must be one of: ${FAILURE_STRATEGIES.join(', ')}, or goto:<step-id>`);
45
- }
46
- }
47
-
48
- if (config['max-retries'] !== undefined) {
49
- const val = config['max-retries'];
50
- if (!Number.isInteger(val) || val < 1) {
51
- errors.push(`Invalid max-retries: ${val}. Must be a positive integer`);
52
- }
53
- }
54
-
55
- return errors;
56
- }
57
-
58
- /**
59
- * Reads agent metadata from the agent's markdown frontmatter.
60
- * @param {string} role - Agent role name (e.g., 'tech-lead')
61
- * @param {string} [projectRoot=process.cwd()] - Project root directory
62
- * @returns {{ name: string, role: string, defaultTier: string|undefined, [key: string]: unknown } | null}
63
- */
64
- export function resolveAgentMetadata(role, projectRoot = process.cwd()) {
65
- const agentPath = join(projectRoot, '.claude', 'agents', `${role}.md`);
66
-
67
- if (!existsSync(agentPath)) {
68
- return null;
69
- }
70
-
71
- const content = readFileSync(agentPath, 'utf8');
72
- const frontmatter = parseFrontmatter(content);
73
-
74
- return {
75
- ...frontmatter,
76
- role,
77
- defaultTier: frontmatter['default-tier'] || undefined,
78
- };
79
- }
80
-
81
- /**
82
- * Resolves the effective model tier for a workflow step using the precedence chain:
83
- * 1. stepConfig['model-tier'] (explicit in workflow step)
84
- * 2. agentMetadata.defaultTier (from agent frontmatter)
85
- * 3. DEFAULT_AGENT_TIERS[role] (hardcoded defaults)
86
- * 4. 'execution' (ultimate fallback)
87
- *
88
- * @param {object} stepConfig - Workflow step with role and optional model-tier
89
- * @param {object|null} [agentMetadata=null] - Agent metadata from resolveAgentMetadata
90
- * @returns {string} One of MODEL_TIERS values
91
- */
92
- export function resolveEffectiveTier(stepConfig, agentMetadata = null) {
93
- if (stepConfig['model-tier'] && MODEL_TIERS.includes(stepConfig['model-tier'])) {
94
- return stepConfig['model-tier'];
95
- }
96
-
97
- if (agentMetadata?.defaultTier && MODEL_TIERS.includes(agentMetadata.defaultTier)) {
98
- return agentMetadata.defaultTier;
99
- }
100
-
101
- const defaultTier = DEFAULT_AGENT_TIERS[stepConfig.role];
102
- if (defaultTier) {
103
- return defaultTier;
104
- }
105
-
106
- return 'execution';
107
- }
108
-
109
- /**
110
- * Resolves a model tier to a concrete model ID using a profile.
111
- * Applies the fallback chain if the tier is not found in the profile.
112
- *
113
- * @param {string} tier - One of MODEL_TIERS
114
- * @param {string|Record<string, string>} profile - Profile name ('max', 'pro') or custom mapping
115
- * @returns {string} Concrete model ID (e.g., 'claude-opus-4-6')
116
- * @throws {Error} If no model can be resolved after exhausting the fallback chain
117
- */
118
- export function resolveModel(tier, profile) {
119
- const profileMap = typeof profile === 'string'
120
- ? DEFAULT_MODEL_PROFILES[profile]
121
- : profile;
122
-
123
- if (!profileMap) {
124
- throw new Error(`Unknown profile: "${profile}". Available: ${Object.keys(DEFAULT_MODEL_PROFILES).join(', ')}`);
125
- }
126
-
127
- let currentTier = tier;
128
- const visited = new Set();
129
-
130
- while (currentTier) {
131
- if (visited.has(currentTier)) {
132
- break;
133
- }
134
- visited.add(currentTier);
135
-
136
- if (profileMap[currentTier]) {
137
- return profileMap[currentTier];
138
- }
139
-
140
- currentTier = FALLBACK_CHAIN[currentTier];
141
- }
142
-
143
- throw new Error(`Cannot resolve model for tier "${tier}": no model available in profile after fallback chain`);
144
- }
145
-
146
- /**
147
- * Extracts dispatch configuration from skill markdown content.
148
- * Precedence: workflow steps (frontmatter) > null (legacy prose).
149
- *
150
- * Dependency direction: dispatch.js imports from workflow-parser.js.
151
- * Do not reverse this — workflow-parser.js must not import from dispatch.js.
152
- *
153
- * @param {string} skillMarkdown - Raw SKILL.md content
154
- * @returns {{ source: 'workflow', steps: Array<object> } | { source: null }}
155
- * @throws {Error} If YAML frontmatter is malformed (propagated from parseSkill)
156
- */
157
- export function extractDispatchConfigs(skillMarkdown) {
158
- if (!skillMarkdown) {
159
- return { source: null };
160
- }
161
-
162
- const skill = parseSkill(skillMarkdown);
163
-
164
- if (skill.workflow && Array.isArray(skill.workflow.steps) && skill.workflow.steps.length > 0) {
165
- return {
166
- source: 'workflow',
167
- steps: skill.workflow.steps,
168
- };
169
- }
170
-
171
- return { source: null };
172
- }
@@ -1,183 +0,0 @@
1
- /**
2
- * executor.js — Execution loop for Guild workflow plans.
3
- *
4
- * Drives a plan to completion by iterating through steps, dispatching
5
- * agent steps to a provider function and system steps to local commands.
6
- * Sequential execution only (v1.1); parallel groups deferred to v1.2.
7
- */
8
-
9
- import { execFile } from 'child_process';
10
- import {
11
- advanceStep,
12
- getNextSteps,
13
- isPlanComplete,
14
- } from './orchestrator.js';
15
- import { buildStepContext, recordStepTrace } from './orchestrator-io.js';
16
-
17
- const SYSTEM_STEP_TIMEOUT = 120_000; // 2 minutes
18
-
19
- /**
20
- * Promisified execFile wrapper that always resolves (never rejects).
21
- *
22
- * @param {string} cmd - Command to execute
23
- * @param {string[]} args - Arguments
24
- * @param {object} opts - execFile options
25
- * @returns {Promise<{ stdout: string, stderr: string, exitCode: number }>}
26
- */
27
- function execFileAsync(cmd, args, opts) {
28
- return new Promise((resolve) => {
29
- execFile(cmd, args, opts, (error, stdout, stderr) => {
30
- resolve({
31
- stdout: stdout || '',
32
- stderr: stderr || (error && error.message) || '',
33
- exitCode: error ? (typeof error.code === 'number' ? error.code : 1) : 0,
34
- });
35
- });
36
- });
37
- }
38
-
39
- /**
40
- * Executes a system step by running its commands or handling delegation.
41
- *
42
- * @param {object} step - System step definition
43
- * @param {object} [options={}] - Options
44
- * @param {string} [options.projectRoot=process.cwd()] - Working directory for commands
45
- * @returns {Promise<{ status: string, output: string }>}
46
- */
47
- async function executeSystemStep(step, options = {}) {
48
- const { projectRoot = process.cwd() } = options;
49
-
50
- if (step.commands && step.commands.length > 0) {
51
- const outputs = [];
52
- for (const cmd of step.commands) {
53
- // v1.1: simple split — commands with quoted args or shell features
54
- // are not supported. Use simple commands like "npm test".
55
- const [bin, ...args] = cmd.split(' ');
56
- const result = await execFileAsync(bin, args, {
57
- cwd: projectRoot,
58
- timeout: SYSTEM_STEP_TIMEOUT,
59
- });
60
-
61
- if (result.exitCode !== 0) {
62
- return {
63
- status: 'failed',
64
- output: result.stderr || result.stdout || `Command failed: ${cmd}`,
65
- };
66
- }
67
- outputs.push(result.stdout);
68
- }
69
- return { status: 'passed', output: outputs.join('\n') };
70
- }
71
-
72
- if (step.delegatesTo) {
73
- return { status: 'passed', output: `Delegation to "${step.delegatesTo}" skipped (v1.1)` };
74
- }
75
-
76
- return { status: 'passed', output: 'System step completed' };
77
- }
78
-
79
- /**
80
- * Finds a step definition by ID across all groups in a plan.
81
- *
82
- * @param {object} plan - Execution plan
83
- * @param {string} stepId - Step ID to find
84
- * @returns {object|null}
85
- */
86
- function findStepInPlan(plan, stepId) {
87
- for (const group of plan.groups) {
88
- for (const step of group.steps) {
89
- if (step.id === stepId) return step;
90
- }
91
- }
92
- return null;
93
- }
94
-
95
- /**
96
- * Executes a workflow plan to completion.
97
- *
98
- * Drives the orchestrator state machine by repeatedly calling getNextSteps,
99
- * dispatching each step (agent via provider, system via local commands),
100
- * and advancing the plan with the result.
101
- *
102
- * @param {import('./orchestrator.js').ExecutionPlan} plan - Initial execution plan
103
- * @param {Object.<string, import('./orchestrator-io.js').StepDispatchInfo>} dispatchInfoMap - Dispatch info per step
104
- * @param {object} [options={}] - Options
105
- * @param {Function} options.provider - Agent step provider: (step, dispatch, context) => { status, output, outcome?, error?, tokens? }
106
- * @param {object} [options.trace] - Trace context for recording step executions
107
- * @param {string} [options.projectRoot] - Working directory for system commands
108
- * @param {string} [options.skillBody=''] - Skill body text for context building
109
- * @param {Function} [options.onStepStart] - Callback before each step: (step, dispatch) => void
110
- * @param {Function} [options.onStepEnd] - Callback after each step: (step, result) => void
111
- * @returns {Promise<import('./orchestrator.js').ExecutionPlan>} Final plan state
112
- */
113
- export async function execute(plan, dispatchInfoMap, options = {}) {
114
- const {
115
- provider,
116
- trace,
117
- projectRoot,
118
- skillBody = '',
119
- onStepStart,
120
- onStepEnd,
121
- } = options;
122
-
123
- let currentPlan = plan;
124
- let emptyIterations = 0;
125
- const MAX_EMPTY_ITERATIONS = 100;
126
-
127
- while (!isPlanComplete(currentPlan)) {
128
- const { steps, skipped } = getNextSteps(currentPlan);
129
-
130
- // Advance skipped steps first
131
- for (const stepId of skipped) {
132
- currentPlan = advanceStep(currentPlan, stepId, { status: 'skipped' });
133
-
134
- if (trace) {
135
- const step = findStepInPlan(currentPlan, stepId);
136
- const dispatch = dispatchInfoMap[stepId] || {};
137
- if (step) {
138
- recordStepTrace(trace, step, currentPlan.stepStates[stepId], dispatch);
139
- }
140
- }
141
- }
142
-
143
- // If no executable steps remain, check completion again
144
- if (steps.length === 0) {
145
- if (isPlanComplete(currentPlan)) break;
146
- if (++emptyIterations > MAX_EMPTY_ITERATIONS) {
147
- currentPlan = { ...currentPlan, status: 'aborted' };
148
- break;
149
- }
150
- continue;
151
- }
152
- emptyIterations = 0;
153
-
154
- // v1.1: sequential execution — one step at a time
155
- const step = steps[0];
156
- const dispatch = dispatchInfoMap[step.id] || {};
157
-
158
- onStepStart?.(step, dispatch);
159
-
160
- let result;
161
- if (step.role === 'system') {
162
- result = await executeSystemStep(step, { projectRoot });
163
- } else {
164
- const context = buildStepContext(step, currentPlan, { skillBody });
165
- result = await provider(step, dispatch, context);
166
- }
167
-
168
- currentPlan = advanceStep(currentPlan, step.id, result);
169
-
170
- if (trace) {
171
- recordStepTrace(trace, step, currentPlan.stepStates[step.id], dispatch);
172
- }
173
-
174
- onStepEnd?.(step, result);
175
- }
176
-
177
- // Mark plan as completed if all steps reached terminal state and plan is still running
178
- if (currentPlan.status === 'running' && isPlanComplete(currentPlan)) {
179
- currentPlan = { ...currentPlan, status: 'completed' };
180
- }
181
-
182
- return currentPlan;
183
- }
@@ -1,76 +0,0 @@
1
- /**
2
- * learnings-io.js — File I/O operations for compound learnings.
3
- *
4
- * Read, write, init, exists, and delete operations for .claude/guild/learnings.md.
5
- * Separated from the pure functions in learnings.js following the trace.js pattern.
6
- *
7
- * NOTE: File locking for concurrent access is intentionally omitted.
8
- * Concurrent workflow execution is a v2 concern — current Guild workflows
9
- * are single-session and sequential.
10
- */
11
-
12
- import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from 'fs';
13
- import { dirname } from 'path';
14
- import { GUILD_LEARNINGS_PATH, renderEmptyLearnings } from './learnings.js';
15
-
16
- /**
17
- * Reads the learnings file from disk.
18
- * Returns the raw content as a string, or null if the file does not exist.
19
- * @param {string} [filePath] - Override path (default: GUILD_LEARNINGS_PATH)
20
- * @returns {string | null}
21
- */
22
- export function readLearnings(filePath) {
23
- const target = filePath || GUILD_LEARNINGS_PATH;
24
- if (!existsSync(target)) return null;
25
- return readFileSync(target, 'utf8');
26
- }
27
-
28
- /**
29
- * Writes content to the learnings file.
30
- * Creates parent directories if needed.
31
- * @param {string} content - Markdown content to write
32
- * @param {string} [filePath] - Override path (default: GUILD_LEARNINGS_PATH)
33
- */
34
- export function writeLearnings(content, filePath) {
35
- const target = filePath || GUILD_LEARNINGS_PATH;
36
- mkdirSync(dirname(target), { recursive: true });
37
- writeFileSync(target, content, 'utf8');
38
- }
39
-
40
- /**
41
- * Checks whether the learnings file exists on disk.
42
- * @param {string} [filePath] - Override path (default: GUILD_LEARNINGS_PATH)
43
- * @returns {boolean}
44
- */
45
- export function learningsExist(filePath) {
46
- const target = filePath || GUILD_LEARNINGS_PATH;
47
- return existsSync(target);
48
- }
49
-
50
- /**
51
- * Initializes the learnings file with empty scaffold content.
52
- * No-ops if the file already exists.
53
- * @param {string} [projectName='Project'] - Project name for the header
54
- * @param {string} [filePath] - Override path (default: GUILD_LEARNINGS_PATH)
55
- * @returns {{ created: boolean }}
56
- */
57
- export function initLearnings(projectName = 'Project', filePath) {
58
- const target = filePath || GUILD_LEARNINGS_PATH;
59
- if (existsSync(target)) return { created: false };
60
- mkdirSync(dirname(target), { recursive: true });
61
- writeFileSync(target, renderEmptyLearnings(projectName), 'utf8');
62
- return { created: true };
63
- }
64
-
65
- /**
66
- * Deletes the learnings file from disk.
67
- * Returns { deleted: false } if the file does not exist (no throw).
68
- * @param {string} [filePath] - Override path (default: GUILD_LEARNINGS_PATH)
69
- * @returns {{ deleted: boolean }}
70
- */
71
- export function deleteLearnings(filePath) {
72
- const target = filePath || GUILD_LEARNINGS_PATH;
73
- if (!existsSync(target)) return { deleted: false };
74
- unlinkSync(target);
75
- return { deleted: true };
76
- }
@@ -1,204 +0,0 @@
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
- }