@yemi33/minions 0.1.59 → 0.1.60

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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.60 (2026-03-30)
4
+
5
+ ### Engine
6
+ - engine.js
7
+ - engine/cooldown.js
8
+ - engine/playbook.js
9
+ - engine/routing.js
10
+
11
+ ### Other
12
+ - test/unit.test.js
13
+
3
14
  ## 0.1.59 (2026-03-30)
4
15
 
5
16
  ### Engine
@@ -0,0 +1,117 @@
1
+ /**
2
+ * engine/cooldown.js — Dispatch cooldowns, deduplication, and context coalescing.
3
+ * Extracted from engine.js.
4
+ */
5
+
6
+ const path = require('path');
7
+ const shared = require('./shared');
8
+ const queries = require('./queries');
9
+
10
+ const { safeJson, safeWrite } = shared;
11
+ const { ENGINE_DIR } = queries;
12
+
13
+ // Lazy require to avoid circular dependency with engine.js
14
+ let _engine = null;
15
+ function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
16
+
17
+ const COOLDOWN_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
18
+ const dispatchCooldowns = new Map(); // key → { timestamp, failures }
19
+
20
+ function loadCooldowns() {
21
+ const saved = safeJson(COOLDOWN_PATH);
22
+ if (!saved) return;
23
+ const now = Date.now();
24
+ for (const [k, v] of Object.entries(saved)) {
25
+ // Prune entries older than 24 hours
26
+ if (now - v.timestamp < 24 * 60 * 60 * 1000) {
27
+ dispatchCooldowns.set(k, v);
28
+ }
29
+ }
30
+ engine().log('info', `Loaded ${dispatchCooldowns.size} cooldowns from disk`);
31
+ }
32
+
33
+ let _cooldownWriteTimer = null;
34
+ function saveCooldowns() {
35
+ // Debounce: reset timer on each call so latest state is always written
36
+ if (_cooldownWriteTimer) clearTimeout(_cooldownWriteTimer);
37
+ _cooldownWriteTimer = setTimeout(() => {
38
+ _cooldownWriteTimer = null;
39
+ // Prune expired entries (>24h) before saving
40
+ const now = Date.now();
41
+ for (const [k, v] of dispatchCooldowns) {
42
+ if (now - v.timestamp > 24 * 60 * 60 * 1000) dispatchCooldowns.delete(k);
43
+ }
44
+ const obj = Object.fromEntries(dispatchCooldowns);
45
+ safeWrite(COOLDOWN_PATH, obj);
46
+ }, 1000); // debounce — write at most once per second
47
+ }
48
+
49
+ function isOnCooldown(key, cooldownMs) {
50
+ const entry = dispatchCooldowns.get(key);
51
+ if (!entry) return false;
52
+ const backoff = Math.min(Math.pow(2, entry.failures || 0), 8);
53
+ return (Date.now() - entry.timestamp) < (cooldownMs * backoff);
54
+ }
55
+
56
+ function setCooldown(key) {
57
+ const existing = dispatchCooldowns.get(key);
58
+ dispatchCooldowns.set(key, { timestamp: Date.now(), failures: existing?.failures || 0 });
59
+ saveCooldowns();
60
+ }
61
+
62
+ function setCooldownWithContext(key, context) {
63
+ const existing = dispatchCooldowns.get(key);
64
+ const pendingContexts = existing?.pendingContexts || [];
65
+ if (context) pendingContexts.push(context);
66
+ dispatchCooldowns.set(key, {
67
+ timestamp: Date.now(),
68
+ failures: existing?.failures || 0,
69
+ pendingContexts
70
+ });
71
+ saveCooldowns();
72
+ }
73
+
74
+ function getCoalescedContexts(key) {
75
+ const entry = dispatchCooldowns.get(key);
76
+ const contexts = entry?.pendingContexts || [];
77
+ if (contexts.length > 0 && entry) {
78
+ entry.pendingContexts = []; // Clear after retrieval
79
+ }
80
+ return contexts;
81
+ }
82
+
83
+ function setCooldownFailure(key) {
84
+ const existing = dispatchCooldowns.get(key);
85
+ const failures = (existing?.failures || 0) + 1;
86
+ dispatchCooldowns.set(key, { timestamp: Date.now(), failures });
87
+ if (failures >= 3) {
88
+ engine().log('warn', `${key} has failed ${failures} times — cooldown is now ${Math.min(Math.pow(2, failures), 8)}x`);
89
+ }
90
+ saveCooldowns();
91
+ }
92
+
93
+ function isAlreadyDispatched(key) {
94
+ const dispatch = queries.getDispatch();
95
+ // Check pending and active
96
+ const inFlight = [...dispatch.pending, ...(dispatch.active || [])];
97
+ if (inFlight.some(d => d.meta?.dispatchKey === key)) return true;
98
+ // Also check recently completed (last hour) to prevent re-dispatch
99
+ const oneHourAgo = Date.now() - 3600000;
100
+ const recentCompleted = (dispatch.completed || []).filter(d =>
101
+ d.completed_at && new Date(d.completed_at).getTime() > oneHourAgo
102
+ );
103
+ return recentCompleted.some(d => d.meta?.dispatchKey === key);
104
+ }
105
+
106
+ module.exports = {
107
+ COOLDOWN_PATH,
108
+ dispatchCooldowns,
109
+ loadCooldowns,
110
+ saveCooldowns,
111
+ isOnCooldown,
112
+ setCooldown,
113
+ setCooldownWithContext,
114
+ getCoalescedContexts,
115
+ setCooldownFailure,
116
+ isAlreadyDispatched,
117
+ };
@@ -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
+ };