@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/engine.js CHANGED
@@ -123,516 +123,17 @@ const { getConfig, getControl, getDispatch, getNotes,
123
123
  collectSkillFiles, getSkillIndex, getKnowledgeBaseIndex,
124
124
  getPrs, SKILLS_DIR } = queries;
125
125
 
126
- function getRouting() {
127
- return safeRead(ROUTING_PATH);
128
- }
129
-
130
- // ─── Routing Parser ─────────────────────────────────────────────────────────
131
-
132
- let _routingCache = null;
133
- let _routingCacheMtime = 0;
134
-
135
- function parseRoutingTable() {
136
- const content = getRouting();
137
- const routes = {};
138
- const lines = content.split('\n');
139
- let inTable = false;
140
-
141
- for (const line of lines) {
142
- if (line.startsWith('| Work Type')) { inTable = true; continue; }
143
- if (line.startsWith('|---')) continue;
144
- if (!inTable || !line.startsWith('|')) {
145
- if (inTable && !line.startsWith('|')) inTable = false;
146
- continue;
147
- }
148
- const cells = line.split('|').map(c => c.trim()).filter(Boolean);
149
- if (cells.length >= 3) {
150
- routes[cells[0].toLowerCase()] = {
151
- preferred: cells[1].toLowerCase(),
152
- fallback: cells[2].toLowerCase()
153
- };
154
- }
155
- }
156
- return routes;
157
- }
158
-
159
- function getRoutingTableCached() {
160
- let mtime = 0;
161
- try { mtime = fs.statSync(ROUTING_PATH).mtimeMs; } catch { /* optional */ }
162
- if (_routingCache && _routingCacheMtime === mtime) return _routingCache;
163
- _routingCache = parseRoutingTable();
164
- _routingCacheMtime = mtime;
165
- return _routingCache;
166
- }
167
-
168
- function getMonthlySpend(agentId) {
169
- const metrics = safeJson(path.join(ENGINE_DIR, 'metrics.json')) || {};
170
- const daily = metrics._daily || {};
171
- const now = new Date();
172
- const monthPrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
173
- let total = 0;
174
- for (const [date, data] of Object.entries(daily)) {
175
- if (date.startsWith(monthPrefix)) {
176
- total += (data.perAgent?.[agentId]?.costUsd || 0);
177
- }
178
- }
179
- // Fallback: if no per-agent daily data, use cumulative (less accurate for monthly)
180
- if (total === 0 && metrics[agentId]?.totalCostUsd) {
181
- // Can't distinguish monthly from cumulative — treat as monthly estimate
182
- // This path is for backward compat before per-agent daily tracking was added
183
- }
184
- return total;
185
- }
186
-
187
- function getAgentErrorRate(agentId) {
188
- const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
189
- const metrics = safeJson(metricsPath) || {};
190
- const m = metrics[agentId];
191
- if (!m) return 0;
192
- const total = m.tasksCompleted + m.tasksErrored;
193
- return total > 0 ? m.tasksErrored / total : 0;
194
- }
195
-
196
- function isAgentIdle(agentId) {
197
- // Dispatch queue is the single source of truth for agent availability
198
- const dispatch = safeJson(DISPATCH_PATH) || {};
199
- return !(dispatch.active || []).some(d => d.agent === agentId);
200
- }
201
-
202
- // Track agents claimed during a single discovery pass to distribute work
203
- const _claimedAgents = new Set();
204
- function resetClaimedAgents() { _claimedAgents.clear(); }
205
-
206
- function resolveAgent(workType, config, authorAgent = null) {
207
- const routes = getRoutingTableCached();
208
- const route = routes[workType] || routes['implement'];
209
- const agents = config.agents || {};
210
-
211
- // Resolve _author_ token
212
- let preferred = route.preferred === '_author_' ? authorAgent : route.preferred;
213
- let fallback = route.fallback === '_author_' ? authorAgent : route.fallback;
214
-
215
- const isAvailable = (id) => {
216
- if (!agents[id] || !isAgentIdle(id) || _claimedAgents.has(id)) return false;
217
- // Budget check — no budget means infinite (no limit)
218
- const budget = agents[id].monthlyBudgetUsd;
219
- if (budget && budget > 0) {
220
- if (getMonthlySpend(id) >= budget) return false;
221
- }
222
- return true;
223
- };
224
-
225
- // Check preferred and fallback first (routing table order)
226
- if (preferred && isAvailable(preferred)) { _claimedAgents.add(preferred); return preferred; }
227
- if (fallback && isAvailable(fallback)) { _claimedAgents.add(fallback); return fallback; }
228
-
229
- // Fall back to any idle agent, preferring lower error rates
230
- const idle = Object.keys(agents)
231
- .filter(id => id !== preferred && id !== fallback && isAvailable(id))
232
- .sort((a, b) => getAgentErrorRate(a) - getAgentErrorRate(b));
233
-
234
- if (idle[0]) { _claimedAgents.add(idle[0]); return idle[0]; }
235
-
236
- // No idle configured agent — try temp agent if enabled
237
- if (config.engine?.allowTempAgents) {
238
- const tempId = `temp-${shared.uid()}`;
239
- _claimedAgents.add(tempId);
240
- tempAgents.set(tempId, { name: `Temp-${tempId.slice(5, 9)}`, role: 'Temporary Agent', createdAt: ts() });
241
- log('info', `Spawning temp agent ${tempId} — all permanent agents busy`);
242
- return tempId;
243
- }
244
-
245
- // No idle agent available — return null, item stays pending until next tick
246
- return null;
247
- }
248
-
249
- // ─── Task Context Resolution ────────────────────────────────────────────────
250
- // Resolves implicit references in task descriptions (e.g., "ripley's plan",
251
- // "dallas's PR") to actual artifacts and injects their content.
252
-
253
- function resolveTaskContext(item, config) {
254
- const title = (item.title || '').toLowerCase();
255
- const desc = (item.description || '').toLowerCase();
256
- const text = title + ' ' + desc;
257
- const agentNames = Object.entries(config.agents || {}).map(([id, a]) => ({
258
- id,
259
- name: (a.name || id).toLowerCase(),
260
- }));
261
- const resolved = { additionalContext: '', referencedFiles: [] };
262
-
263
- // Match agent references: "ripley's plan", "dallas's pr", "lambert's output", etc.
264
- for (const agent of agentNames) {
265
- const patterns = [
266
- new RegExp(`${agent.name}(?:'s|s)?\\s+plan`, 'i'),
267
- new RegExp(`${agent.id}(?:'s|s)?\\s+plan`, 'i'),
268
- new RegExp(`plan\\s+(?:created|made|written|generated)\\s+by\\s+${agent.name}`, 'i'),
269
- new RegExp(`plan\\s+(?:created|made|written|generated)\\s+by\\s+${agent.id}`, 'i'),
270
- ];
271
- const matchesPlan = patterns.some(p => p.test(text));
272
- if (matchesPlan) {
273
- // Find plans created by this agent (check work items for plan tasks dispatched to this agent)
274
- try {
275
- const plans = fs.readdirSync(path.join(MINIONS_DIR, 'plans')).filter(f => f.endsWith('.md') || f.endsWith('.json'));
276
- // Check work-items to find which plan file this agent created
277
- const workItems = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
278
- const agentPlanItems = workItems.filter(w =>
279
- w.type === 'plan' && w.dispatched_to === agent.id && w.status === 'done' && w._planFileName
280
- ).sort((a, b) => (b.completedAt || '').localeCompare(a.completedAt || ''));
281
-
282
- if (agentPlanItems.length > 0) {
283
- const planFile = agentPlanItems[0]._planFileName;
284
- const planPath = path.join(MINIONS_DIR, 'plans', planFile);
285
- try {
286
- const content = safeRead(planPath);
287
- resolved.additionalContext += `\n\n## Referenced Plan: ${planFile} (created by ${agent.name})\n\n${content}`;
288
- resolved.referencedFiles.push(planPath);
289
- log('info', `Context resolution: found plan "${planFile}" by ${agent.name} for work item ${item.id}`);
290
- } catch (e) { log('warn', 'resolve plan context: ' + e.message); }
291
- } else if (plans.length > 0) {
292
- // Fallback: try to find a plan file with the agent's name or ID in it
293
- const match = plans.find(f => f.toLowerCase().includes(agent.id) || f.toLowerCase().includes(agent.name));
294
- if (match) {
295
- const planPath = path.join(MINIONS_DIR, 'plans', match);
296
- try {
297
- const content = safeRead(planPath);
298
- resolved.additionalContext += `\n\n## Referenced Plan: ${match}\n\n${content}`;
299
- resolved.referencedFiles.push(planPath);
300
- log('info', `Context resolution: found plan "${match}" (name match) for work item ${item.id}`);
301
- } catch (e) { log('warn', 'resolve plan fallback context: ' + e.message); }
302
- }
303
- }
304
- } catch (e) { log('warn', 'resolve agent plan context: ' + e.message); }
305
- }
126
+ // ─── Routing (extracted to engine/routing.js) ───────────────────────────────
306
127
 
307
- // Match agent output/notes references
308
- const outputPatterns = [
309
- new RegExp(`${agent.name}(?:'s|s)?\\s+(?:output|findings|notes|results)`, 'i'),
310
- new RegExp(`(?:output|findings|notes|results)\\s+(?:from|by)\\s+${agent.name}`, 'i'),
311
- ];
312
- if (outputPatterns.some(p => p.test(text))) {
313
- // Find the agent's latest inbox notes
314
- try {
315
- const inboxDir = path.join(MINIONS_DIR, 'notes', 'inbox');
316
- const files = fs.readdirSync(inboxDir)
317
- .filter(f => f.startsWith(agent.id + '-'))
318
- .sort().reverse();
319
- if (files.length > 0) {
320
- const content = safeRead(path.join(inboxDir, files[0]));
321
- resolved.additionalContext += `\n\n## Referenced Notes by ${agent.name}: ${files[0]}\n\n${content.slice(0, 5000)}`;
322
- resolved.referencedFiles.push(path.join(inboxDir, files[0]));
323
- log('info', `Context resolution: found notes "${files[0]}" by ${agent.name} for work item ${item.id}`);
324
- }
325
- } catch (e) { log('warn', 'resolve plan context outer: ' + e.message); }
326
- }
327
- }
128
+ const { getRouting, parseRoutingTable, getRoutingTableCached, getMonthlySpend,
129
+ getAgentErrorRate, isAgentIdle, resolveAgent, resetClaimedAgents,
130
+ tempAgents } = require('./engine/routing');
328
131
 
329
- // If no specific reference was resolved but the text mentions "the plan" or "latest plan",
330
- // find the most recent plan
331
- if (!resolved.additionalContext && /\b(the|latest|last|recent)\s+plan\b/i.test(text)) {
332
- try {
333
- const plans = fs.readdirSync(path.join(MINIONS_DIR, 'plans'))
334
- .filter(f => f.endsWith('.md') || f.endsWith('.json'))
335
- .sort().reverse();
336
- if (plans.length > 0) {
337
- const planPath = path.join(MINIONS_DIR, 'plans', plans[0]);
338
- const content = safeRead(planPath);
339
- resolved.additionalContext += `\n\n## Referenced Plan (latest): ${plans[0]}\n\n${content}`;
340
- resolved.referencedFiles.push(planPath);
341
- log('info', `Context resolution: using latest plan "${plans[0]}" for work item ${item.id}`);
342
- }
343
- } catch (e) { log('warn', 'resolve latest plan context: ' + e.message); }
344
- }
132
+ // ─── Playbook, system prompt, agent context (extracted to engine/playbook.js)
345
133
 
346
- return resolved;
347
- }
348
-
349
- // ─── Playbook Renderer ──────────────────────────────────────────────────────
350
-
351
- function renderPlaybook(type, vars) {
352
- const pbPath = path.join(PLAYBOOKS_DIR, `${type}.md`);
353
- let content;
354
- try { content = fs.readFileSync(pbPath, 'utf8'); } catch {
355
- log('warn', `Playbook not found: ${type}`);
356
- return null;
357
- }
358
-
359
- // Inject pinned context (always visible to agents) — capped at 4KB
360
- let pinnedContent = '';
361
- try { pinnedContent = fs.readFileSync(path.join(MINIONS_DIR, 'pinned.md'), 'utf8'); } catch { /* optional */ }
362
- if (pinnedContent) {
363
- if (pinnedContent.length > 4096) pinnedContent = pinnedContent.slice(0, 4096) + '\n\n_...pinned.md truncated (read full file if needed)_';
364
- content += '\n\n---\n\n## Pinned Context (CRITICAL — READ FIRST)\n\n' + pinnedContent;
365
- }
366
-
367
- // Inject team notes (single injection point — not in buildAgentContext) — capped at 8KB
368
- let notes = getNotes();
369
- if (notes) {
370
- if (notes.length > 8192) {
371
- const sections = notes.split(/(?=^### )/m);
372
- const recent = sections.slice(-10).join('');
373
- notes = recent.length > 8192 ? recent.slice(0, 8192) + '\n\n_...notes truncated_' : recent;
374
- notes += '\n\n_' + Math.max(0, sections.length - 10) + ' older entries in `notes.md` — Read if needed._';
375
- }
376
- content += '\n\n---\n\n## Team Notes (MUST READ)\n\n' + notes;
377
- }
378
-
379
- // Inject KB guardrail
380
- content += `\n\n---\n\n## Knowledge Base Rules\n\n`;
381
- 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`;
382
-
383
- // Inject learnings requirement
384
- content += `\n\n---\n\n## REQUIRED: Write Learnings\n\n`;
385
- content += `After completing your task, you MUST write a findings/learnings file to:\n`;
386
- content += `\`${MINIONS_DIR}/notes/inbox/${vars.agent_id || 'agent'}-${dateStamp()}.md\`\n\n`;
387
- content += `Include:\n`;
388
- content += `- What you learned about the codebase\n`;
389
- content += `- Patterns you discovered or established\n`;
390
- content += `- Gotchas or warnings for future agents\n`;
391
- content += `- Conventions to follow\n`;
392
- 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`;
393
- content += `### Skill Extraction (IMPORTANT)\n\n`;
394
- 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`;
395
- content += `Format your skill as a fenced code block with the \`skill\` language tag:\n\n`;
396
- content += '````\n```skill\n';
397
- 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`;
398
- content += '```\n````\n\n';
399
- content += `- Set \`scope: minions\` for cross-project skills (engine writes to ~/.claude/skills/ automatically)\n`;
400
- content += `- Set \`scope: project\` + \`project: <name>\` for repo-specific skills (engine queues a PR to <project>/.claude/skills/)\n`;
401
- content += `- Only output a skill block if you genuinely discovered something reusable — don't force it\n`;
402
-
403
- // Inject project-level variables from config
404
- const config = getConfig();
405
- const projects = getProjects(config);
406
- // Find the specific project being dispatched (match by repo_id or repo_name from vars)
407
- const dispatchProject = (vars.repo_id && projects.find(p => p.repositoryId === vars.repo_id))
408
- || (vars.repo_name && projects.find(p => p.repoName === vars.repo_name))
409
- || projects[0] || {};
410
- const projectVars = {
411
- project_name: dispatchProject.name || 'Unknown Project',
412
- ado_org: dispatchProject.adoOrg || 'Unknown',
413
- ado_project: dispatchProject.adoProject || 'Unknown',
414
- repo_name: dispatchProject.repoName || 'Unknown',
415
- pr_create_instructions: getPrCreateInstructions(dispatchProject),
416
- pr_comment_instructions: getPrCommentInstructions(dispatchProject),
417
- pr_fetch_instructions: getPrFetchInstructions(dispatchProject),
418
- pr_vote_instructions: getPrVoteInstructions(dispatchProject),
419
- repo_host_label: getRepoHostLabel(dispatchProject),
420
- };
421
- const allVars = { ...projectVars, ...vars };
422
-
423
- // Substitute variables
424
- for (const [key, val] of Object.entries(allVars)) {
425
- content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(val));
426
- }
427
-
428
- return content;
429
- }
430
-
431
- // ─── Repo Host Helpers ──────────────────────────────────────────────────────
432
-
433
- function getRepoHost(project) {
434
- return project?.repoHost || 'ado';
435
- }
436
-
437
- function getPrCreateInstructions(project) {
438
- const host = getRepoHost(project);
439
- const repoId = project?.repositoryId || '';
440
- if (host === 'github') {
441
- const org = project?.adoOrg || '';
442
- const repo = project?.repoName || '';
443
- const mainBranch = project?.mainBranch || 'main';
444
- return `Use \`gh pr create\` to create a pull request:\n` +
445
- `- \`gh pr create --base ${mainBranch} --head <your-branch> --title "PR title" --body "PR description" --repo ${org}/${repo}\`\n` +
446
- `- Always set --base to \`${mainBranch}\` (the main branch)\n` +
447
- `- Always set --repo to \`${org}/${repo}\` to target the correct repository\n` +
448
- `- Use --head to specify your feature branch name\n` +
449
- `- Include a meaningful --title and --body describing the changes`;
450
- }
451
- // Default: Azure DevOps
452
- return `Use \`mcp__azure-ado__repo_create_pull_request\`:\n- repositoryId: \`${repoId}\``;
453
- }
454
-
455
- function getPrCommentInstructions(project) {
456
- const host = getRepoHost(project);
457
- const repoId = project?.repositoryId || '';
458
- if (host === 'github') {
459
- const org = project?.adoOrg || '';
460
- const repo = project?.repoName || '';
461
- return `Use \`gh pr comment\` to post a comment on the PR:\n` +
462
- `- \`gh pr comment <number> --body "Your comment text" --repo ${org}/${repo}\`\n` +
463
- `- Replace <number> with the PR number\n` +
464
- `- Always set --repo to \`${org}/${repo}\` to target the correct repository\n` +
465
- `- Use --body to provide the comment text (supports Markdown)`;
466
- }
467
- return `Use \`mcp__azure-ado__repo_create_pull_request_thread\`:\n- repositoryId: \`${repoId}\``;
468
- }
469
-
470
- function getPrFetchInstructions(project) {
471
- const host = getRepoHost(project);
472
- if (host === 'github') {
473
- const org = project?.adoOrg || '';
474
- const repo = project?.repoName || '';
475
- const mainBranch = project?.mainBranch || 'main';
476
- return `Use \`gh pr view\` to fetch PR status:\n` +
477
- `- \`gh pr view <number> --json number,title,state,mergeable,reviewDecision,headRefName,baseRefName,statusCheckRollup --repo ${org}/${repo}\`\n` +
478
- `- This returns JSON with PR state, mergeability, review decision, and check statuses\n` +
479
- `- To fetch the PR branch locally:\n` +
480
- ` 1. \`git fetch origin <branch-name>\`\n` +
481
- ` 2. \`git checkout <branch-name>\`\n` +
482
- `- Or use \`gh pr checkout <number> --repo ${org}/${repo}\` to fetch and checkout in one step\n` +
483
- `- The base branch is \`${mainBranch}\``;
484
- }
485
- return `Use \`mcp__azure-ado__repo_get_pull_request_by_id\` to fetch PR status.`;
486
- }
487
-
488
- function getPrVoteInstructions(project) {
489
- const host = getRepoHost(project);
490
- const repoId = project?.repositoryId || '';
491
- if (host === 'github') {
492
- const org = project?.adoOrg || '';
493
- const repo = project?.repoName || '';
494
- return `Use \`gh pr review\` to submit a review on the PR:\n` +
495
- `- Approve: \`gh pr review <number> --approve --body "Approval comment" --repo ${org}/${repo}\`\n` +
496
- `- Request changes: \`gh pr review <number> --request-changes --body "What needs to change" --repo ${org}/${repo}\`\n` +
497
- `- Comment only: \`gh pr review <number> --comment --body "Review comment" --repo ${org}/${repo}\`\n` +
498
- `- Replace <number> with the PR number\n` +
499
- `- Always set --repo to \`${org}/${repo}\` to target the correct repository\n` +
500
- `- Use --body to provide a review summary (supports Markdown)`;
501
- }
502
- 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)`;
503
- }
504
-
505
- function getRepoHostLabel(project) {
506
- const host = getRepoHost(project);
507
- if (host === 'github') return 'GitHub';
508
- return 'Azure DevOps';
509
- }
510
-
511
- function getRepoHostToolRule(project) {
512
- const host = getRepoHost(project);
513
- if (host === 'github') return 'Use GitHub MCP tools or `gh` CLI for PR operations';
514
- return 'Use Azure DevOps MCP tools (mcp__azure-ado__*) for PR operations — NEVER use gh CLI';
515
- }
516
-
517
- // ─── System Prompt Builder ──────────────────────────────────────────────────
518
-
519
- // Lean system prompt: agent identity + rules only (~2-4KB, never grows)
520
- function buildSystemPrompt(agentId, config, project) {
521
- const agent = config.agents[agentId] || tempAgents.get(agentId) || { name: agentId, role: 'Temporary Agent', skills: [] };
522
- const charter = getAgentCharter(agentId); // returns '' for temp agents (no charter file)
523
- project = project || getProjects(config)[0] || {};
524
-
525
- let prompt = '';
526
-
527
- // Agent identity
528
- prompt += `# You are ${agent.name} (${agent.role})\n\n`;
529
- prompt += `Agent ID: ${agentId}\n`;
530
- prompt += `Skills: ${(agent.skills || []).join(', ')}\n\n`;
531
-
532
- // Charter (detailed instructions — typically 1-2KB)
533
- if (charter) {
534
- prompt += `## Your Charter\n\n${charter}\n\n`;
535
- }
536
-
537
- // Project context (fixed size)
538
- prompt += `## Project: ${project.name || 'Unknown Project'}\n\n`;
539
- prompt += `- Repo: ${project.repoName || 'Unknown'} (${project.adoOrg || 'Unknown'}/${project.adoProject || 'Unknown'})\n`;
540
- prompt += `- Repo ID: ${project.repositoryId || ''}\n`;
541
- prompt += `- Repo host: ${getRepoHostLabel(project)}\n`;
542
- prompt += `- Main branch: ${project.mainBranch || 'main'}\n\n`;
543
-
544
- // Critical rules (fixed size)
545
- prompt += `## Critical Rules\n\n`;
546
- prompt += `1. Use git worktrees — NEVER checkout on main working tree\n`;
547
- prompt += `2. ${getRepoHostToolRule(project)}\n`;
548
- prompt += `3. Follow the project conventions in CLAUDE.md if present\n`;
549
- prompt += `4. Write learnings to: ${MINIONS_DIR}/notes/inbox/${agentId}-${dateStamp()}.md\n`;
550
- prompt += `5. Agent status is managed by the engine via dispatch.json — agents do not need to track their own status\n`;
551
- 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`;
552
-
553
- return prompt;
554
- }
555
-
556
- // Bulk context: history, notes, conventions, skills — prepended to user/task prompt.
557
- // This is the content that grows over time and would bloat the system prompt.
558
- function buildAgentContext(agentId, config, project) {
559
- project = project || getProjects(config)[0] || {};
560
- let context = '';
561
-
562
- // Agent history — last 5 tasks only (keeps it relevant, avoids 37KB dumps)
563
- const history = safeRead(path.join(AGENTS_DIR, agentId, 'history.md'));
564
- if (history && history.trim() !== '# Agent History') {
565
- const entries = history.split(/(?=^### )/m);
566
- const header = entries[0].startsWith('#') && !entries[0].startsWith('### ') ? entries.shift() : '';
567
- const recent = entries.slice(-5);
568
- const trimmed = (header ? header + '\n' : '') + recent.join('');
569
- context += `## Your Recent History (last 5 tasks)\n\n${trimmed}\n\n`;
570
- }
571
-
572
- // Project conventions (from CLAUDE.md) — always relevant for code quality
573
- if (project.localPath) {
574
- const claudeMd = safeRead(path.join(project.localPath, 'CLAUDE.md'));
575
- if (claudeMd && claudeMd.trim()) {
576
- const truncated = claudeMd.length > 8192 ? claudeMd.slice(0, 8192) + '\n\n...(truncated)' : claudeMd;
577
- context += `## Project Conventions (from CLAUDE.md)\n\n${truncated}\n\n`;
578
- }
579
- }
580
-
581
- // KB and skills: NOT injected — agents can Glob/Read when needed
582
- // This saves ~27KB per dispatch. Reference note so agents know they exist:
583
- 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`;
584
-
585
- // Minions awareness: what's in flight, who's doing what
586
- const dispatch = getDispatch();
587
- const activeItems = (dispatch.active || []).map(d =>
588
- `- **${d.agent}**: ${d.type} — ${(d.task || '').slice(0, 100)}${d.agent === agentId ? ' ← (you)' : ''}`
589
- );
590
- if (activeItems.length > 0) {
591
- context += `## Active Agents\n\n${activeItems.join('\n')}\n\n`;
592
- }
593
-
594
- // Recent completions (last 5, not 10)
595
- const recentCompleted = (dispatch.completed || []).slice(-5).reverse().map(d =>
596
- `- **${d.agent}** ${d.result === 'success' ? 'completed' : 'failed'}: ${(d.task || '').slice(0, 80)}${d.resultSummary ? ' — ' + d.resultSummary.slice(0, 100) : ''}`
597
- );
598
- if (recentCompleted.length > 0) {
599
- context += `## Recently Completed\n\n${recentCompleted.join('\n')}\n\n`;
600
- }
601
-
602
- // Active + linked PRs across projects — coordination awareness
603
- const projects = getProjects(config);
604
- const allPrs = [];
605
- for (const p of projects) {
606
- const prs = getPrs(p).filter(pr => pr.status === 'active' || pr.status === 'linked');
607
- for (const pr of prs) allPrs.push({ ...pr, _project: p.name });
608
- }
609
- // Also check central pull-requests.json
610
- try {
611
- const centralPrs = safeJson(path.join(MINIONS_DIR, 'pull-requests.json')) || [];
612
- for (const pr of centralPrs.filter(pr => pr.status === 'active' || pr.status === 'linked')) {
613
- if (!allPrs.some(p => p.id === pr.id)) allPrs.push({ ...pr, _project: 'central' });
614
- }
615
- } catch (e) { log('warn', 'read central pull-requests: ' + e.message); }
616
- if (allPrs.length > 0) {
617
- const prLines = allPrs.map(pr =>
618
- `- **${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) : ''}`
619
- );
620
- context += `## Active Pull Requests\n\n${prLines.join('\n')}\n\n`;
621
- }
622
-
623
- // Pending work items
624
- const pendingItems = (dispatch.pending || []).slice(0, 10).map(d =>
625
- `- ${d.type}: ${(d.task || '').slice(0, 80)}`
626
- );
627
- if (pendingItems.length > 0) {
628
- 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`;
629
- }
630
-
631
- // Team notes injected via renderPlaybook (single injection point, with truncation)
632
- // Not duplicated here to avoid double-injection and token waste
633
-
634
- return context;
635
- }
134
+ const { renderPlaybook, buildSystemPrompt, buildAgentContext, selectPlaybook,
135
+ buildBaseVars, buildPrDispatch, resolveTaskContext,
136
+ getRepoHostLabel, getRepoHostToolRule } = require('./engine/playbook');
636
137
 
637
138
  // sanitizeBranch imported from shared.js
638
139
 
@@ -645,7 +146,7 @@ const { runPostCompletionHooks, updateWorkItemStatus, syncPrdItemStatus, handleP
645
146
  // ─── Agent Spawner ──────────────────────────────────────────────────────────
646
147
 
647
148
  const activeProcesses = new Map(); // dispatchId → { proc, agentId, startedAt }
648
- const tempAgents = new Map(); // tempAgentId → { name, role, createdAt }
149
+ // tempAgents imported from engine/routing.js
649
150
  let engineRestartGraceUntil = 0; // timestamp — suppress orphan detection until this time
650
151
 
651
152
  // Resolve dependency plan item IDs to their PR branches
@@ -2086,96 +1587,11 @@ function runCleanup(config, verbose = false) {
2086
1587
  return cleaned;
2087
1588
  }
2088
1589
 
2089
- // ─── Work Discovery ─────────────────────────────────────────────────────────
2090
-
2091
- const COOLDOWN_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
2092
- const dispatchCooldowns = new Map(); // key → { timestamp, failures }
2093
-
2094
- function loadCooldowns() {
2095
- const saved = safeJson(COOLDOWN_PATH);
2096
- if (!saved) return;
2097
- const now = Date.now();
2098
- for (const [k, v] of Object.entries(saved)) {
2099
- // Prune entries older than 24 hours
2100
- if (now - v.timestamp < 24 * 60 * 60 * 1000) {
2101
- dispatchCooldowns.set(k, v);
2102
- }
2103
- }
2104
- log('info', `Loaded ${dispatchCooldowns.size} cooldowns from disk`);
2105
- }
2106
-
2107
- let _cooldownWriteTimer = null;
2108
- function saveCooldowns() {
2109
- // Debounce: reset timer on each call so latest state is always written
2110
- if (_cooldownWriteTimer) clearTimeout(_cooldownWriteTimer);
2111
- _cooldownWriteTimer = setTimeout(() => {
2112
- _cooldownWriteTimer = null;
2113
- // Prune expired entries (>24h) before saving
2114
- const now = Date.now();
2115
- for (const [k, v] of dispatchCooldowns) {
2116
- if (now - v.timestamp > 24 * 60 * 60 * 1000) dispatchCooldowns.delete(k);
2117
- }
2118
- const obj = Object.fromEntries(dispatchCooldowns);
2119
- safeWrite(COOLDOWN_PATH, obj);
2120
- }, 1000); // debounce — write at most once per second
2121
- }
2122
-
2123
- function isOnCooldown(key, cooldownMs) {
2124
- const entry = dispatchCooldowns.get(key);
2125
- if (!entry) return false;
2126
- const backoff = Math.min(Math.pow(2, entry.failures || 0), 8);
2127
- return (Date.now() - entry.timestamp) < (cooldownMs * backoff);
2128
- }
2129
-
2130
- function setCooldown(key) {
2131
- const existing = dispatchCooldowns.get(key);
2132
- dispatchCooldowns.set(key, { timestamp: Date.now(), failures: existing?.failures || 0 });
2133
- saveCooldowns();
2134
- }
2135
-
2136
- function setCooldownWithContext(key, context) {
2137
- const existing = dispatchCooldowns.get(key);
2138
- const pendingContexts = existing?.pendingContexts || [];
2139
- if (context) pendingContexts.push(context);
2140
- dispatchCooldowns.set(key, {
2141
- timestamp: Date.now(),
2142
- failures: existing?.failures || 0,
2143
- pendingContexts
2144
- });
2145
- saveCooldowns();
2146
- }
2147
-
2148
- function getCoalescedContexts(key) {
2149
- const entry = dispatchCooldowns.get(key);
2150
- const contexts = entry?.pendingContexts || [];
2151
- if (contexts.length > 0 && entry) {
2152
- entry.pendingContexts = []; // Clear after retrieval
2153
- }
2154
- return contexts;
2155
- }
1590
+ // ─── Cooldowns (extracted to engine/cooldown.js) ─────────────────────────────
2156
1591
 
2157
- function setCooldownFailure(key) {
2158
- const existing = dispatchCooldowns.get(key);
2159
- const failures = (existing?.failures || 0) + 1;
2160
- dispatchCooldowns.set(key, { timestamp: Date.now(), failures });
2161
- if (failures >= 3) {
2162
- log('warn', `${key} has failed ${failures} times — cooldown is now ${Math.min(Math.pow(2, failures), 8)}x`);
2163
- }
2164
- saveCooldowns();
2165
- }
2166
-
2167
- function isAlreadyDispatched(key) {
2168
- const dispatch = getDispatch();
2169
- // Check pending and active
2170
- const inFlight = [...dispatch.pending, ...(dispatch.active || [])];
2171
- if (inFlight.some(d => d.meta?.dispatchKey === key)) return true;
2172
- // Also check recently completed (last hour) to prevent re-dispatch
2173
- const oneHourAgo = Date.now() - 3600000;
2174
- const recentCompleted = (dispatch.completed || []).filter(d =>
2175
- d.completed_at && new Date(d.completed_at).getTime() > oneHourAgo
2176
- );
2177
- return recentCompleted.some(d => d.meta?.dispatchKey === key);
2178
- }
1592
+ const { COOLDOWN_PATH, dispatchCooldowns, loadCooldowns, saveCooldowns,
1593
+ isOnCooldown, setCooldown, setCooldownWithContext, getCoalescedContexts,
1594
+ setCooldownFailure, isAlreadyDispatched } = require('./engine/cooldown');
2179
1595
 
2180
1596
 
2181
1597
 
@@ -2507,50 +1923,7 @@ function materializePlansAsWorkItems(config) {
2507
1923
  }
2508
1924
  }
2509
1925
 
2510
- // ─── Work Discovery Helpers ──────────────────────────────────────────────────
2511
-
2512
- function buildBaseVars(agentId, config, project) {
2513
- return {
2514
- agent_id: agentId,
2515
- agent_name: config.agents[agentId]?.name || agentId,
2516
- agent_role: config.agents[agentId]?.role || 'Agent',
2517
- team_root: MINIONS_DIR,
2518
- repo_id: project?.repositoryId || '',
2519
- project_name: project?.name || 'Unknown Project',
2520
- ado_org: project?.adoOrg || 'Unknown',
2521
- ado_project: project?.adoProject || 'Unknown',
2522
- repo_name: project?.repoName || 'Unknown',
2523
- main_branch: project?.mainBranch || 'main',
2524
- date: dateStamp(),
2525
- };
2526
- }
2527
-
2528
- function selectPlaybook(workType, item) {
2529
- if (item?.branchStrategy === 'shared-branch' && (workType === 'implement' || workType === 'implement:large')) {
2530
- return 'implement-shared';
2531
- }
2532
- if (workType === 'review' && !item?._pr && !item?.pr_id) {
2533
- return 'work-item';
2534
- }
2535
- const typeSpecificPlaybooks = ['explore', 'review', 'test', 'plan-to-prd', 'plan', 'ask', 'verify', 'decompose'];
2536
- return typeSpecificPlaybooks.includes(workType) ? workType : 'work-item';
2537
- }
2538
-
2539
- function buildPrDispatch(agentId, config, project, pr, type, extraVars, taskLabel, meta) {
2540
- const vars = { ...buildBaseVars(agentId, config, project), ...extraVars };
2541
- const playbookName = type === 'test' ? 'build-and-test' : (type === 'review' ? 'review' : 'fix');
2542
- const prompt = renderPlaybook(playbookName, vars);
2543
- if (!prompt) return null;
2544
- return {
2545
- type,
2546
- agent: agentId,
2547
- agentName: config.agents[agentId]?.name,
2548
- agentRole: config.agents[agentId]?.role,
2549
- task: `[${project?.name || 'project'}] ${taskLabel}`,
2550
- prompt,
2551
- meta,
2552
- };
2553
- }
1926
+ // buildBaseVars, selectPlaybook, buildPrDispatch extracted to engine/playbook.js
2554
1927
 
2555
1928
  function clearPendingHumanFeedbackFlag(projectMeta, prId) {
2556
1929
  if (!prId) return;