@yemi33/minions 0.1.58 → 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 {}
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 {}
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 {}
302
- }
303
- }
304
- } catch {}
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 {}
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 {}
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 {}
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 {}
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
@@ -703,7 +204,7 @@ function findExistingWorktree(repoDir, branchName) {
703
204
  }
704
205
  }
705
206
  }
706
- } catch {}
207
+ } catch (e) { log('warn', 'git: ' + e.message); }
707
208
  return null;
708
209
  }
709
210
 
@@ -727,7 +228,7 @@ function removeStaleIndexLock(rootDir) {
727
228
  log('warn', `Removed stale index.lock (${Math.round(age / 1000)}s old) in ${rootDir}`);
728
229
  }
729
230
  }
730
- } catch {}
231
+ } catch (e) { log('warn', 'git: ' + e.message); }
731
232
  }
732
233
 
733
234
  function runWorktreeAdd(rootDir, worktreePath, args, gitOpts, worktreeCreateRetries) {
@@ -736,7 +237,7 @@ function runWorktreeAdd(rootDir, worktreePath, args, gitOpts, worktreeCreateRetr
736
237
  for (let attempt = 0; attempt <= retries; attempt++) {
737
238
  try {
738
239
  if (attempt > 0) {
739
- try { exec('git worktree prune', { ...gitOpts, cwd: rootDir, timeout: 15000 }); } catch {}
240
+ try { exec('git worktree prune', { ...gitOpts, cwd: rootDir, timeout: 15000 }); } catch (e) { log('warn', 'git: ' + e.message); }
740
241
  removeStaleIndexLock(rootDir);
741
242
  log('warn', `Retrying git worktree add (attempt ${attempt + 1}/${retries + 1}) for ${path.basename(worktreePath)}`);
742
243
  }
@@ -795,8 +296,8 @@ function spawnAgent(dispatchItem, config) {
795
296
  if (existingWt) {
796
297
  worktreePath = existingWt;
797
298
  log('info', `Reusing existing worktree for ${branchName}: ${existingWt}`);
798
- try { exec(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch {}
799
- try { exec(`git pull origin "${branchName}"`, { ..._gitOpts, cwd: existingWt }); } catch {}
299
+ try { exec(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
300
+ try { exec(`git pull origin "${branchName}"`, { ..._gitOpts, cwd: existingWt }); } catch (e) { log('warn', 'git: ' + e.message); }
800
301
  } else if (type !== 'implement') {
801
302
  // Only implement tasks may create new worktrees.
802
303
  // Other task types are reuse-only: if no existing worktree, run in rootDir.
@@ -808,13 +309,13 @@ function spawnAgent(dispatchItem, config) {
808
309
  if (!fs.existsSync(worktreePath)) {
809
310
  const isSharedBranch = meta?.branchStrategy === 'shared-branch' || meta?.useExistingBranch;
810
311
  // Prune stale worktree entries before creating (handles leftover entries from crashed runs)
811
- try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch {}
312
+ try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
812
313
  // Remove stale index.lock before creating worktree (Windows crashes can leave this behind)
813
314
  removeStaleIndexLock(rootDir);
814
315
 
815
316
  if (isSharedBranch) {
816
317
  log('info', `Creating worktree for shared branch: ${worktreePath} on ${branchName}`);
817
- try { exec(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch {}
318
+ try { exec(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
818
319
  try {
819
320
  runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
820
321
  } catch (eShared) {
@@ -832,7 +333,7 @@ function spawnAgent(dispatchItem, config) {
832
333
  runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${sanitizeBranch(project.mainBranch || 'main')}`, _worktreeGitOpts, worktreeCreateRetries);
833
334
  } catch (e1) {
834
335
  // Branch already exists or checked out elsewhere — try without -b
835
- try { exec(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch {}
336
+ try { exec(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
836
337
  try {
837
338
  runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
838
339
  log('info', `Reusing existing branch: ${branchName}`);
@@ -859,12 +360,12 @@ function spawnAgent(dispatchItem, config) {
859
360
  } else if (existingWtPath && !fs.existsSync(existingWtPath)) {
860
361
  // Directory gone but git still tracks it — prune and recreate
861
362
  log('warn', `Branch ${branchName} tracked in missing dir ${existingWtPath} — pruning and recreating`);
862
- try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch {}
363
+ try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
863
364
  runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
864
365
  log('info', `Recovered worktree for ${branchName} after stale entry prune`);
865
366
  } else {
866
367
  // Can't find the worktree at all — prune and retry
867
- try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch {}
368
+ try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
868
369
  runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
869
370
  }
870
371
  } else {
@@ -875,7 +376,7 @@ function spawnAgent(dispatchItem, config) {
875
376
  }
876
377
  } else if (meta?.branchStrategy === 'shared-branch') {
877
378
  log('info', `Pulling latest on shared branch ${branchName}`);
878
- try { exec(`git pull origin "${branchName}"`, { ..._gitOpts, cwd: worktreePath }); } catch {}
379
+ try { exec(`git pull origin "${branchName}"`, { ..._gitOpts, cwd: worktreePath }); } catch (e) { log('warn', 'git: ' + e.message); }
879
380
  }
880
381
  } catch (err) {
881
382
  if (recoverPartialWorktree(rootDir, worktreePath, branchName, _gitOpts)) {
@@ -956,7 +457,7 @@ function spawnAgent(dispatchItem, config) {
956
457
  log('info', `Resuming session ${sessionFile.sessionId} for ${agentId} on branch ${branchName} (age: ${Math.round(sessionAge / 60000)}min)`);
957
458
  }
958
459
  }
959
- } catch {}
460
+ } catch (e) { log('warn', 'session resume lookup: ' + e.message); }
960
461
  }
961
462
 
962
463
  // MCP servers: agents inherit from ~/.claude.json directly as Claude Code processes.
@@ -997,14 +498,14 @@ function spawnAgent(dispatchItem, config) {
997
498
  const silentMs = Date.now() - lastOutputAt;
998
499
  if (silentMs < 30000) return;
999
500
  const silentSec = Math.round(silentMs / 1000);
1000
- try { fs.appendFileSync(liveOutputPath, `[heartbeat] running — no output for ${silentSec}s\n`); } catch {}
501
+ try { fs.appendFileSync(liveOutputPath, `[heartbeat] running — no output for ${silentSec}s\n`); } catch { /* optional */ }
1001
502
  }, 30000);
1002
503
 
1003
504
  proc.stdout.on('data', (data) => {
1004
505
  const chunk = data.toString();
1005
506
  lastOutputAt = Date.now();
1006
507
  if (stdout.length < MAX_OUTPUT) stdout += chunk.slice(0, MAX_OUTPUT - stdout.length);
1007
- try { fs.appendFileSync(liveOutputPath, chunk); } catch {}
508
+ try { fs.appendFileSync(liveOutputPath, chunk); } catch { /* optional */ }
1008
509
 
1009
510
  // Capture sessionId early for mid-session steering
1010
511
  const procInfo = activeProcesses.get(id);
@@ -1021,7 +522,7 @@ function spawnAgent(dispatchItem, config) {
1021
522
  break;
1022
523
  }
1023
524
  }
1024
- } catch {}
525
+ } catch { /* JSON parse — output may not be valid JSON */ }
1025
526
  }
1026
527
  });
1027
528
 
@@ -1029,7 +530,7 @@ function spawnAgent(dispatchItem, config) {
1029
530
  const chunk = data.toString();
1030
531
  lastOutputAt = Date.now();
1031
532
  if (stderr.length < MAX_OUTPUT) stderr += chunk.slice(0, MAX_OUTPUT - stderr.length);
1032
- try { fs.appendFileSync(liveOutputPath, '[stderr] ' + chunk); } catch {}
533
+ try { fs.appendFileSync(liveOutputPath, '[stderr] ' + chunk); } catch { /* optional */ }
1033
534
  });
1034
535
 
1035
536
  function onAgentClose(code) {
@@ -1077,13 +578,13 @@ function spawnAgent(dispatchItem, config) {
1077
578
  const chunk = data.toString();
1078
579
  lastOutputAt = Date.now();
1079
580
  if (stdout.length < MAX_OUTPUT) stdout += chunk.slice(0, MAX_OUTPUT - stdout.length);
1080
- try { fs.appendFileSync(liveOutputPath, chunk); } catch {}
581
+ try { fs.appendFileSync(liveOutputPath, chunk); } catch { /* optional */ }
1081
582
  });
1082
583
  resumeProc.stderr.on('data', (data) => {
1083
584
  const chunk = data.toString();
1084
585
  lastOutputAt = Date.now();
1085
586
  if (stderr.length < MAX_OUTPUT) stderr += chunk.slice(0, MAX_OUTPUT - stderr.length);
1086
- try { fs.appendFileSync(liveOutputPath, '[stderr] ' + chunk); } catch {}
587
+ try { fs.appendFileSync(liveOutputPath, '[stderr] ' + chunk); } catch { /* optional */ }
1087
588
  });
1088
589
 
1089
590
  // Re-wire close handler for the resumed process
@@ -1106,9 +607,9 @@ function spawnAgent(dispatchItem, config) {
1106
607
  const stillActive = (dispatchNow.active || []).some(d => d.id === id);
1107
608
  if (!stillActive) {
1108
609
  log('info', `Agent ${agentId} (${id}) close event ignored — dispatch already completed elsewhere`);
1109
- try { fs.unlinkSync(sysPromptPath); } catch {}
1110
- try { fs.unlinkSync(promptPath); } catch {}
1111
- try { fs.unlinkSync(promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid')); } catch {}
610
+ try { fs.unlinkSync(sysPromptPath); } catch { /* cleanup */ }
611
+ try { fs.unlinkSync(promptPath); } catch { /* cleanup */ }
612
+ try { fs.unlinkSync(promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid')); } catch { /* cleanup */ }
1112
613
  return;
1113
614
  }
1114
615
 
@@ -1124,9 +625,9 @@ function spawnAgent(dispatchItem, config) {
1124
625
  const errMsg = stderr.includes('claude-code') ? stderr.trim() : 'Configuration error — Claude Code CLI not found. Install with: npm install -g @anthropic-ai/claude-code';
1125
626
  log('error', `Agent ${agentId} (${id}) failed: ${errMsg}`);
1126
627
  completeDispatch(id, 'error', errMsg, '');
1127
- try { fs.unlinkSync(sysPromptPath); } catch {}
1128
- try { fs.unlinkSync(promptPath); } catch {}
1129
- try { fs.unlinkSync(promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid')); } catch {}
628
+ try { fs.unlinkSync(sysPromptPath); } catch { /* cleanup */ }
629
+ try { fs.unlinkSync(promptPath); } catch { /* cleanup */ }
630
+ try { fs.unlinkSync(promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid')); } catch { /* cleanup */ }
1130
631
  return;
1131
632
  }
1132
633
 
@@ -1137,9 +638,9 @@ function spawnAgent(dispatchItem, config) {
1137
638
  completeDispatch(id, code === 0 ? 'success' : 'error', '', resultSummary);
1138
639
 
1139
640
  // Cleanup temp files (including PID file now that dispatch is complete)
1140
- try { fs.unlinkSync(sysPromptPath); } catch {}
1141
- try { fs.unlinkSync(promptPath); } catch {}
1142
- try { fs.unlinkSync(promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid')); } catch {}
641
+ try { fs.unlinkSync(sysPromptPath); } catch { /* cleanup */ }
642
+ try { fs.unlinkSync(promptPath); } catch { /* cleanup */ }
643
+ try { fs.unlinkSync(promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid')); } catch { /* cleanup */ }
1143
644
 
1144
645
  log('info', `Agent ${agentId} completed. Output saved to ${archivePath}`);
1145
646
 
@@ -1151,7 +652,7 @@ function spawnAgent(dispatchItem, config) {
1151
652
  // Keep output archive but remove temp agent directory (live-output.log etc.)
1152
653
  fs.rmSync(agentDir, { recursive: true, force: true });
1153
654
  log('info', `Temp agent ${agentId} cleaned up`);
1154
- } catch {}
655
+ } catch { /* cleanup */ }
1155
656
  }
1156
657
  }
1157
658
 
@@ -1287,7 +788,7 @@ function completeDispatch(id, result = 'success', reason = '', resultSummary = '
1287
788
  const wi = items.find(i => i.id === item.meta.item.id);
1288
789
  if (wi) retries = wi._retryCount || 0;
1289
790
  }
1290
- } catch {}
791
+ } catch (e) { log('warn', 'read retry count: ' + e.message); }
1291
792
  if (retryableFailure && retries < 3) {
1292
793
  log('info', `Dispatch error for ${item.meta.item.id} — auto-retry ${retries + 1}/3`);
1293
794
  updateWorkItemStatus(item.meta, 'pending', '');
@@ -1298,7 +799,7 @@ function completeDispatch(id, result = 'success', reason = '', resultSummary = '
1298
799
  dp.completed = Array.isArray(dp.completed) ? dp.completed.filter(d => d.meta?.dispatchKey !== item.meta.dispatchKey) : [];
1299
800
  return dp;
1300
801
  });
1301
- } catch {}
802
+ } catch (e) { log('warn', 'clear dispatch for retry: ' + e.message); }
1302
803
  }
1303
804
  // Increment retry counter on the source work item
1304
805
  try {
@@ -1320,7 +821,7 @@ function completeDispatch(id, result = 'success', reason = '', resultSummary = '
1320
821
  safeWrite(wiPath, items);
1321
822
  }
1322
823
  }
1323
- } catch {}
824
+ } catch (e) { log('warn', 'increment retry counter: ' + e.message); }
1324
825
  } else {
1325
826
  const finalReason = !retryableFailure
1326
827
  ? `Non-retryable failure: ${reason || 'Unknown error'}`
@@ -1349,7 +850,7 @@ function completeDispatch(id, result = 'success', reason = '', resultSummary = '
1349
850
  `These items cannot dispatch until \`${failedId}\` is fixed and reset to \`pending\`.\n`
1350
851
  : `No downstream items are blocked.\n`)
1351
852
  );
1352
- } catch {}
853
+ } catch (e) { log('warn', 'write failure alert: ' + e.message); }
1353
854
  }
1354
855
  }
1355
856
  }
@@ -1370,7 +871,7 @@ function areDependenciesMet(item, config) {
1370
871
  try {
1371
872
  const wi = safeJson(projectWorkItemsPath(p)) || [];
1372
873
  allWorkItems = allWorkItems.concat(wi);
1373
- } catch {}
874
+ } catch (e) { log('warn', 'read project work items for deps: ' + e.message); }
1374
875
  }
1375
876
  // PRD item statuses that count as "done" for dep resolution
1376
877
  const PRD_MET_STATUSES = new Set(['done', 'in-pr', 'implemented', 'complete']);
@@ -1383,7 +884,7 @@ function areDependenciesMet(item, config) {
1383
884
  const plan = safeJson(path.join(PRD_DIR, sourcePlan));
1384
885
  const prdItem = (plan?.missing_features || []).find(f => f.id === depId);
1385
886
  if (prdItem && PRD_MET_STATUSES.has(prdItem.status)) continue; // PRD says done — treat as met
1386
- } catch {}
887
+ } catch (e) { log('warn', 'check PRD dep status: ' + e.message); }
1387
888
  log('warn', `Dependency ${depId} not found for ${item.id} (plan: ${sourcePlan}) — treating as unmet`);
1388
889
  return false;
1389
890
  }
@@ -1419,7 +920,7 @@ function writeInboxAlert(slug, content) {
1419
920
  const existing = safeReadDir(INBOX_DIR).find(f => f.startsWith(`engine-alert-${slug}-${dateStamp()}`));
1420
921
  if (existing) return;
1421
922
  safeWrite(file, content);
1422
- } catch {}
923
+ } catch (e) { log('warn', 'write inbox alert: ' + e.message); }
1423
924
  }
1424
925
 
1425
926
  // Reconciles work items against known PRs.
@@ -1526,7 +1027,7 @@ function checkSteering(config) {
1526
1027
  if (!fs.existsSync(steerPath)) continue;
1527
1028
 
1528
1029
  const message = safeRead(steerPath);
1529
- try { fs.unlinkSync(steerPath); } catch {}
1030
+ try { fs.unlinkSync(steerPath); } catch { /* cleanup */ }
1530
1031
  if (!message) continue;
1531
1032
 
1532
1033
  const sessionId = info.sessionId;
@@ -1538,7 +1039,7 @@ function checkSteering(config) {
1538
1039
  log('info', `Steering: killing ${info.agentId} (${id}) for session resume with human message`);
1539
1040
 
1540
1041
  // Kill current process
1541
- try { info.proc.kill('SIGTERM'); } catch {}
1042
+ try { info.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
1542
1043
 
1543
1044
  // Store steering context for re-spawn on close
1544
1045
  info._steeringMessage = message;
@@ -1558,9 +1059,9 @@ function checkTimeouts(config) {
1558
1059
  const elapsed = Date.now() - new Date(info.startedAt).getTime();
1559
1060
  if (elapsed > itemTimeout) {
1560
1061
  log('warn', `Agent ${info.agentId} (${id}) hit hard timeout after ${Math.round(elapsed / 1000)}s — killing`);
1561
- try { info.proc.kill('SIGTERM'); } catch {}
1062
+ try { info.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
1562
1063
  setTimeout(() => {
1563
- try { info.proc.kill('SIGKILL'); } catch {}
1064
+ try { info.proc.kill('SIGKILL'); } catch { /* process may be dead */ }
1564
1065
  }, 5000);
1565
1066
  }
1566
1067
  }
@@ -1581,7 +1082,7 @@ function checkTimeouts(config) {
1581
1082
  try {
1582
1083
  const stat = fs.statSync(liveLogPath);
1583
1084
  lastActivity = Math.max(lastActivity, stat.mtimeMs);
1584
- } catch {}
1085
+ } catch { /* optional */ }
1585
1086
 
1586
1087
  const silentMs = Date.now() - lastActivity;
1587
1088
  const silentSec = Math.round(silentMs / 1000);
@@ -1605,7 +1106,7 @@ function checkTimeouts(config) {
1605
1106
  const result = JSON.parse(resultLine);
1606
1107
  safeWrite(outputLogPath, `# Output for dispatch ${item.id}\n# Exit code: ${isSuccess ? 0 : 1}\n# Completed: ${ts()}\n# Detected via output scan\n\n## Result\n${result.result || '(no text)'}\n`);
1607
1108
  }
1608
- } catch {}
1109
+ } catch (e) { log('warn', 'parse output result: ' + e.message); }
1609
1110
 
1610
1111
  completeDispatch(item.id, isSuccess ? 'success' : 'error', 'Completed (detected from output)');
1611
1112
 
@@ -1613,12 +1114,12 @@ function checkTimeouts(config) {
1613
1114
  runPostCompletionHooks(item, item.agent, isSuccess ? 0 : 1, liveLog, config);
1614
1115
 
1615
1116
  if (hasProcess) {
1616
- try { activeProcesses.get(item.id)?.proc.kill('SIGTERM'); } catch {}
1117
+ try { activeProcesses.get(item.id)?.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
1617
1118
  activeProcesses.delete(item.id);
1618
1119
  }
1619
1120
  continue; // Skip orphan/hung detection — we handled it
1620
1121
  }
1621
- } catch {}
1122
+ } catch (e) { log('warn', 'output completion detection: ' + e.message); }
1622
1123
 
1623
1124
  // Check if agent is in a blocking tool call (TaskOutput block:true, Bash with long timeout, etc.)
1624
1125
  // These tools produce no stdout for extended periods — don't kill them prematurely
@@ -1652,13 +1153,13 @@ function checkTimeouts(config) {
1652
1153
  isBlocking = true;
1653
1154
  }
1654
1155
  break; // only check the most recent tool_use
1655
- } catch {}
1156
+ } catch { /* JSON parse — line may not be valid JSON */ }
1656
1157
  }
1657
1158
  if (isBlocking) {
1658
1159
  log('info', `Agent ${item.agent} (${item.id}) is in a blocking tool call — extended timeout to ${Math.round(blockingTimeout / 1000)}s (silent for ${silentSec}s)`);
1659
1160
  }
1660
1161
  }
1661
- } catch {}
1162
+ } catch (e) { log('warn', 'blocking tool detection: ' + e.message); }
1662
1163
  }
1663
1164
 
1664
1165
  const effectiveTimeout = isBlocking ? blockingTimeout : heartbeatTimeout;
@@ -1672,8 +1173,8 @@ function checkTimeouts(config) {
1672
1173
  log('warn', `Hung agent: ${item.agent} (${item.id}) — process exists but no output for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
1673
1174
  const procInfo = activeProcesses.get(item.id);
1674
1175
  if (procInfo) {
1675
- try { procInfo.proc.kill('SIGTERM'); } catch {}
1676
- setTimeout(() => { try { procInfo.proc.kill('SIGKILL'); } catch {} }, 5000);
1176
+ try { procInfo.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
1177
+ setTimeout(() => { try { procInfo.proc.kill('SIGKILL'); } catch { /* process may be dead */ } }, 5000);
1677
1178
  activeProcesses.delete(item.id);
1678
1179
  }
1679
1180
  deadItems.push({ item, reason: `Hung — no output for ${silentSec}s` });
@@ -1752,11 +1253,11 @@ function runCleanup(config, verbose = false) {
1752
1253
  fs.unlinkSync(fp);
1753
1254
  cleaned.tempFiles++;
1754
1255
  }
1755
- } catch {}
1256
+ } catch { /* cleanup */ }
1756
1257
  }
1757
1258
  }
1758
1259
  }
1759
- } catch {}
1260
+ } catch (e) { log('warn', 'cleanup temp files: ' + e.message); }
1760
1261
 
1761
1262
  // 2. Clean live-output.log for idle agents (not currently working)
1762
1263
  for (const [agentId] of Object.entries(config.agents || {})) {
@@ -1770,7 +1271,7 @@ function runCleanup(config, verbose = false) {
1770
1271
  fs.unlinkSync(livePath);
1771
1272
  cleaned.liveOutputs++;
1772
1273
  }
1773
- } catch {}
1274
+ } catch { /* cleanup */ }
1774
1275
  }
1775
1276
  }
1776
1277
  }
@@ -1835,7 +1336,7 @@ function runCleanup(config, verbose = false) {
1835
1336
  if (ageMs > 7200000 && !isReferenced) { // 2 hours
1836
1337
  shouldClean = true;
1837
1338
  }
1838
- } catch {}
1339
+ } catch { /* optional */ }
1839
1340
  }
1840
1341
 
1841
1342
  // Skip worktrees for active shared-branch plans (check both prd/ and plans/ for .json PRDs)
@@ -1859,7 +1360,7 @@ function runCleanup(config, verbose = false) {
1859
1360
  }
1860
1361
  if (isProtected) break;
1861
1362
  }
1862
- } catch {}
1363
+ } catch (e) { log('warn', 'check shared-branch protection: ' + e.message); }
1863
1364
  }
1864
1365
 
1865
1366
  wtEntries.push({ dir, wtPath, mtime, shouldClean, isProtected });
@@ -1889,7 +1390,7 @@ function runCleanup(config, verbose = false) {
1889
1390
  }
1890
1391
  }
1891
1392
  }
1892
- } catch {}
1393
+ } catch (e) { log('warn', 'cleanup worktrees: ' + e.message); }
1893
1394
  }
1894
1395
 
1895
1396
  // 4. Kill zombie claude processes not tracked by the engine
@@ -1905,15 +1406,15 @@ function runCleanup(config, verbose = false) {
1905
1406
  const activeIds = new Set((dispatch.active || []).map(d => d.id));
1906
1407
  for (const [id, info] of activeProcesses.entries()) {
1907
1408
  if (!activeIds.has(id)) {
1908
- try { if (info.proc) info.proc.kill('SIGTERM'); } catch {}
1409
+ try { if (info.proc) info.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
1909
1410
  activeProcesses.delete(id);
1910
1411
  cleaned.zombies++;
1911
1412
  }
1912
1413
  }
1913
- } catch {}
1414
+ } catch (e) { log('warn', 'cleanup zombie processes: ' + e.message); }
1914
1415
 
1915
1416
  // 5. Clean spawn-debug.log
1916
- try { fs.unlinkSync(path.join(ENGINE_DIR, 'spawn-debug.log')); } catch {}
1417
+ try { fs.unlinkSync(path.join(ENGINE_DIR, 'spawn-debug.log')); } catch { /* cleanup */ }
1917
1418
 
1918
1419
  // 6. Prune old output archive files (keep last 30 per agent)
1919
1420
  for (const agentId of Object.keys(config.agents || {})) {
@@ -1925,9 +1426,9 @@ function runCleanup(config, verbose = false) {
1925
1426
  .map(f => ({ name: f, mtime: fs.statSync(path.join(agentDir, f)).mtimeMs }))
1926
1427
  .sort((a, b) => b.mtime - a.mtime);
1927
1428
  for (const old of outputFiles.slice(30)) {
1928
- try { fs.unlinkSync(path.join(agentDir, old.name)); cleaned.files++; } catch {}
1429
+ try { fs.unlinkSync(path.join(agentDir, old.name)); cleaned.files++; } catch { /* cleanup */ }
1929
1430
  }
1930
- } catch {}
1431
+ } catch (e) { log('warn', 'prune output archives: ' + e.message); }
1931
1432
  }
1932
1433
 
1933
1434
  // 7. Prune orphaned dispatch entries — items whose source work item no longer exists
@@ -1939,12 +1440,12 @@ function runCleanup(config, verbose = false) {
1939
1440
  try {
1940
1441
  const central = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
1941
1442
  central.forEach(w => allWiIds.add(w.id));
1942
- } catch {}
1443
+ } catch (e) { log('warn', 'read central work items for orphan check: ' + e.message); }
1943
1444
  for (const project of projects) {
1944
1445
  try {
1945
1446
  const projItems = safeJson(projectWorkItemsPath(project)) || [];
1946
1447
  projItems.forEach(w => allWiIds.add(w.id));
1947
- } catch {}
1448
+ } catch (e) { log('warn', 'read project work items for orphan check: ' + e.message); }
1948
1449
  }
1949
1450
 
1950
1451
  let changed = false;
@@ -1974,7 +1475,7 @@ function runCleanup(config, verbose = false) {
1974
1475
  }
1975
1476
  });
1976
1477
  }
1977
- } catch {}
1478
+ } catch (e) { log('warn', 'prune orphaned dispatches: ' + e.message); }
1978
1479
 
1979
1480
  if (cleaned.tempFiles + cleaned.liveOutputs + cleaned.worktrees + cleaned.zombies + (cleaned.files || 0) + cleaned.orphanedDispatches > 0) {
1980
1481
  log('info', `Cleanup: ${cleaned.tempFiles} temp, ${cleaned.liveOutputs} live outputs, ${cleaned.worktrees} worktrees, ${cleaned.zombies} zombies, ${cleaned.files || 0} archives, ${cleaned.orphanedDispatches} orphaned dispatches`);
@@ -1993,10 +1494,10 @@ function runCleanup(config, verbose = false) {
1993
1494
  if (!cleaned.sweptKb) cleaned.sweptKb = 0;
1994
1495
  cleaned.sweptKb++;
1995
1496
  }
1996
- } catch {}
1497
+ } catch { /* cleanup */ }
1997
1498
  }
1998
1499
  }
1999
- } catch {}
1500
+ } catch (e) { log('warn', 'cleanup swept KB files: ' + e.message); }
2000
1501
 
2001
1502
  // 9. KB watchdog — restore deleted KB files from git if count dropped vs checkpoint
2002
1503
  try {
@@ -2024,7 +1525,7 @@ function runCleanup(config, verbose = false) {
2024
1525
  }
2025
1526
  }
2026
1527
  }
2027
- } catch {}
1528
+ } catch (e) { log('warn', 'KB watchdog check: ' + e.message); }
2028
1529
 
2029
1530
  // 6. Migrate legacy work-item statuses to canonical values
2030
1531
  // in-pr, implemented, complete → done (one-time correction per item)
@@ -2044,7 +1545,7 @@ function runCleanup(config, verbose = false) {
2044
1545
  safeWrite(wiPath, items);
2045
1546
  log('info', `Migrated ${migrated} legacy status(es) → done in ${project.name} work items`);
2046
1547
  }
2047
- } catch {}
1548
+ } catch (e) { log('warn', 'migrate legacy statuses: ' + e.message); }
2048
1549
  }
2049
1550
  // Central work items
2050
1551
  try {
@@ -2061,7 +1562,7 @@ function runCleanup(config, verbose = false) {
2061
1562
  safeWrite(centralPath, centralItems);
2062
1563
  log('info', `Migrated ${migrated} legacy status(es) → done in central work items`);
2063
1564
  }
2064
- } catch {}
1565
+ } catch (e) { log('warn', 'migrate central legacy statuses: ' + e.message); }
2065
1566
  // PRD items (missing_features[].status)
2066
1567
  try {
2067
1568
  const prdFiles = fs.readdirSync(PRD_DIR).filter(f => f.endsWith('.json'));
@@ -2081,101 +1582,16 @@ function runCleanup(config, verbose = false) {
2081
1582
  log('info', `Migrated ${migrated} legacy PRD item status(es) → done in ${pf}`);
2082
1583
  }
2083
1584
  }
2084
- } catch {}
1585
+ } catch (e) { log('warn', 'migrate PRD legacy statuses: ' + e.message); }
2085
1586
 
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
 
@@ -2204,7 +1620,7 @@ function autoCleanPrdWorkItems(prdFile, config) {
2204
1620
  return true;
2205
1621
  });
2206
1622
  if (filtered.length < items.length) safeWrite(wiPath, filtered);
2207
- } catch {}
1623
+ } catch (e) { log('warn', 'auto-clean PRD work items: ' + e.message); }
2208
1624
  }
2209
1625
  if (deletedIds.length > 0) {
2210
1626
  const deletedSet = new Set(deletedIds);
@@ -2214,7 +1630,7 @@ function autoCleanPrdWorkItems(prdFile, config) {
2214
1630
  }
2215
1631
 
2216
1632
  function materializePlansAsWorkItems(config) {
2217
- if (!fs.existsSync(PRD_DIR)) { try { fs.mkdirSync(PRD_DIR, { recursive: true }); } catch {} }
1633
+ if (!fs.existsSync(PRD_DIR)) { try { fs.mkdirSync(PRD_DIR, { recursive: true }); } catch (e) { log('warn', 'create PRD directory: ' + e.message); } }
2218
1634
 
2219
1635
  // Enforce: PRDs must be .json — auto-rename .md files that contain valid PRD JSON
2220
1636
  // Check both prd/ and plans/ (agents may still write JSON to plans/)
@@ -2231,12 +1647,12 @@ function materializePlansAsWorkItems(config) {
2231
1647
  if (parsed.missing_features) {
2232
1648
  const jsonName = mf.replace(/\.md$/, '.json');
2233
1649
  safeWrite(path.join(PRD_DIR, jsonName), parsed);
2234
- try { fs.unlinkSync(path.join(checkDir, mf)); } catch {}
1650
+ try { fs.unlinkSync(path.join(checkDir, mf)); } catch { /* cleanup */ }
2235
1651
  log('info', `Plan enforcement: moved ${mf} → prd/${jsonName} (PRDs must be .json in prd/)`);
2236
1652
  }
2237
1653
  } catch {} // Not JSON — it's a proper plan .md, leave it
2238
1654
  }
2239
- } catch {}
1655
+ } catch (e) { log('warn', 'scan .md files for PRD enforcement: ' + e.message); }
2240
1656
  // Also migrate any .json PRD files from plans/ to prd/
2241
1657
  if (checkDir === PLANS_DIR) {
2242
1658
  try {
@@ -2246,12 +1662,12 @@ function materializePlansAsWorkItems(config) {
2246
1662
  const parsed = safeJson(path.join(PLANS_DIR, jf));
2247
1663
  if (parsed?.missing_features) {
2248
1664
  safeWrite(path.join(PRD_DIR, jf), parsed);
2249
- try { fs.unlinkSync(path.join(PLANS_DIR, jf)); } catch {}
1665
+ try { fs.unlinkSync(path.join(PLANS_DIR, jf)); } catch { /* cleanup */ }
2250
1666
  log('info', `Auto-migrated PRD ${jf} from plans/ to prd/`);
2251
1667
  }
2252
- } catch {}
1668
+ } catch (e) { log('warn', 'migrate PRD from plans: ' + e.message); }
2253
1669
  }
2254
- } catch {}
1670
+ } catch (e) { log('warn', 'scan JSON in plans dir: ' + e.message); }
2255
1671
  }
2256
1672
  }
2257
1673
 
@@ -2303,7 +1719,7 @@ function materializePlansAsWorkItems(config) {
2303
1719
  : '';
2304
1720
 
2305
1721
  // Delete old PRD — agent will write replacement at same path
2306
- try { fs.unlinkSync(path.join(PRD_DIR, file)); } catch {}
1722
+ try { fs.unlinkSync(path.join(PRD_DIR, file)); } catch { /* cleanup */ }
2307
1723
 
2308
1724
  // Queue plan-to-prd regeneration
2309
1725
  const planContent = safeRead(path.join(PLANS_DIR, plan.source_plan));
@@ -2341,7 +1757,7 @@ function materializePlansAsWorkItems(config) {
2341
1757
 
2342
1758
  safeWrite(path.join(PRD_DIR, file), plan);
2343
1759
  }
2344
- } catch {}
1760
+ } catch (e) { log('warn', 'plan staleness check: ' + e.message); }
2345
1761
  }
2346
1762
 
2347
1763
  // Human approval gate: plans start as 'awaiting-approval' and must be approved before work begins
@@ -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;
@@ -2561,7 +1934,7 @@ function clearPendingHumanFeedbackFlag(projectMeta, prId) {
2561
1934
  if (!target?.humanFeedback?.pendingFix) return;
2562
1935
  target.humanFeedback.pendingFix = false;
2563
1936
  safeWrite(prsPath, prs);
2564
- } catch {}
1937
+ } catch (e) { log('warn', 'clear pending human feedback flag: ' + e.message); }
2565
1938
  }
2566
1939
 
2567
1940
  /**
@@ -2681,7 +2054,7 @@ function discoverFromPrs(config, project) {
2681
2054
  target._buildFailNotified = true;
2682
2055
  safeWrite(prPath, prs);
2683
2056
  }
2684
- } catch {}
2057
+ } catch (e) { log('warn', 'mark build fail notified: ' + e.message); }
2685
2058
  }
2686
2059
  }
2687
2060
 
@@ -2747,7 +2120,7 @@ function discoverFromWorkItems(config, project) {
2747
2120
  return dp.completed.length !== before ? dp : undefined;
2748
2121
  });
2749
2122
  dispatchCooldowns.delete(key);
2750
- } catch {}
2123
+ } catch (e) { log('warn', 'self-heal dispatch state: ' + e.message); }
2751
2124
  // Cooldown bypass for resumed items — clear in-memory cooldown so they dispatch immediately
2752
2125
  if (item._resumedAt) {
2753
2126
  dispatchCooldowns.delete(key);
@@ -2809,7 +2182,7 @@ function discoverFromWorkItems(config, project) {
2809
2182
  commit_message: item.commitMessage || `feat: ${item.title || item.id}`,
2810
2183
  notes_content: '',
2811
2184
  };
2812
- try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
2185
+ try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch { /* optional */ }
2813
2186
 
2814
2187
  // Inject references and acceptance criteria
2815
2188
  const refs = (item.references || []).filter(r => r && r.url).map(r =>
@@ -2824,7 +2197,7 @@ function discoverFromWorkItems(config, project) {
2824
2197
  vars.question = item.title + (item.description ? '\n\n' + item.description : '');
2825
2198
  vars.task_id = item.id;
2826
2199
  vars.notes_content = '';
2827
- try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
2200
+ try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch { /* optional */ }
2828
2201
  }
2829
2202
 
2830
2203
  // Resolve implicit context references (e.g., "ripley's plan", "the latest plan")
@@ -2954,7 +2327,7 @@ function materializeSpecsAsWorkItems(config, project) {
2954
2327
  recentSpecs.push({ file: line.trim(), ...currentCommit });
2955
2328
  }
2956
2329
  }
2957
- } catch {}
2330
+ } catch (e) { log('warn', 'git: ' + e.message); }
2958
2331
  }
2959
2332
 
2960
2333
  if (recentSpecs.length === 0) return;
@@ -3118,7 +2491,7 @@ function discoverCentralWorkItems(config) {
3118
2491
  vars.question = item.title + (item.description ? '\n\n' + item.description : '');
3119
2492
  vars.task_id = item.id;
3120
2493
  vars.notes_content = '';
3121
- try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
2494
+ try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch { /* optional */ }
3122
2495
  }
3123
2496
 
3124
2497
  const resolvedCtx = resolveTaskContext(item, config);
@@ -3179,7 +2552,7 @@ function discoverCentralWorkItems(config) {
3179
2552
  project_path: firstProject?.localPath || '',
3180
2553
  notes_content: '',
3181
2554
  };
3182
- try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
2555
+ try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch { /* optional */ }
3183
2556
 
3184
2557
  // Inject references and acceptance criteria
3185
2558
  const normRefs = (item.references || []).filter(r => r && r.url).map(r =>
@@ -3199,7 +2572,7 @@ function discoverCentralWorkItems(config) {
3199
2572
  vars.plan_file = planFileName;
3200
2573
  vars.task_description = item.title;
3201
2574
  vars.notes_content = '';
3202
- try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
2575
+ try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch { /* optional */ }
3203
2576
  // Track expected plan filename in meta for chainPlanToPrd
3204
2577
  item._planFileName = planFileName;
3205
2578
  }
@@ -3228,7 +2601,7 @@ function discoverCentralWorkItems(config) {
3228
2601
  vars.question = item.title + (item.description ? '\n\n' + item.description : '');
3229
2602
  vars.task_id = item.id;
3230
2603
  vars.notes_content = '';
3231
- try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
2604
+ try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch { /* optional */ }
3232
2605
  }
3233
2606
 
3234
2607
  // Resolve implicit context references
@@ -3321,7 +2694,7 @@ function discoverWork(config) {
3321
2694
  }
3322
2695
  if (added > 0) safeWrite(centralPath, items);
3323
2696
  }
3324
- } catch {}
2697
+ } catch (e) { log('warn', 'discover scheduled work: ' + e.message); }
3325
2698
 
3326
2699
  // Gate reviews and fixes: do not dispatch until all implement items are complete
3327
2700
  const hasIncompleteImplements = projects.some(project => {
@@ -3381,7 +2754,7 @@ async function tickInner() {
3381
2754
  }
3382
2755
 
3383
2756
  // Write heartbeat so dashboard can detect stale engine
3384
- try { safeWrite(CONTROL_PATH, { ...control, heartbeat: Date.now() }); } catch {}
2757
+ try { safeWrite(CONTROL_PATH, { ...control, heartbeat: Date.now() }); } catch (e) { log('warn', 'write heartbeat: ' + e.message); }
3385
2758
 
3386
2759
  const config = getConfig();
3387
2760
  tickCount++;
@@ -3480,7 +2853,7 @@ async function tickInner() {
3480
2853
  dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
3481
2854
  if (dp.completed.length !== before) return dp;
3482
2855
  });
3483
- } catch {}
2856
+ } catch (e) { log('warn', 'stall recovery clear dispatch: ' + e.message); }
3484
2857
 
3485
2858
  // Clear cooldown so item isn't blocked by exponential backoff
3486
2859
  try {
@@ -3489,7 +2862,7 @@ async function tickInner() {
3489
2862
  dispatchCooldowns.delete(key);
3490
2863
  saveCooldowns();
3491
2864
  }
3492
- } catch {}
2865
+ } catch (e) { log('warn', 'stall recovery clear cooldown: ' + e.message); }
3493
2866
  }
3494
2867
  }
3495
2868
 
@@ -3513,14 +2886,14 @@ async function tickInner() {
3513
2886
  mutateDispatch((dp) => {
3514
2887
  dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
3515
2888
  });
3516
- } catch {}
2889
+ } catch (e) { log('warn', 'stall recovery clear dependent dispatch: ' + e.message); }
3517
2890
  }
3518
2891
  }
3519
2892
  }
3520
2893
  }
3521
2894
 
3522
2895
  if (changed) safeWrite(wiPath, items);
3523
- } catch {}
2896
+ } catch (e) { log('warn', 'stall recovery process project: ' + e.message); }
3524
2897
  }
3525
2898
  }
3526
2899
  } catch (err) { log('warn', `Stall detection error: ${err?.message || err}`); }