@yemi33/minions 0.1.1680 → 0.1.1682

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.
@@ -69,6 +69,12 @@ function checkPlanCompletion(meta, config) {
69
69
 
70
70
  const doneItems = planItems.filter(w => DONE_STATUSES.has(w.status));
71
71
  const failedItems = planItems.filter(w => w.status === WI_STATUS.FAILED || w.status === WI_STATUS.CANCELLED);
72
+ const isActiveVerify = (wi) => wi && (
73
+ wi.status === WI_STATUS.PENDING ||
74
+ wi.status === WI_STATUS.QUEUED ||
75
+ wi.status === WI_STATUS.DISPATCHED
76
+ );
77
+ const isReopenableVerify = (wi) => wi && (DONE_STATUSES.has(wi.status) || wi.status === WI_STATUS.FAILED);
72
78
 
73
79
  if (failedItems.length > 0) {
74
80
  const failDetails = failedItems.map(w =>
@@ -166,6 +172,7 @@ function checkPlanCompletion(meta, config) {
166
172
  const mainBranch = shared.resolveMainBranch(primaryProject.localPath, primaryProject.mainBranch);
167
173
  const itemSummary = doneItems.map(w => '- ' + w.id + ': ' + w.title.replace('Implement: ', '')).join('\n');
168
174
  mutateWorkItems(wiPath, workItems => {
175
+ if (workItems.some(w => w.sourcePlan === planFile && w.itemType === 'pr')) return workItems;
169
176
  workItems.push({
170
177
  id, title: `Create PR for plan: ${plan.plan_summary || planFile}`,
171
178
  type: 'implement', priority: 'high',
@@ -181,9 +188,23 @@ function checkPlanCompletion(meta, config) {
181
188
  // 4. Create verification work item (build, test, start webapp, write testing guide)
182
189
  // Only one verify per PRD — skip if pending/dispatched, re-open if done/failed (PRD was modified)
183
190
  const existingVerify = allWorkItems.find(w => w.sourcePlan === planFile && w.itemType === 'verify');
184
- if (existingVerify && (existingVerify.status === WI_STATUS.PENDING || existingVerify.status === WI_STATUS.DISPATCHED)) {
191
+ if (isActiveVerify(existingVerify)) {
185
192
  log('info', `Plan ${planFile}: verify WI ${existingVerify.id} already ${existingVerify.status} — skipping`);
186
- } else if (doneItems.length > 0) {
193
+ } else if (isReopenableVerify(existingVerify) && doneItems.length > 0) {
194
+ const verifyProject = existingVerify.project || projectName;
195
+ const vWiPath = shared.projectWorkItemsPath(
196
+ projects.find(p => p.name?.toLowerCase() === verifyProject?.toLowerCase()) || primaryProject
197
+ );
198
+ let reopenedVerify = false;
199
+ mutateWorkItems(vWiPath, items => {
200
+ const v = items.find(w => w.id === existingVerify.id);
201
+ if (isReopenableVerify(v)) {
202
+ shared.reopenWorkItem(v);
203
+ reopenedVerify = true;
204
+ }
205
+ });
206
+ if (reopenedVerify) log('info', `Re-opened verification work item ${existingVerify.id} for modified plan ${planFile}`);
207
+ } else if (!existingVerify && doneItems.length > 0) {
187
208
  const verifyId = 'PL-' + shared.uid();
188
209
  const planSlug = planFile.replace('.json', '');
189
210
 
@@ -276,7 +297,17 @@ function checkPlanCompletion(meta, config) {
276
297
  prSummary,
277
298
  ].join('\n');
278
299
 
300
+ let createdVerify = false;
301
+ let reopenedVerifyId = null;
279
302
  mutateWorkItems(wiPath, workItems => {
303
+ const v = workItems.find(w => w.sourcePlan === planFile && w.itemType === 'verify');
304
+ if (v) {
305
+ if (isReopenableVerify(v)) {
306
+ shared.reopenWorkItem(v);
307
+ reopenedVerifyId = v.id;
308
+ }
309
+ return workItems;
310
+ }
280
311
  workItems.push({
281
312
  id: verifyId,
282
313
  title: `Verify plan: ${(plan.plan_summary || planFile).slice(0, 80)}`,
@@ -290,32 +321,19 @@ function checkPlanCompletion(meta, config) {
290
321
  itemType: 'verify',
291
322
  project: projectName,
292
323
  });
324
+ createdVerify = true;
293
325
  });
294
- log('info', `Created verification work item ${verifyId} for plan ${planFile}`);
326
+ if (createdVerify) {
327
+ log('info', `Created verification work item ${verifyId} for plan ${planFile}`);
295
328
 
296
- // Teams notification for verify creation — non-blocking
297
- try {
298
- const teams = require('./teams');
299
- teams.teamsNotifyPlanEvent({ name: plan.plan_summary || planFile, file: planFile }, 'verify-created').catch(() => {});
300
- } catch {}
301
- } else if (existingVerify && DONE_STATUSES.has(existingVerify.status) && doneItems.length > 0) {
302
- // PRD was modified and re-completed re-open the existing verify instead of creating a duplicate
303
- const verifyProject = existingVerify.project || projectName;
304
- const vWiPath = shared.projectWorkItemsPath(
305
- projects.find(p => p.name?.toLowerCase() === verifyProject?.toLowerCase()) || primaryProject
306
- );
307
- mutateWorkItems(vWiPath, items => {
308
- const v = items.find(w => w.id === existingVerify.id);
309
- if (v && DONE_STATUSES.has(v.status)) {
310
- v.status = WI_STATUS.PENDING;
311
- v._reopened = true;
312
- delete v.completedAt;
313
- delete v.dispatched_to;
314
- delete v.dispatched_at;
315
- v._retryCount = 0;
316
- }
317
- });
318
- log('info', `Re-opened verification work item ${existingVerify.id} for modified plan ${planFile}`);
329
+ // Teams notification for verify creation — non-blocking
330
+ try {
331
+ const teams = require('./teams');
332
+ teams.teamsNotifyPlanEvent({ name: plan.plan_summary || planFile, file: planFile }, 'verify-created').catch(() => {});
333
+ } catch {}
334
+ } else if (reopenedVerifyId) {
335
+ log('info', `Re-opened verification work item ${reopenedVerifyId} for modified plan ${planFile}`);
336
+ }
319
337
  }
320
338
 
321
339
  // Archive deferred until verify completes
@@ -350,9 +368,9 @@ function archivePlan(planFile, plan, projects, config) {
350
368
  // plan completion and spawning duplicate verify tasks for already-archived plans.
351
369
  // On Windows, the unlink can fail due to file locking; overwrite with archived status
352
370
  // as a fallback so a restored backup is inert even if deletion fails.
353
- const backupPath = planPath + '.backup';
354
- try { fs.unlinkSync(backupPath); } catch {
355
- try { fs.writeFileSync(backupPath, JSON.stringify({ status: 'archived' })); } catch { }
371
+ const backupCleanup = shared.neutralizeJsonBackupSidecar(planPath);
372
+ if (!backupCleanup.ok) {
373
+ log('warn', `Archive backup cleanup failed for ${planFile}: unlink failed (${backupCleanup.unlinkError}); fallback neutralize failed (${backupCleanup.writeError})`);
356
374
  }
357
375
  } catch (err) {
358
376
  log('warn', `Failed to archive PRD ${planFile}: ${err.message}`);
@@ -440,95 +458,6 @@ function cleanupPlanWorktrees(planFile, plan, projects, config) {
440
458
  } catch (err) { log('warn', `Plan worktree cleanup: ${err.message}`); }
441
459
  }
442
460
 
443
- // ─── Plan → PRD Chaining ─────────────────────────────────────────────────────
444
- function chainPlanToPrd(dispatchItem, meta, config) {
445
-
446
- const planDir = path.join(MINIONS_DIR, 'plans');
447
- if (!fs.existsSync(planDir)) fs.mkdirSync(planDir, { recursive: true });
448
-
449
- let planFileName = meta?.planFileName || meta?.item?._planFileName;
450
- if (planFileName && fs.existsSync(path.join(planDir, planFileName))) {
451
- // Exact match from meta
452
- } else {
453
- const planFiles = fs.readdirSync(planDir)
454
- .filter(f => f.endsWith('.md') || f.endsWith('.json'))
455
- .map(f => ({ name: f, mtime: fs.statSync(path.join(planDir, f)).mtimeMs }))
456
- .sort((a, b) => b.mtime - a.mtime);
457
- planFileName = planFiles[0]?.name;
458
- if (!planFileName) {
459
- log('warn', `Plan chaining: no plan files found in plans/ after task ${dispatchItem.id}`);
460
- return;
461
- }
462
- log('info', `Plan chaining: using mtime fallback — found ${planFileName}`);
463
- }
464
-
465
- if (planFileName.endsWith('.json')) {
466
- const mdName = planFileName.replace(/\.json$/, '.md');
467
- // Check plans/ first, then prd/ for .json files
468
- const jsonPath = fs.existsSync(path.join(planDir, planFileName))
469
- ? path.join(planDir, planFileName)
470
- : path.join(MINIONS_DIR, 'prd', planFileName);
471
- const mdPath = path.join(planDir, mdName);
472
- try {
473
- const content = fs.readFileSync(jsonPath, 'utf8');
474
- const parsed = JSON.parse(content);
475
- if (!parsed.missing_features) {
476
- fs.renameSync(jsonPath, mdPath);
477
- planFileName = mdName;
478
- log('info', `Plan chaining: renamed ${planFileName} → ${mdName} (plans must be .md)`);
479
- }
480
- } catch {
481
- try {
482
- if (fs.existsSync(jsonPath)) fs.renameSync(jsonPath, path.join(planDir, mdName));
483
- planFileName = mdName;
484
- log('info', `Plan chaining: renamed to .md (not valid JSON)`);
485
- } catch (err) { log('warn', `Plan rename fallback: ${err.message}`); }
486
- }
487
- }
488
-
489
- const planFile = { name: planFileName };
490
- const planPath = path.join(planDir, planFileName);
491
- let planContent;
492
- try { planContent = fs.readFileSync(planPath, 'utf8'); } catch (err) {
493
- log('error', `Plan chaining: failed to read plan file ${planFile.name}: ${err.message}`);
494
- return;
495
- }
496
-
497
- const projectName = meta?.item?.project || meta?.project?.name;
498
- const projects = shared.getProjects(config);
499
- if (projects.length === 0) {
500
- log('error', 'Plan chaining: no projects configured');
501
- return;
502
- }
503
- const targetProject = projectName
504
- ? projects.find(p => p.name === projectName) || projects[0]
505
- : projects[0];
506
-
507
- if (!targetProject) {
508
- log('error', 'Plan chaining: no target project available');
509
- return;
510
- }
511
-
512
- log('info', `Plan chaining: queuing plan-to-prd for next tick (chained from ${dispatchItem.id})`);
513
- const wiPath = path.join(MINIONS_DIR, 'work-items.json');
514
- shared.mutateJsonFileLocked(wiPath, (items) => {
515
- if (!Array.isArray(items)) items = [];
516
- items.push({
517
- id: 'W-' + shared.uid(),
518
- title: `Convert plan to PRD: ${meta?.item?.title || planFile.name}`,
519
- type: 'plan-to-prd',
520
- priority: meta?.item?.priority || 'high',
521
- description: `Plan file: plans/${planFile.name}\nChained from plan task ${dispatchItem.id}`,
522
- status: WI_STATUS.PENDING,
523
- created: ts(),
524
- createdBy: 'engine:chain',
525
- project: targetProject.name,
526
- planFile: planFile.name,
527
- });
528
- return items;
529
- }, { defaultValue: [] });
530
- }
531
-
532
461
  // ─── Work Item Path Resolution ───────────────────────────────────────────────
533
462
 
534
463
  /** Resolve the work-items.json path from dispatch meta. Reused by retry paths. */
@@ -642,15 +571,14 @@ function syncPrdItemStatus(itemId, status, sourcePlan) {
642
571
  const feature = plan?.missing_features?.find(f => f.id === itemId);
643
572
  if (!feature || feature.status === status) continue;
644
573
  let updated = false;
645
- shared.withFileLock(`${fpath}.lock`, () => {
646
- const fresh = safeJson(fpath);
574
+ mutateJsonFileLocked(fpath, (fresh) => {
647
575
  const f = fresh?.missing_features?.find(x => x.id === itemId);
648
576
  if (f && f.status !== status) {
649
577
  f.status = status;
650
- safeWrite(fpath, fresh);
651
578
  updated = true;
652
579
  }
653
- });
580
+ return fresh;
581
+ }, { skipWriteIfUnchanged: true });
654
582
  if (updated) return;
655
583
  }
656
584
  } catch (err) { log('warn', `PRD status sync: ${err.message}`); }
@@ -682,31 +610,28 @@ function reconcilePrdStatuses(config) {
682
610
  for (const file of prdFiles) {
683
611
  try {
684
612
  const fpath = path.join(PRD_DIR, file);
685
- const plan = safeJson(fpath);
686
- if (!plan?.missing_features) continue;
687
- // Skip completed/archived PRDs — no reconciliation needed
688
- if (plan.status === PLAN_STATUS.COMPLETED) continue;
689
-
690
- let modified = false;
691
- for (const feature of plan.missing_features) {
692
- if (feature.status === PRD_ITEM_STATUS.MISSING && doneWiById.has(feature.id)) {
693
- feature.status = PRD_ITEM_STATUS.UPDATED;
694
- modified = true;
695
- log('info', `PRD backward-scan: promoted ${feature.id} from missing→updated in ${file} (done work item exists)`);
696
- }
697
- // (#984) Stale status: PRD item stuck at dispatched/failed/pending while WI is done —
698
- // happens when fix work items complete with a different ID than the original PRD feature
699
- else if (_STALE_PRD_STATUSES.has(feature.status) && doneWiById.has(feature.id)) {
700
- const prev = feature.status;
701
- feature.status = WI_STATUS.DONE;
702
- modified = true;
703
- log('info', `PRD backward-scan: promoted ${feature.id} from ${prev}→done in ${file} (done work item exists)`);
704
- }
705
- }
613
+ const logMessages = [];
614
+ mutateJsonFileLocked(fpath, (plan) => {
615
+ if (!plan?.missing_features) return plan;
616
+ // Skip completed/archived PRDs — no reconciliation needed
617
+ if (plan.status === PLAN_STATUS.COMPLETED) return plan;
706
618
 
707
- if (modified) {
708
- safeWrite(fpath, plan);
709
- }
619
+ for (const feature of plan.missing_features) {
620
+ if (feature.status === PRD_ITEM_STATUS.MISSING && doneWiById.has(feature.id)) {
621
+ feature.status = PRD_ITEM_STATUS.UPDATED;
622
+ logMessages.push(`PRD backward-scan: promoted ${feature.id} from missing→updated in ${file} (done work item exists)`);
623
+ }
624
+ // (#984) Stale status: PRD item stuck at dispatched/failed/pending while WI is done —
625
+ // happens when fix work items complete with a different ID than the original PRD feature
626
+ else if (_STALE_PRD_STATUSES.has(feature.status) && doneWiById.has(feature.id)) {
627
+ const prev = feature.status;
628
+ feature.status = WI_STATUS.DONE;
629
+ logMessages.push(`PRD backward-scan: promoted ${feature.id} from ${prev}→done in ${file} (done work item exists)`);
630
+ }
631
+ }
632
+ return plan;
633
+ }, { skipWriteIfUnchanged: true });
634
+ for (const message of logMessages) log('info', message);
710
635
  } catch (err) { log('warn', `PRD backward-scan for ${file}: ${err.message}`); }
711
636
  }
712
637
  }
@@ -1608,19 +1533,17 @@ async function handlePostMerge(pr, project, config, newStatus) {
1608
1533
  const planFiles = fs.readdirSync(prdDir).filter(f => f.endsWith('.json'));
1609
1534
  let updated = 0;
1610
1535
  for (const pf of planFiles) {
1611
- const plan = safeJson(path.join(prdDir, pf));
1612
- if (!plan?.missing_features) continue;
1613
- let changed = false;
1614
- for (const feature of plan.missing_features) {
1615
- if (mergedItemSet.has(feature.id) && feature.status !== WI_STATUS.DONE) {
1616
- feature.status = WI_STATUS.DONE;
1617
- changed = true;
1618
- updated++;
1536
+ const planPath = path.join(prdDir, pf);
1537
+ mutateJsonFileLocked(planPath, (plan) => {
1538
+ if (!plan?.missing_features) return plan;
1539
+ for (const feature of plan.missing_features) {
1540
+ if (mergedItemSet.has(feature.id) && feature.status !== WI_STATUS.DONE) {
1541
+ feature.status = WI_STATUS.DONE;
1542
+ updated++;
1543
+ }
1619
1544
  }
1620
- }
1621
- if (changed) {
1622
- shared.safeWrite(path.join(prdDir, pf), plan);
1623
- }
1545
+ return plan;
1546
+ }, { skipWriteIfUnchanged: true });
1624
1547
  }
1625
1548
  if (updated > 0) log('info', `Post-merge: marked ${mergedItemIds.join(', ')} as done for ${pr.id}`);
1626
1549
  } catch (err) { log('warn', `Post-merge PRD update: ${err.message}`); }
@@ -1703,9 +1626,23 @@ function checkForLearnings(agentId, agentInfo, taskDesc) {
1703
1626
  log('warn', `${agentInfo?.name || agentId} didn't write learnings — no follow-up queued`);
1704
1627
  }
1705
1628
 
1706
- function extractSkillsFromOutput(output, agentId, dispatchItem, config) {
1629
+ function skillWriteTargets(runtimeName, project = null) {
1630
+ try {
1631
+ const runtime = resolveRuntime(runtimeName || 'claude');
1632
+ if (typeof runtime.getSkillWriteTargets === 'function') {
1633
+ return runtime.getSkillWriteTargets({ homeDir: os.homedir(), project });
1634
+ }
1635
+ } catch { /* fall through to Claude-compatible legacy target */ }
1636
+ return {
1637
+ personal: path.join(os.homedir(), '.claude', 'skills'),
1638
+ project: project?.localPath ? path.resolve(project.localPath, '.claude', 'skills') : null,
1639
+ };
1640
+ }
1641
+
1642
+ function extractSkillsFromOutput(output, agentId, dispatchItem, config, runtimeName = null) {
1707
1643
 
1708
1644
  if (!output) return;
1645
+ const effectiveRuntime = runtimeName || dispatchItem?.meta?.runtimeName || dispatchItem?.runtimeName || 'claude';
1709
1646
  let fullText = '';
1710
1647
  for (const line of output.split('\n')) {
1711
1648
  try {
@@ -1743,6 +1680,9 @@ function extractSkillsFromOutput(output, agentId, dispatchItem, config) {
1743
1680
  if (scope === 'project' && project) {
1744
1681
  const proj = shared.getProjects(config).find(p => p.name === project);
1745
1682
  if (proj) {
1683
+ const projectSkillRoot = skillWriteTargets(effectiveRuntime, proj).project
1684
+ || path.resolve(proj.localPath, '.claude', 'skills');
1685
+ const projectSkillPath = path.join(projectSkillRoot, skillDirName, 'SKILL.md');
1746
1686
  const centralPath = path.join(MINIONS_DIR, 'work-items.json');
1747
1687
  let skillId = null;
1748
1688
  mutateJsonFileLocked(centralPath, data => {
@@ -1750,7 +1690,7 @@ function extractSkillsFromOutput(output, agentId, dispatchItem, config) {
1750
1690
  if (data.some(i => i.title === `Add skill: ${name}` && i.status !== WI_STATUS.FAILED)) return data;
1751
1691
  skillId = `SK${String(data.filter(i => i.id?.startsWith('SK')).length + 1).padStart(3, '0')}`;
1752
1692
  data.push({ id: skillId, type: 'implement', title: `Add skill: ${name}`,
1753
- description: `Create project-level skill \`${skillDirName}/SKILL.md\` in ${project}.\n\nWrite this file to \`${proj.localPath}/.claude/skills/${skillDirName}/SKILL.md\` via a PR.\n\n## Skill Content\n\n\`\`\`\n${enrichedBlock}\n\`\`\``,
1693
+ description: `Create project-level skill \`${skillDirName}/SKILL.md\` in ${project}.\n\nWrite this file to \`${projectSkillPath}\` via a PR.\n\n## Skill Content\n\n\`\`\`\n${enrichedBlock}\n\`\`\``,
1754
1694
  priority: 'low', status: WI_STATUS.QUEUED, created: ts(), createdBy: `engine:skill-extraction:${agentName}` });
1755
1695
  return data;
1756
1696
  }, { skipWriteIfUnchanged: true });
@@ -1759,18 +1699,18 @@ function extractSkillsFromOutput(output, agentId, dispatchItem, config) {
1759
1699
  }
1760
1700
  }
1761
1701
  } else {
1762
- // Write in Claude Code native format: ~/.claude/skills/<name>/SKILL.md
1763
- const claudeSkillsDir = path.join(os.homedir(), '.claude', 'skills');
1764
- const skillDir = path.join(claudeSkillsDir, name.replace(/[^a-z0-9-]/g, '-'));
1702
+ const personalSkillRoot = skillWriteTargets(effectiveRuntime).personal;
1703
+ const skillDir = path.join(personalSkillRoot, name.replace(/[^a-z0-9-]/g, '-'));
1765
1704
  const skillPath = path.join(skillDir, 'SKILL.md');
1766
1705
  if (!fs.existsSync(skillPath)) {
1767
- // Convert to Claude Code format: only name + description in frontmatter
1706
+ // Native skill format: only name + description in frontmatter.
1768
1707
  const description = m('description') || m('trigger') || `Auto-extracted skill from ${agentName}`;
1769
1708
  const body = fmMatch[2] || '';
1770
1709
  const ccContent = `---\nname: ${name}\ndescription: ${description}\n---\n\n${body.trim()}\n`;
1771
1710
  if (!fs.existsSync(skillDir)) fs.mkdirSync(skillDir, { recursive: true });
1772
1711
  shared.safeWrite(skillPath, ccContent);
1773
- log('info', `Extracted skill "${name}" from ${agentName} → ~/.claude/skills/${name.replace(/[^a-z0-9-]/g, '-')}/SKILL.md`);
1712
+ try { require('./queries').invalidateSkillsCache(); } catch {}
1713
+ log('info', `Extracted skill "${name}" from ${agentName} → ${skillPath}`);
1774
1714
  } else {
1775
1715
  log('info', `Skill "${name}" already exists, skipping`);
1776
1716
  }
package/engine/meeting.js CHANGED
@@ -45,7 +45,7 @@ function getStructuredNoteArtifacts(structuredCompletion) {
45
45
 
46
46
  function isPathInside(parent, child) {
47
47
  const rel = path.relative(parent, child);
48
- return rel && !rel.startsWith('..') && !path.isAbsolute(rel);
48
+ return Boolean(rel && !rel.startsWith('..') && !path.isAbsolute(rel));
49
49
  }
50
50
 
51
51
  function resolveMeetingNoteArtifactPath(artifactPath) {
@@ -114,27 +114,34 @@ function formatMeetingContributions(entries, agents, emptyText, label, maxBytes)
114
114
  return truncateMeetingContext(combined, maxBytes, label);
115
115
  }
116
116
 
117
- function cleanMeetingSummaryText(text) {
117
+ function stripMeetingSummaryMarkdown(text) {
118
118
  return String(text || '')
119
119
  .replace(/\r/g, '')
120
- .replace(/```[\s\S]*?```/g, ' ')
120
+ .replace(/```[\s\S]*?```/g, '\n')
121
121
  .replace(/`([^`]+)`/g, '$1')
122
122
  .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
123
- .replace(/^[#>*-]+\s*/gm, '')
123
+ .replace(/^\s*(?:[#>*-]+|\d+[.)])\s*/gm, '');
124
+ }
125
+
126
+ function cleanMeetingSummaryText(text) {
127
+ return stripMeetingSummaryMarkdown(text)
124
128
  .replace(/\s+/g, ' ')
125
129
  .trim();
126
130
  }
127
131
 
128
132
  function splitMeetingSummaryFragments(text) {
129
- return cleanMeetingSummaryText(text)
130
- .split(/\n+|(?:[.!?])\s+|;\s+/)
131
- .map(s => s.trim())
133
+ return stripMeetingSummaryMarkdown(text)
134
+ .split(/\n+|[.!?]+\s*|;\s*/)
135
+ .map(s => s.replace(/\s+/g, ' ').trim())
132
136
  .filter(Boolean);
133
137
  }
134
138
 
135
139
  function truncateMeetingSummary(text, maxLen) {
136
- if (text.length <= maxLen) return text;
137
- return text.slice(0, Math.max(0, maxLen - 1)).trimEnd() + '';
140
+ const value = String(text || '');
141
+ if (!value) return '';
142
+ if (!Number.isFinite(maxLen) || maxLen <= 0) return '';
143
+ if (value.length < maxLen) return value;
144
+ return value.slice(0, Math.max(0, maxLen - 1)) + '…';
138
145
  }
139
146
 
140
147
  function formatMeetingSummaryBullets(entries, agents, emptyText, maxLen) {
@@ -148,13 +155,14 @@ function formatMeetingSummaryBullets(entries, agents, emptyText, maxLen) {
148
155
  }
149
156
 
150
157
  function scoreMeetingTakeaway(fragment) {
151
- const lower = fragment.toLowerCase();
158
+ const value = String(fragment || '');
159
+ const lower = value.toLowerCase();
152
160
  let score = 0;
153
161
  if (/(should|must|need to|needs to|recommend|recommended|action|next step|follow up|fix|mitigat|investigat|verify|test|block)/.test(lower)) score += 4;
154
162
  if (/(agree|aligned|consensus|support|prefer)/.test(lower)) score += 3;
155
163
  if (/(disagree|however|but|risk|risky|concern|trade-off|question|uncertain|worry)/.test(lower)) score += 3;
156
- if (fragment.length >= 40 && fragment.length <= 180) score += 2;
157
- if (fragment.length > 220) score -= 1;
164
+ if (value.length >= 40 && value.length <= 180) score += 2;
165
+ if (value.length > 220) score -= 1;
158
166
  return score;
159
167
  }
160
168
 
@@ -195,7 +203,9 @@ function collectMeetingNextSteps(meeting) {
195
203
  }
196
204
  }
197
205
  }
198
- return ['- Review the findings and debate, then add a human-written conclusion if more nuance is needed.'];
206
+ return steps.length
207
+ ? steps
208
+ : ['- Review the findings and debate, then add a human-written conclusion if more nuance is needed.'];
199
209
  }
200
210
 
201
211
  function buildTimedOutMeetingConclusion(meeting, agents) {
@@ -615,4 +625,17 @@ module.exports = {
615
625
  discoverMeetingWork, collectMeetingFindings, checkMeetingTimeouts,
616
626
  addMeetingNote, advanceMeetingRound, endMeeting, archiveMeeting, unarchiveMeeting, deleteMeeting,
617
627
  EMPTY_OUTPUT_PATTERNS,
628
+ // exported for testing — engine code MUST go through
629
+ // getMeetings/discoverMeetingWork/collectMeetingFindings/checkMeetingTimeouts,
630
+ // never these helpers directly.
631
+ isPathInside,
632
+ resolveMeetingNoteArtifactPath,
633
+ cleanMeetingSummaryText,
634
+ splitMeetingSummaryFragments,
635
+ truncateMeetingSummary,
636
+ formatMeetingSummaryBullets,
637
+ scoreMeetingTakeaway,
638
+ collectMeetingTakeaways,
639
+ collectMeetingNextSteps,
640
+ buildTimedOutMeetingConclusion,
618
641
  };
@@ -5,12 +5,13 @@
5
5
  */
6
6
 
7
7
  const fs = require('fs');
8
+ const os = require('os');
8
9
  const path = require('path');
9
10
  const shared = require('./shared');
10
11
  const queries = require('./queries');
11
12
 
12
13
  const { safeJson, safeRead, getProjects, log, ts, dateStamp, truncateTextBytes, ENGINE_DEFAULTS, WI_STATUS, WORK_TYPE, PR_STATUS, DISPATCH_RESULT } = shared;
13
- const { getConfig, getDispatch, getNotes, getAgentCharter, getPrs, AGENTS_DIR } = queries;
14
+ const { getConfig, getDispatch, getNotes, getAgentCharter, getPrs, getKnowledgeBaseIndex, AGENTS_DIR } = queries;
14
15
 
15
16
  const MINIONS_DIR = shared.MINIONS_DIR;
16
17
  const PLAYBOOKS_DIR = path.join(MINIONS_DIR, 'playbooks');
@@ -394,8 +395,8 @@ function renderPlaybook(type, vars) {
394
395
  content += '````\n```skill\n';
395
396
  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`;
396
397
  content += '```\n````\n\n';
397
- content += `- Set \`scope: minions\` for cross-project or Minions-wide skills; the engine writes them to ~/.claude/skills/ so they are available in normal Claude windows too\n`;
398
- content += `- Set \`scope: project\` + \`project: <name>\` only for repo-specific skills; the engine queues a PR to <project>/.claude/skills/\n`;
398
+ content += `- Set \`scope: minions\` for cross-project or Minions-wide skills; the engine writes them to the selected runtime's native personal skills directory\n`;
399
+ content += `- Set \`scope: project\` + \`project: <name>\` only for repo-specific skills; the engine queues a PR to the selected runtime's native project skills directory\n`;
399
400
  content += `- Emit at most one skill block per task unless you uncovered two clearly distinct reusable workflows\n`;
400
401
  content += `- Do NOT create a skill for one-off bug fixes, isolated command output, obvious repo facts, or anything already covered by existing docs/playbooks/skills\n`;
401
402
 
@@ -503,7 +504,7 @@ function buildSystemPrompt(agentId, config, project) {
503
504
  prompt += `3. Follow the project conventions in CLAUDE.md if present\n`;
504
505
  prompt += `4. Write learnings to the path specified in the task prompt (format: \`notes/inbox/{agent}-{work-item-id}-{date}-{time}.md\`)\n`;
505
506
  prompt += `5. Agent status is managed by the engine via dispatch.json — agents do not need to track their own status\n`;
506
- prompt += `6. If you discover a repeatable workflow, output it as a \\\`\\\`\\\`skill fenced block — minions-scoped skills are auto-extracted to ~/.claude/skills/ so they are available in normal Claude windows too\n\n`;
507
+ prompt += `6. If you discover a repeatable workflow, output it as a \\\`\\\`\\\`skill fenced block — minions-scoped skills are auto-extracted to the selected runtime's native personal skills directory\n\n`;
507
508
 
508
509
  return prompt;
509
510
  }
@@ -514,6 +515,26 @@ function buildAgentContext(agentId, config, project) {
514
515
  project = project || getProjects(config)[0] || {};
515
516
  let context = '';
516
517
 
518
+ function appendContextFile(heading, filePath, maxBytes, extra = '') {
519
+ if (!filePath) return;
520
+ const content = safeRead(filePath);
521
+ if (!content || !content.trim()) return;
522
+ const truncated = Buffer.byteLength(content, 'utf8') > maxBytes
523
+ ? truncateTextBytes(content, maxBytes, '\n\n_...truncated; read the full file if needed_')
524
+ : content;
525
+ context += `## ${heading}\n\n`;
526
+ if (extra) context += `${extra}\n\n`;
527
+ context += `${truncated}\n\n`;
528
+ }
529
+
530
+ function appendIndex(heading, body, maxBytes) {
531
+ if (!body || !String(body).trim()) return;
532
+ const truncated = Buffer.byteLength(body, 'utf8') > maxBytes
533
+ ? truncateTextBytes(body, maxBytes, '\n\n_...index truncated; use Glob/Read for the full list_')
534
+ : body;
535
+ context += `## ${heading}\n\n${truncated.replace(/^## .+\n\n/, '')}\n`;
536
+ }
537
+
517
538
 
518
539
  // Agent history — last 5 tasks only (keeps it relevant, avoids 37KB dumps)
519
540
  const history = safeRead(path.join(AGENTS_DIR, agentId, 'history.md'));
@@ -527,16 +548,19 @@ function buildAgentContext(agentId, config, project) {
527
548
 
528
549
  // Project conventions (from CLAUDE.md) — always relevant for code quality
529
550
  if (project.localPath) {
530
- const claudeMd = safeRead(path.join(project.localPath, 'CLAUDE.md'));
531
- if (claudeMd && claudeMd.trim()) {
532
- const truncated = claudeMd.length > 8192 ? claudeMd.slice(0, 8192) + '\n\n...(truncated)' : claudeMd;
533
- context += `## Project Conventions (from CLAUDE.md)\n\n${truncated}\n\n`;
534
- }
551
+ appendContextFile('Project Conventions (from CLAUDE.md)', path.join(project.localPath, 'CLAUDE.md'), 8192);
552
+ appendContextFile('Project Agent Instructions (from AGENTS.md)', path.join(project.localPath, 'AGENTS.md'), 8192,
553
+ 'These instructions are explicitly injected because some runtimes suppress automatic AGENTS.md loading. Follow them unless they conflict with the Minions task contract or playbook.');
554
+ appendContextFile('Project Copilot Instructions (from .github/copilot-instructions.md)', path.join(project.localPath, '.github', 'copilot-instructions.md'), 8192,
555
+ 'Follow these repository instructions unless they conflict with the Minions task contract or playbook.');
535
556
  }
536
557
 
537
- // KB and skills: NOT injected agents can Glob/Read when needed
538
- // This saves ~27KB per dispatch. Reference note so agents know they exist:
539
- context += `## Reference Files\n\nKnowledge base entries are in \`knowledge/{category}/*.md\`. User-level Minions skills live in \`~/.claude/skills/\`, and project-specific skills live in \`<project>/.claude/skills/\`. Use Glob/Read when relevant.\n\n`;
558
+ appendContextFile('User Claude Instructions (from ~/.claude/CLAUDE.md)', path.join(os.homedir(), '.claude', 'CLAUDE.md'), 8192,
559
+ 'These are the user-level Claude Code instructions available in regular Claude usage. Follow them unless they conflict with the Minions task contract or playbook.');
560
+
561
+ appendIndex('Knowledge Base Reference', getKnowledgeBaseIndex(), 8192);
562
+
563
+ context += `## Reference Files\n\nKnowledge base entries are in \`knowledge/{category}/*.md\`, and project-local playbooks live in \`projects/<project>/playbooks/\`. Runtime-native skills and commands are left to the selected CLI runtime; Minions does not inject their contents into the task prompt.\n\n`;
540
564
 
541
565
  // Minions awareness: what's in flight, who's doing what
542
566
  const dispatch = getDispatch();