@yemi33/minions 0.1.59 → 0.1.61
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/CHANGELOG.md +22 -0
- package/engine/cleanup.js +393 -0
- package/engine/cooldown.js +117 -0
- package/engine/dispatch.js +207 -0
- package/engine/playbook.js +479 -0
- package/engine/routing.js +163 -0
- package/engine/timeout.js +280 -0
- package/engine.js +41 -1398
- package/package.json +1 -1
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/playbook.js — Playbook rendering, system prompt building, agent context,
|
|
3
|
+
* task context resolution, and repo-host helpers.
|
|
4
|
+
* Extracted from engine.js.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const shared = require('./shared');
|
|
10
|
+
const queries = require('./queries');
|
|
11
|
+
|
|
12
|
+
const { safeJson, safeRead, getProjects } = shared;
|
|
13
|
+
const { getConfig, getDispatch, getNotes, getAgentCharter, getPrs, AGENTS_DIR } = queries;
|
|
14
|
+
|
|
15
|
+
const MINIONS_DIR = path.resolve(__dirname, '..');
|
|
16
|
+
const PLAYBOOKS_DIR = path.join(MINIONS_DIR, 'playbooks');
|
|
17
|
+
|
|
18
|
+
// Lazy require to avoid circular dependency with engine.js
|
|
19
|
+
let _engine = null;
|
|
20
|
+
function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
|
|
21
|
+
|
|
22
|
+
// Import tempAgents from routing module
|
|
23
|
+
const { tempAgents } = require('./routing');
|
|
24
|
+
|
|
25
|
+
function dateStamp() { return new Date().toISOString().slice(0, 10); }
|
|
26
|
+
|
|
27
|
+
// ─── Repo Host Helpers ──────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function getRepoHost(project) {
|
|
30
|
+
return project?.repoHost || 'ado';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getPrCreateInstructions(project) {
|
|
34
|
+
const host = getRepoHost(project);
|
|
35
|
+
const repoId = project?.repositoryId || '';
|
|
36
|
+
if (host === 'github') {
|
|
37
|
+
const org = project?.adoOrg || '';
|
|
38
|
+
const repo = project?.repoName || '';
|
|
39
|
+
const mainBranch = project?.mainBranch || 'main';
|
|
40
|
+
return `Use \`gh pr create\` to create a pull request:\n` +
|
|
41
|
+
`- \`gh pr create --base ${mainBranch} --head <your-branch> --title "PR title" --body "PR description" --repo ${org}/${repo}\`\n` +
|
|
42
|
+
`- Always set --base to \`${mainBranch}\` (the main branch)\n` +
|
|
43
|
+
`- Always set --repo to \`${org}/${repo}\` to target the correct repository\n` +
|
|
44
|
+
`- Use --head to specify your feature branch name\n` +
|
|
45
|
+
`- Include a meaningful --title and --body describing the changes`;
|
|
46
|
+
}
|
|
47
|
+
// Default: Azure DevOps
|
|
48
|
+
return `Use \`mcp__azure-ado__repo_create_pull_request\`:\n- repositoryId: \`${repoId}\``;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getPrCommentInstructions(project) {
|
|
52
|
+
const host = getRepoHost(project);
|
|
53
|
+
const repoId = project?.repositoryId || '';
|
|
54
|
+
if (host === 'github') {
|
|
55
|
+
const org = project?.adoOrg || '';
|
|
56
|
+
const repo = project?.repoName || '';
|
|
57
|
+
return `Use \`gh pr comment\` to post a comment on the PR:\n` +
|
|
58
|
+
`- \`gh pr comment <number> --body "Your comment text" --repo ${org}/${repo}\`\n` +
|
|
59
|
+
`- Replace <number> with the PR number\n` +
|
|
60
|
+
`- Always set --repo to \`${org}/${repo}\` to target the correct repository\n` +
|
|
61
|
+
`- Use --body to provide the comment text (supports Markdown)`;
|
|
62
|
+
}
|
|
63
|
+
return `Use \`mcp__azure-ado__repo_create_pull_request_thread\`:\n- repositoryId: \`${repoId}\``;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getPrFetchInstructions(project) {
|
|
67
|
+
const host = getRepoHost(project);
|
|
68
|
+
if (host === 'github') {
|
|
69
|
+
const org = project?.adoOrg || '';
|
|
70
|
+
const repo = project?.repoName || '';
|
|
71
|
+
const mainBranch = project?.mainBranch || 'main';
|
|
72
|
+
return `Use \`gh pr view\` to fetch PR status:\n` +
|
|
73
|
+
`- \`gh pr view <number> --json number,title,state,mergeable,reviewDecision,headRefName,baseRefName,statusCheckRollup --repo ${org}/${repo}\`\n` +
|
|
74
|
+
`- This returns JSON with PR state, mergeability, review decision, and check statuses\n` +
|
|
75
|
+
`- To fetch the PR branch locally:\n` +
|
|
76
|
+
` 1. \`git fetch origin <branch-name>\`\n` +
|
|
77
|
+
` 2. \`git checkout <branch-name>\`\n` +
|
|
78
|
+
`- Or use \`gh pr checkout <number> --repo ${org}/${repo}\` to fetch and checkout in one step\n` +
|
|
79
|
+
`- The base branch is \`${mainBranch}\``;
|
|
80
|
+
}
|
|
81
|
+
return `Use \`mcp__azure-ado__repo_get_pull_request_by_id\` to fetch PR status.`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getPrVoteInstructions(project) {
|
|
85
|
+
const host = getRepoHost(project);
|
|
86
|
+
const repoId = project?.repositoryId || '';
|
|
87
|
+
if (host === 'github') {
|
|
88
|
+
const org = project?.adoOrg || '';
|
|
89
|
+
const repo = project?.repoName || '';
|
|
90
|
+
return `Use \`gh pr review\` to submit a review on the PR:\n` +
|
|
91
|
+
`- Approve: \`gh pr review <number> --approve --body "Approval comment" --repo ${org}/${repo}\`\n` +
|
|
92
|
+
`- Request changes: \`gh pr review <number> --request-changes --body "What needs to change" --repo ${org}/${repo}\`\n` +
|
|
93
|
+
`- Comment only: \`gh pr review <number> --comment --body "Review comment" --repo ${org}/${repo}\`\n` +
|
|
94
|
+
`- Replace <number> with the PR number\n` +
|
|
95
|
+
`- Always set --repo to \`${org}/${repo}\` to target the correct repository\n` +
|
|
96
|
+
`- Use --body to provide a review summary (supports Markdown)`;
|
|
97
|
+
}
|
|
98
|
+
return `Use \`mcp__azure-ado__repo_update_pull_request_reviewers\`:\n- repositoryId: \`${repoId}\`\n- Set your reviewer vote on the PR (10=approve, 5=approve-with-suggestions, -10=reject)`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function getRepoHostLabel(project) {
|
|
102
|
+
const host = getRepoHost(project);
|
|
103
|
+
if (host === 'github') return 'GitHub';
|
|
104
|
+
return 'Azure DevOps';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getRepoHostToolRule(project) {
|
|
108
|
+
const host = getRepoHost(project);
|
|
109
|
+
if (host === 'github') return 'Use GitHub MCP tools or `gh` CLI for PR operations';
|
|
110
|
+
return 'Use Azure DevOps MCP tools (mcp__azure-ado__*) for PR operations — NEVER use gh CLI';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ─── Task Context Resolution ────────────────────────────────────────────────
|
|
114
|
+
// Resolves implicit references in task descriptions (e.g., "ripley's plan",
|
|
115
|
+
// "dallas's PR") to actual artifacts and injects their content.
|
|
116
|
+
|
|
117
|
+
function resolveTaskContext(item, config) {
|
|
118
|
+
const title = (item.title || '').toLowerCase();
|
|
119
|
+
const desc = (item.description || '').toLowerCase();
|
|
120
|
+
const text = title + ' ' + desc;
|
|
121
|
+
const agentNames = Object.entries(config.agents || {}).map(([id, a]) => ({
|
|
122
|
+
id,
|
|
123
|
+
name: (a.name || id).toLowerCase(),
|
|
124
|
+
}));
|
|
125
|
+
const resolved = { additionalContext: '', referencedFiles: [] };
|
|
126
|
+
const log = (...args) => engine().log(...args);
|
|
127
|
+
|
|
128
|
+
// Match agent references: "ripley's plan", "dallas's pr", "lambert's output", etc.
|
|
129
|
+
for (const agent of agentNames) {
|
|
130
|
+
const patterns = [
|
|
131
|
+
new RegExp(`${agent.name}(?:'s|s)?\\s+plan`, 'i'),
|
|
132
|
+
new RegExp(`${agent.id}(?:'s|s)?\\s+plan`, 'i'),
|
|
133
|
+
new RegExp(`plan\\s+(?:created|made|written|generated)\\s+by\\s+${agent.name}`, 'i'),
|
|
134
|
+
new RegExp(`plan\\s+(?:created|made|written|generated)\\s+by\\s+${agent.id}`, 'i'),
|
|
135
|
+
];
|
|
136
|
+
const matchesPlan = patterns.some(p => p.test(text));
|
|
137
|
+
if (matchesPlan) {
|
|
138
|
+
// Find plans created by this agent (check work items for plan tasks dispatched to this agent)
|
|
139
|
+
try {
|
|
140
|
+
const plans = fs.readdirSync(path.join(MINIONS_DIR, 'plans')).filter(f => f.endsWith('.md') || f.endsWith('.json'));
|
|
141
|
+
// Check work-items to find which plan file this agent created
|
|
142
|
+
const workItems = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
|
|
143
|
+
const agentPlanItems = workItems.filter(w =>
|
|
144
|
+
w.type === 'plan' && w.dispatched_to === agent.id && w.status === 'done' && w._planFileName
|
|
145
|
+
).sort((a, b) => (b.completedAt || '').localeCompare(a.completedAt || ''));
|
|
146
|
+
|
|
147
|
+
if (agentPlanItems.length > 0) {
|
|
148
|
+
const planFile = agentPlanItems[0]._planFileName;
|
|
149
|
+
const planPath = path.join(MINIONS_DIR, 'plans', planFile);
|
|
150
|
+
try {
|
|
151
|
+
const content = safeRead(planPath);
|
|
152
|
+
resolved.additionalContext += `\n\n## Referenced Plan: ${planFile} (created by ${agent.name})\n\n${content}`;
|
|
153
|
+
resolved.referencedFiles.push(planPath);
|
|
154
|
+
log('info', `Context resolution: found plan "${planFile}" by ${agent.name} for work item ${item.id}`);
|
|
155
|
+
} catch (e) { log('warn', 'resolve plan context: ' + e.message); }
|
|
156
|
+
} else if (plans.length > 0) {
|
|
157
|
+
// Fallback: try to find a plan file with the agent's name or ID in it
|
|
158
|
+
const match = plans.find(f => f.toLowerCase().includes(agent.id) || f.toLowerCase().includes(agent.name));
|
|
159
|
+
if (match) {
|
|
160
|
+
const planPath = path.join(MINIONS_DIR, 'plans', match);
|
|
161
|
+
try {
|
|
162
|
+
const content = safeRead(planPath);
|
|
163
|
+
resolved.additionalContext += `\n\n## Referenced Plan: ${match}\n\n${content}`;
|
|
164
|
+
resolved.referencedFiles.push(planPath);
|
|
165
|
+
log('info', `Context resolution: found plan "${match}" (name match) for work item ${item.id}`);
|
|
166
|
+
} catch (e) { log('warn', 'resolve plan fallback context: ' + e.message); }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch (e) { log('warn', 'resolve agent plan context: ' + e.message); }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Match agent output/notes references
|
|
173
|
+
const outputPatterns = [
|
|
174
|
+
new RegExp(`${agent.name}(?:'s|s)?\\s+(?:output|findings|notes|results)`, 'i'),
|
|
175
|
+
new RegExp(`(?:output|findings|notes|results)\\s+(?:from|by)\\s+${agent.name}`, 'i'),
|
|
176
|
+
];
|
|
177
|
+
if (outputPatterns.some(p => p.test(text))) {
|
|
178
|
+
// Find the agent's latest inbox notes
|
|
179
|
+
try {
|
|
180
|
+
const inboxDir = path.join(MINIONS_DIR, 'notes', 'inbox');
|
|
181
|
+
const files = fs.readdirSync(inboxDir)
|
|
182
|
+
.filter(f => f.startsWith(agent.id + '-'))
|
|
183
|
+
.sort().reverse();
|
|
184
|
+
if (files.length > 0) {
|
|
185
|
+
const content = safeRead(path.join(inboxDir, files[0]));
|
|
186
|
+
resolved.additionalContext += `\n\n## Referenced Notes by ${agent.name}: ${files[0]}\n\n${content.slice(0, 5000)}`;
|
|
187
|
+
resolved.referencedFiles.push(path.join(inboxDir, files[0]));
|
|
188
|
+
log('info', `Context resolution: found notes "${files[0]}" by ${agent.name} for work item ${item.id}`);
|
|
189
|
+
}
|
|
190
|
+
} catch (e) { log('warn', 'resolve plan context outer: ' + e.message); }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// If no specific reference was resolved but the text mentions "the plan" or "latest plan",
|
|
195
|
+
// find the most recent plan
|
|
196
|
+
if (!resolved.additionalContext && /\b(the|latest|last|recent)\s+plan\b/i.test(text)) {
|
|
197
|
+
const log = (...args) => engine().log(...args);
|
|
198
|
+
try {
|
|
199
|
+
const plans = fs.readdirSync(path.join(MINIONS_DIR, 'plans'))
|
|
200
|
+
.filter(f => f.endsWith('.md') || f.endsWith('.json'))
|
|
201
|
+
.sort().reverse();
|
|
202
|
+
if (plans.length > 0) {
|
|
203
|
+
const planPath = path.join(MINIONS_DIR, 'plans', plans[0]);
|
|
204
|
+
const content = safeRead(planPath);
|
|
205
|
+
resolved.additionalContext += `\n\n## Referenced Plan (latest): ${plans[0]}\n\n${content}`;
|
|
206
|
+
resolved.referencedFiles.push(planPath);
|
|
207
|
+
log('info', `Context resolution: using latest plan "${plans[0]}" for work item ${item.id}`);
|
|
208
|
+
}
|
|
209
|
+
} catch (e) { log('warn', 'resolve latest plan context: ' + e.message); }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return resolved;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── Playbook Renderer ──────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
function renderPlaybook(type, vars) {
|
|
218
|
+
const pbPath = path.join(PLAYBOOKS_DIR, `${type}.md`);
|
|
219
|
+
let content;
|
|
220
|
+
try { content = fs.readFileSync(pbPath, 'utf8'); } catch {
|
|
221
|
+
engine().log('warn', `Playbook not found: ${type}`);
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Inject pinned context (always visible to agents) — capped at 4KB
|
|
226
|
+
let pinnedContent = '';
|
|
227
|
+
try { pinnedContent = fs.readFileSync(path.join(MINIONS_DIR, 'pinned.md'), 'utf8'); } catch { /* optional */ }
|
|
228
|
+
if (pinnedContent) {
|
|
229
|
+
if (pinnedContent.length > 4096) pinnedContent = pinnedContent.slice(0, 4096) + '\n\n_...pinned.md truncated (read full file if needed)_';
|
|
230
|
+
content += '\n\n---\n\n## Pinned Context (CRITICAL — READ FIRST)\n\n' + pinnedContent;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Inject team notes (single injection point — not in buildAgentContext) — capped at 8KB
|
|
234
|
+
let notes = getNotes();
|
|
235
|
+
if (notes) {
|
|
236
|
+
if (notes.length > 8192) {
|
|
237
|
+
const sections = notes.split(/(?=^### )/m);
|
|
238
|
+
const recent = sections.slice(-10).join('');
|
|
239
|
+
notes = recent.length > 8192 ? recent.slice(0, 8192) + '\n\n_...notes truncated_' : recent;
|
|
240
|
+
notes += '\n\n_' + Math.max(0, sections.length - 10) + ' older entries in `notes.md` — Read if needed._';
|
|
241
|
+
}
|
|
242
|
+
content += '\n\n---\n\n## Team Notes (MUST READ)\n\n' + notes;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Inject KB guardrail
|
|
246
|
+
content += `\n\n---\n\n## Knowledge Base Rules\n\n`;
|
|
247
|
+
content += `**Never delete, move, or overwrite files in \`knowledge/\`.** The sweep (consolidation engine) is the only process that writes to \`knowledge/\`. If you think a KB file is wrong, note it in your learnings file — do not touch \`knowledge/\` directly.\n`;
|
|
248
|
+
|
|
249
|
+
// Inject learnings requirement
|
|
250
|
+
content += `\n\n---\n\n## REQUIRED: Write Learnings\n\n`;
|
|
251
|
+
content += `After completing your task, you MUST write a findings/learnings file to:\n`;
|
|
252
|
+
content += `\`${MINIONS_DIR}/notes/inbox/${vars.agent_id || 'agent'}-${dateStamp()}.md\`\n\n`;
|
|
253
|
+
content += `Include:\n`;
|
|
254
|
+
content += `- What you learned about the codebase\n`;
|
|
255
|
+
content += `- Patterns you discovered or established\n`;
|
|
256
|
+
content += `- Gotchas or warnings for future agents\n`;
|
|
257
|
+
content += `- Conventions to follow\n`;
|
|
258
|
+
content += `- **SOURCE REFERENCES for every finding** — file paths with line numbers, PR URLs, API endpoints, config keys. Format: \`(source: path/to/file.ts:42)\` or \`(source: PR-12345)\`. Without references, findings cannot be verified.\n\n`;
|
|
259
|
+
content += `### Skill Extraction (IMPORTANT)\n\n`;
|
|
260
|
+
content += `If during this task you discovered a **repeatable workflow** — a multi-step procedure, workaround, build process, or pattern that other agents should follow in similar situations — output it as a fenced skill block. The engine will automatically extract it.\n\n`;
|
|
261
|
+
content += `Format your skill as a fenced code block with the \`skill\` language tag:\n\n`;
|
|
262
|
+
content += '````\n```skill\n';
|
|
263
|
+
content += `---\nname: short-descriptive-name\ndescription: One-line description of what this skill does\nallowed-tools: Bash, Read, Edit\ntrigger: when should an agent use this\nscope: minions\nproject: any\n---\n\n# Skill Title\n\n## Steps\n1. ...\n2. ...\n\n## Notes\n...\n`;
|
|
264
|
+
content += '```\n````\n\n';
|
|
265
|
+
content += `- Set \`scope: minions\` for cross-project skills (engine writes to ~/.claude/skills/ automatically)\n`;
|
|
266
|
+
content += `- Set \`scope: project\` + \`project: <name>\` for repo-specific skills (engine queues a PR to <project>/.claude/skills/)\n`;
|
|
267
|
+
content += `- Only output a skill block if you genuinely discovered something reusable — don't force it\n`;
|
|
268
|
+
|
|
269
|
+
// Inject project-level variables from config
|
|
270
|
+
const config = getConfig();
|
|
271
|
+
const projects = getProjects(config);
|
|
272
|
+
// Find the specific project being dispatched (match by repo_id or repo_name from vars)
|
|
273
|
+
const dispatchProject = (vars.repo_id && projects.find(p => p.repositoryId === vars.repo_id))
|
|
274
|
+
|| (vars.repo_name && projects.find(p => p.repoName === vars.repo_name))
|
|
275
|
+
|| projects[0] || {};
|
|
276
|
+
const projectVars = {
|
|
277
|
+
project_name: dispatchProject.name || 'Unknown Project',
|
|
278
|
+
ado_org: dispatchProject.adoOrg || 'Unknown',
|
|
279
|
+
ado_project: dispatchProject.adoProject || 'Unknown',
|
|
280
|
+
repo_name: dispatchProject.repoName || 'Unknown',
|
|
281
|
+
pr_create_instructions: getPrCreateInstructions(dispatchProject),
|
|
282
|
+
pr_comment_instructions: getPrCommentInstructions(dispatchProject),
|
|
283
|
+
pr_fetch_instructions: getPrFetchInstructions(dispatchProject),
|
|
284
|
+
pr_vote_instructions: getPrVoteInstructions(dispatchProject),
|
|
285
|
+
repo_host_label: getRepoHostLabel(dispatchProject),
|
|
286
|
+
};
|
|
287
|
+
const allVars = { ...projectVars, ...vars };
|
|
288
|
+
|
|
289
|
+
// Substitute variables
|
|
290
|
+
for (const [key, val] of Object.entries(allVars)) {
|
|
291
|
+
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(val));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return content;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ─── System Prompt Builder ──────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
// Lean system prompt: agent identity + rules only (~2-4KB, never grows)
|
|
300
|
+
function buildSystemPrompt(agentId, config, project) {
|
|
301
|
+
const agent = config.agents[agentId] || tempAgents.get(agentId) || { name: agentId, role: 'Temporary Agent', skills: [] };
|
|
302
|
+
const charter = getAgentCharter(agentId); // returns '' for temp agents (no charter file)
|
|
303
|
+
project = project || getProjects(config)[0] || {};
|
|
304
|
+
|
|
305
|
+
let prompt = '';
|
|
306
|
+
|
|
307
|
+
// Agent identity
|
|
308
|
+
prompt += `# You are ${agent.name} (${agent.role})\n\n`;
|
|
309
|
+
prompt += `Agent ID: ${agentId}\n`;
|
|
310
|
+
prompt += `Skills: ${(agent.skills || []).join(', ')}\n\n`;
|
|
311
|
+
|
|
312
|
+
// Charter (detailed instructions — typically 1-2KB)
|
|
313
|
+
if (charter) {
|
|
314
|
+
prompt += `## Your Charter\n\n${charter}\n\n`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Project context (fixed size)
|
|
318
|
+
prompt += `## Project: ${project.name || 'Unknown Project'}\n\n`;
|
|
319
|
+
prompt += `- Repo: ${project.repoName || 'Unknown'} (${project.adoOrg || 'Unknown'}/${project.adoProject || 'Unknown'})\n`;
|
|
320
|
+
prompt += `- Repo ID: ${project.repositoryId || ''}\n`;
|
|
321
|
+
prompt += `- Repo host: ${getRepoHostLabel(project)}\n`;
|
|
322
|
+
prompt += `- Main branch: ${project.mainBranch || 'main'}\n\n`;
|
|
323
|
+
|
|
324
|
+
// Critical rules (fixed size)
|
|
325
|
+
prompt += `## Critical Rules\n\n`;
|
|
326
|
+
prompt += `1. Use git worktrees — NEVER checkout on main working tree\n`;
|
|
327
|
+
prompt += `2. ${getRepoHostToolRule(project)}\n`;
|
|
328
|
+
prompt += `3. Follow the project conventions in CLAUDE.md if present\n`;
|
|
329
|
+
prompt += `4. Write learnings to: ${MINIONS_DIR}/notes/inbox/${agentId}-${dateStamp()}.md\n`;
|
|
330
|
+
prompt += `5. Agent status is managed by the engine via dispatch.json — agents do not need to track their own status\n`;
|
|
331
|
+
prompt += `6. If you discover a repeatable workflow, output it as a \\\`\\\`\\\`skill fenced block — the engine auto-extracts it to ~/.claude/skills/\n\n`;
|
|
332
|
+
|
|
333
|
+
return prompt;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Bulk context: history, notes, conventions, skills — prepended to user/task prompt.
|
|
337
|
+
// This is the content that grows over time and would bloat the system prompt.
|
|
338
|
+
function buildAgentContext(agentId, config, project) {
|
|
339
|
+
project = project || getProjects(config)[0] || {};
|
|
340
|
+
let context = '';
|
|
341
|
+
const log = (...args) => engine().log(...args);
|
|
342
|
+
|
|
343
|
+
// Agent history — last 5 tasks only (keeps it relevant, avoids 37KB dumps)
|
|
344
|
+
const history = safeRead(path.join(AGENTS_DIR, agentId, 'history.md'));
|
|
345
|
+
if (history && history.trim() !== '# Agent History') {
|
|
346
|
+
const entries = history.split(/(?=^### )/m);
|
|
347
|
+
const header = entries[0].startsWith('#') && !entries[0].startsWith('### ') ? entries.shift() : '';
|
|
348
|
+
const recent = entries.slice(-5);
|
|
349
|
+
const trimmed = (header ? header + '\n' : '') + recent.join('');
|
|
350
|
+
context += `## Your Recent History (last 5 tasks)\n\n${trimmed}\n\n`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Project conventions (from CLAUDE.md) — always relevant for code quality
|
|
354
|
+
if (project.localPath) {
|
|
355
|
+
const claudeMd = safeRead(path.join(project.localPath, 'CLAUDE.md'));
|
|
356
|
+
if (claudeMd && claudeMd.trim()) {
|
|
357
|
+
const truncated = claudeMd.length > 8192 ? claudeMd.slice(0, 8192) + '\n\n...(truncated)' : claudeMd;
|
|
358
|
+
context += `## Project Conventions (from CLAUDE.md)\n\n${truncated}\n\n`;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// KB and skills: NOT injected — agents can Glob/Read when needed
|
|
363
|
+
// This saves ~27KB per dispatch. Reference note so agents know they exist:
|
|
364
|
+
context += `## Reference Files\n\nKnowledge base entries are in \`knowledge/{category}/*.md\`. Skills are in \`skills/*.md\` and \`.claude/skills/\`. Use Glob/Read to browse when relevant.\n\n`;
|
|
365
|
+
|
|
366
|
+
// Minions awareness: what's in flight, who's doing what
|
|
367
|
+
const dispatch = getDispatch();
|
|
368
|
+
const activeItems = (dispatch.active || []).map(d =>
|
|
369
|
+
`- **${d.agent}**: ${d.type} — ${(d.task || '').slice(0, 100)}${d.agent === agentId ? ' ← (you)' : ''}`
|
|
370
|
+
);
|
|
371
|
+
if (activeItems.length > 0) {
|
|
372
|
+
context += `## Active Agents\n\n${activeItems.join('\n')}\n\n`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Recent completions (last 5, not 10)
|
|
376
|
+
const recentCompleted = (dispatch.completed || []).slice(-5).reverse().map(d =>
|
|
377
|
+
`- **${d.agent}** ${d.result === 'success' ? 'completed' : 'failed'}: ${(d.task || '').slice(0, 80)}${d.resultSummary ? ' — ' + d.resultSummary.slice(0, 100) : ''}`
|
|
378
|
+
);
|
|
379
|
+
if (recentCompleted.length > 0) {
|
|
380
|
+
context += `## Recently Completed\n\n${recentCompleted.join('\n')}\n\n`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Active + linked PRs across projects — coordination awareness
|
|
384
|
+
const projects = getProjects(config);
|
|
385
|
+
const allPrs = [];
|
|
386
|
+
for (const p of projects) {
|
|
387
|
+
const prs = getPrs(p).filter(pr => pr.status === 'active' || pr.status === 'linked');
|
|
388
|
+
for (const pr of prs) allPrs.push({ ...pr, _project: p.name });
|
|
389
|
+
}
|
|
390
|
+
// Also check central pull-requests.json
|
|
391
|
+
try {
|
|
392
|
+
const centralPrs = safeJson(path.join(MINIONS_DIR, 'pull-requests.json')) || [];
|
|
393
|
+
for (const pr of centralPrs.filter(pr => pr.status === 'active' || pr.status === 'linked')) {
|
|
394
|
+
if (!allPrs.some(p => p.id === pr.id)) allPrs.push({ ...pr, _project: 'central' });
|
|
395
|
+
}
|
|
396
|
+
} catch (e) { log('warn', 'read central pull-requests: ' + e.message); }
|
|
397
|
+
if (allPrs.length > 0) {
|
|
398
|
+
const prLines = allPrs.map(pr =>
|
|
399
|
+
`- **${pr.id}** (${pr._project}): ${(pr.title || '').slice(0, 80)} [${pr.status === 'linked' ? 'context-only' : (pr.reviewStatus || 'pending')}${pr.buildStatus === 'failing' ? ', BUILD FAILING' : ''}]${pr.branch ? ' branch: `' + pr.branch + '`' : ''}${pr._context ? ' — ' + pr._context.slice(0, 100) : ''}`
|
|
400
|
+
);
|
|
401
|
+
context += `## Active Pull Requests\n\n${prLines.join('\n')}\n\n`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Pending work items
|
|
405
|
+
const pendingItems = (dispatch.pending || []).slice(0, 10).map(d =>
|
|
406
|
+
`- ${d.type}: ${(d.task || '').slice(0, 80)}`
|
|
407
|
+
);
|
|
408
|
+
if (pendingItems.length > 0) {
|
|
409
|
+
context += `## Pending Work Queue (${(dispatch.pending || []).length} items)\n\n${pendingItems.join('\n')}${(dispatch.pending || []).length > 10 ? '\n- ... and ' + ((dispatch.pending || []).length - 10) + ' more' : ''}\n\n`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Team notes injected via renderPlaybook (single injection point, with truncation)
|
|
413
|
+
// Not duplicated here to avoid double-injection and token waste
|
|
414
|
+
|
|
415
|
+
return context;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ─── Work Discovery Helpers ──────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
function buildBaseVars(agentId, config, project) {
|
|
421
|
+
return {
|
|
422
|
+
agent_id: agentId,
|
|
423
|
+
agent_name: config.agents[agentId]?.name || agentId,
|
|
424
|
+
agent_role: config.agents[agentId]?.role || 'Agent',
|
|
425
|
+
team_root: MINIONS_DIR,
|
|
426
|
+
repo_id: project?.repositoryId || '',
|
|
427
|
+
project_name: project?.name || 'Unknown Project',
|
|
428
|
+
ado_org: project?.adoOrg || 'Unknown',
|
|
429
|
+
ado_project: project?.adoProject || 'Unknown',
|
|
430
|
+
repo_name: project?.repoName || 'Unknown',
|
|
431
|
+
main_branch: project?.mainBranch || 'main',
|
|
432
|
+
date: dateStamp(),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function selectPlaybook(workType, item) {
|
|
437
|
+
if (item?.branchStrategy === 'shared-branch' && (workType === 'implement' || workType === 'implement:large')) {
|
|
438
|
+
return 'implement-shared';
|
|
439
|
+
}
|
|
440
|
+
if (workType === 'review' && !item?._pr && !item?.pr_id) {
|
|
441
|
+
return 'work-item';
|
|
442
|
+
}
|
|
443
|
+
const typeSpecificPlaybooks = ['explore', 'review', 'test', 'plan-to-prd', 'plan', 'ask', 'verify', 'decompose'];
|
|
444
|
+
return typeSpecificPlaybooks.includes(workType) ? workType : 'work-item';
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function buildPrDispatch(agentId, config, project, pr, type, extraVars, taskLabel, meta) {
|
|
448
|
+
const vars = { ...buildBaseVars(agentId, config, project), ...extraVars };
|
|
449
|
+
const playbookName = type === 'test' ? 'build-and-test' : (type === 'review' ? 'review' : 'fix');
|
|
450
|
+
const prompt = renderPlaybook(playbookName, vars);
|
|
451
|
+
if (!prompt) return null;
|
|
452
|
+
return {
|
|
453
|
+
type,
|
|
454
|
+
agent: agentId,
|
|
455
|
+
agentName: config.agents[agentId]?.name,
|
|
456
|
+
agentRole: config.agents[agentId]?.role,
|
|
457
|
+
task: `[${project?.name || 'project'}] ${taskLabel}`,
|
|
458
|
+
prompt,
|
|
459
|
+
meta,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
module.exports = {
|
|
464
|
+
renderPlaybook,
|
|
465
|
+
buildSystemPrompt,
|
|
466
|
+
buildAgentContext,
|
|
467
|
+
selectPlaybook,
|
|
468
|
+
buildBaseVars,
|
|
469
|
+
buildPrDispatch,
|
|
470
|
+
resolveTaskContext,
|
|
471
|
+
// Repo host helpers (used by engine.js for buildProjectContext)
|
|
472
|
+
getRepoHost,
|
|
473
|
+
getRepoHostLabel,
|
|
474
|
+
getRepoHostToolRule,
|
|
475
|
+
getPrCreateInstructions,
|
|
476
|
+
getPrCommentInstructions,
|
|
477
|
+
getPrFetchInstructions,
|
|
478
|
+
getPrVoteInstructions,
|
|
479
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/routing.js — Agent routing, budget checks, and routing table parsing.
|
|
3
|
+
* Extracted from engine.js.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const shared = require('./shared');
|
|
9
|
+
const queries = require('./queries');
|
|
10
|
+
|
|
11
|
+
const { safeJson, safeRead } = shared;
|
|
12
|
+
const { ENGINE_DIR, DISPATCH_PATH } = queries;
|
|
13
|
+
|
|
14
|
+
const MINIONS_DIR = path.resolve(__dirname, '..');
|
|
15
|
+
const ROUTING_PATH = path.join(MINIONS_DIR, 'routing.md');
|
|
16
|
+
|
|
17
|
+
// Lazy require to avoid circular dependency with engine.js
|
|
18
|
+
let _engine = null;
|
|
19
|
+
function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
|
|
20
|
+
|
|
21
|
+
// ─── Temp Agents ─────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const tempAgents = new Map(); // tempAgentId → { name, role, createdAt }
|
|
24
|
+
|
|
25
|
+
// ─── Routing Parser ─────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function getRouting() {
|
|
28
|
+
return safeRead(ROUTING_PATH);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let _routingCache = null;
|
|
32
|
+
let _routingCacheMtime = 0;
|
|
33
|
+
|
|
34
|
+
function parseRoutingTable() {
|
|
35
|
+
const content = getRouting();
|
|
36
|
+
const routes = {};
|
|
37
|
+
const lines = content.split('\n');
|
|
38
|
+
let inTable = false;
|
|
39
|
+
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
if (line.startsWith('| Work Type')) { inTable = true; continue; }
|
|
42
|
+
if (line.startsWith('|---')) continue;
|
|
43
|
+
if (!inTable || !line.startsWith('|')) {
|
|
44
|
+
if (inTable && !line.startsWith('|')) inTable = false;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
|
|
48
|
+
if (cells.length >= 3) {
|
|
49
|
+
routes[cells[0].toLowerCase()] = {
|
|
50
|
+
preferred: cells[1].toLowerCase(),
|
|
51
|
+
fallback: cells[2].toLowerCase()
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return routes;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getRoutingTableCached() {
|
|
59
|
+
let mtime = 0;
|
|
60
|
+
try { mtime = fs.statSync(ROUTING_PATH).mtimeMs; } catch { /* optional */ }
|
|
61
|
+
if (_routingCache && _routingCacheMtime === mtime) return _routingCache;
|
|
62
|
+
_routingCache = parseRoutingTable();
|
|
63
|
+
_routingCacheMtime = mtime;
|
|
64
|
+
return _routingCache;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Budget ──────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function getMonthlySpend(agentId) {
|
|
70
|
+
const metrics = safeJson(path.join(ENGINE_DIR, 'metrics.json')) || {};
|
|
71
|
+
const daily = metrics._daily || {};
|
|
72
|
+
const now = new Date();
|
|
73
|
+
const monthPrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
74
|
+
let total = 0;
|
|
75
|
+
for (const [date, data] of Object.entries(daily)) {
|
|
76
|
+
if (date.startsWith(monthPrefix)) {
|
|
77
|
+
total += (data.perAgent?.[agentId]?.costUsd || 0);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Fallback: if no per-agent daily data, use cumulative (less accurate for monthly)
|
|
81
|
+
if (total === 0 && metrics[agentId]?.totalCostUsd) {
|
|
82
|
+
// Can't distinguish monthly from cumulative — treat as monthly estimate
|
|
83
|
+
// This path is for backward compat before per-agent daily tracking was added
|
|
84
|
+
}
|
|
85
|
+
return total;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getAgentErrorRate(agentId) {
|
|
89
|
+
const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
|
|
90
|
+
const metrics = safeJson(metricsPath) || {};
|
|
91
|
+
const m = metrics[agentId];
|
|
92
|
+
if (!m) return 0;
|
|
93
|
+
const total = m.tasksCompleted + m.tasksErrored;
|
|
94
|
+
return total > 0 ? m.tasksErrored / total : 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isAgentIdle(agentId) {
|
|
98
|
+
// Dispatch queue is the single source of truth for agent availability
|
|
99
|
+
const dispatch = safeJson(DISPATCH_PATH) || {};
|
|
100
|
+
return !(dispatch.active || []).some(d => d.agent === agentId);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Agent Resolution ────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
// Track agents claimed during a single discovery pass to distribute work
|
|
106
|
+
const _claimedAgents = new Set();
|
|
107
|
+
function resetClaimedAgents() { _claimedAgents.clear(); }
|
|
108
|
+
|
|
109
|
+
function resolveAgent(workType, config, authorAgent = null) {
|
|
110
|
+
const routes = getRoutingTableCached();
|
|
111
|
+
const route = routes[workType] || routes['implement'];
|
|
112
|
+
const agents = config.agents || {};
|
|
113
|
+
|
|
114
|
+
// Resolve _author_ token
|
|
115
|
+
let preferred = route.preferred === '_author_' ? authorAgent : route.preferred;
|
|
116
|
+
let fallback = route.fallback === '_author_' ? authorAgent : route.fallback;
|
|
117
|
+
|
|
118
|
+
const isAvailable = (id) => {
|
|
119
|
+
if (!agents[id] || !isAgentIdle(id) || _claimedAgents.has(id)) return false;
|
|
120
|
+
// Budget check — no budget means infinite (no limit)
|
|
121
|
+
const budget = agents[id].monthlyBudgetUsd;
|
|
122
|
+
if (budget && budget > 0) {
|
|
123
|
+
if (getMonthlySpend(id) >= budget) return false;
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Check preferred and fallback first (routing table order)
|
|
129
|
+
if (preferred && isAvailable(preferred)) { _claimedAgents.add(preferred); return preferred; }
|
|
130
|
+
if (fallback && isAvailable(fallback)) { _claimedAgents.add(fallback); return fallback; }
|
|
131
|
+
|
|
132
|
+
// Fall back to any idle agent, preferring lower error rates
|
|
133
|
+
const idle = Object.keys(agents)
|
|
134
|
+
.filter(id => id !== preferred && id !== fallback && isAvailable(id))
|
|
135
|
+
.sort((a, b) => getAgentErrorRate(a) - getAgentErrorRate(b));
|
|
136
|
+
|
|
137
|
+
if (idle[0]) { _claimedAgents.add(idle[0]); return idle[0]; }
|
|
138
|
+
|
|
139
|
+
// No idle configured agent — try temp agent if enabled
|
|
140
|
+
if (config.engine?.allowTempAgents) {
|
|
141
|
+
const tempId = `temp-${shared.uid()}`;
|
|
142
|
+
_claimedAgents.add(tempId);
|
|
143
|
+
tempAgents.set(tempId, { name: `Temp-${tempId.slice(5, 9)}`, role: 'Temporary Agent', createdAt: engine().ts() });
|
|
144
|
+
engine().log('info', `Spawning temp agent ${tempId} — all permanent agents busy`);
|
|
145
|
+
return tempId;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// No idle agent available — return null, item stays pending until next tick
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = {
|
|
153
|
+
tempAgents,
|
|
154
|
+
getRouting,
|
|
155
|
+
parseRoutingTable,
|
|
156
|
+
getRoutingTableCached,
|
|
157
|
+
getMonthlySpend,
|
|
158
|
+
getAgentErrorRate,
|
|
159
|
+
isAgentIdle,
|
|
160
|
+
_claimedAgents,
|
|
161
|
+
resetClaimedAgents,
|
|
162
|
+
resolveAgent,
|
|
163
|
+
};
|