@yemi33/minions 0.1.547 → 0.1.548

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,11 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.547 (2026-04-07)
3
+ ## 0.1.548 (2026-04-07)
4
+
5
+ ### Features
6
+ - auto-enrich linked PRs, progressive markdown rendering, KB sort
7
+ - extract buildWorkItemDispatchVars() from duplicated discovery code (#467)
8
+ - Replace JSON.parse(safeRead()) with safeJsonArr/safeJsonObj in dashboard.js (#465)
4
9
 
5
10
  ### Fixes
6
11
  - remap sequential PRD item IDs to prevent cross-PRD collisions (#474)
package/bin/minions.js CHANGED
@@ -217,6 +217,11 @@ function getInstalledVersion() {
217
217
 
218
218
  function saveInstalledVersion(version) {
219
219
  fs.writeFileSync(path.join(MINIONS_HOME, '.minions-version'), version);
220
+ // Persist source commit so dashboard can detect repo-based installs
221
+ try {
222
+ const commit = execSync('git rev-parse --short HEAD', { cwd: PKG_ROOT, encoding: 'utf8', timeout: 5000, windowsHide: true }).trim();
223
+ if (commit) fs.writeFileSync(path.join(MINIONS_HOME, '.minions-commit'), commit);
224
+ } catch {}
220
225
  }
221
226
 
222
227
  // ─── Init / Upgrade ─────────────────────────────────────────────────────────
@@ -381,10 +381,12 @@ function planHideRevise(file) {
381
381
 
382
382
  let _planPollInterval = null;
383
383
  let _planPollFile = null;
384
+ let _planPollLastRaw = null;
384
385
 
385
386
  function _stopPlanPoll() {
386
387
  if (_planPollInterval) { clearInterval(_planPollInterval); _planPollInterval = null; }
387
388
  _planPollFile = null;
389
+ _planPollLastRaw = null;
388
390
  }
389
391
 
390
392
  function _renderPlanModal(normalizedFile, raw, lastMod) {
@@ -488,6 +490,7 @@ async function planView(file) {
488
490
  const raw = await planRes.text();
489
491
 
490
492
  const { title, text } = _renderPlanModal(normalizedFile, raw, lastMod);
493
+ _planPollLastRaw = raw;
491
494
 
492
495
  _modalDocContext = { title, content: text, selection: '' };
493
496
  _modalFilePath = resolvedPath || ((normalizedFile.endsWith('.json') ? 'prd/' : 'plans/') + normalizedFile); showModalQa();
@@ -502,7 +505,7 @@ async function planView(file) {
502
505
  }
503
506
  fetch('/api/plans/' + encodeURIComponent(normalizedFile))
504
507
  .then(function(r) { return r.text().then(function(raw) { return { raw: raw, lastMod: r.headers.get('Last-Modified') }; }); })
505
- .then(function(d) { if (_planPollFile === normalizedFile) _renderPlanModal(normalizedFile, d.raw, d.lastMod); })
508
+ .then(function(d) { if (_planPollFile === normalizedFile && d.raw !== _planPollLastRaw) { _planPollLastRaw = d.raw; _renderPlanModal(normalizedFile, d.raw, d.lastMod); } })
506
509
  .catch(function() {});
507
510
  }, 3000);
508
511
  } catch (e) { console.error(e); }
@@ -20,7 +20,7 @@ function prRow(pr) {
20
20
  const prId = pr.id || '—';
21
21
  return '<tr>' +
22
22
  '<td><span class="pr-id">' + escHtml(String(prId)) + '</span></td>' +
23
- '<td><a class="pr-title" href="' + escHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escHtml(pr.title || 'Untitled') + '</a></td>' +
23
+ '<td><a class="pr-title" href="' + escHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escHtml(pr.title || 'Untitled') + '</a>' + (pr.description ? '<div class="pr-desc">' + escHtml(pr.description.length > 120 ? pr.description.slice(0, 120) + '...' : pr.description) + '</div>' : '') + '</td>' +
24
24
  '<td><span class="pr-agent">' + escHtml(pr.agent || '—') + '</span></td>' +
25
25
  '<td><span class="pr-branch">' + escHtml(pr.branch || '—') + '</span></td>' +
26
26
  '<td><span class="pr-badge ' + reviewClass + '">' + escHtml(reviewLabel) + '</span></td>' +
@@ -60,8 +60,11 @@ function copyLlmText(btn) {
60
60
  */
61
61
  function renderMd(s) {
62
62
  if (!s) return '';
63
- // Truncate excessively long inputs to prevent regex backtracking
64
- if (s.length > 10000) s = s.slice(0, 10000) + '\n\n…(truncated)';
63
+ if (s.length > 10000) return _renderMdChunked(s);
64
+ return _renderMdCore(s);
65
+ }
66
+
67
+ function _renderMdCore(s) {
65
68
  let html = escHtml(s);
66
69
 
67
70
  // 1. Extract code blocks and inline code into placeholders (protect from other transforms)
@@ -199,6 +202,78 @@ function renderMd(s) {
199
202
  return '<div class="md-content">' + html + '</div>';
200
203
  }
201
204
 
205
+ var MD_CHUNK_SIZE = 10000;
206
+ var _mdChunkUid = 0;
207
+
208
+ function _renderMdChunked(fullText) {
209
+ // Split at blank-line boundaries to avoid breaking code blocks and other multi-line elements
210
+ var chunks = [];
211
+ var pos = 0;
212
+ while (pos < fullText.length) {
213
+ if (pos + MD_CHUNK_SIZE >= fullText.length) {
214
+ chunks.push(fullText.slice(pos));
215
+ break;
216
+ }
217
+ var target = pos + MD_CHUNK_SIZE;
218
+ var searchStart = Math.max(pos + Math.floor(MD_CHUNK_SIZE * 0.7), pos);
219
+ var searchEnd = Math.min(target + 500, fullText.length);
220
+ var slice = fullText.slice(searchStart, searchEnd);
221
+ var lastBlank = slice.lastIndexOf('\n\n');
222
+ var best;
223
+ if (lastBlank !== -1) {
224
+ best = searchStart + lastBlank + 2;
225
+ } else {
226
+ var nl = fullText.indexOf('\n', target);
227
+ best = (nl !== -1 && nl - target < 500) ? nl + 1 : target;
228
+ }
229
+ chunks.push(fullText.slice(pos, best));
230
+ pos = best;
231
+ }
232
+
233
+ var firstHtml = _renderMdCore(chunks[0]);
234
+ var uid = '_mdChunk' + (++_mdChunkUid);
235
+ if (chunks.length <= 1) return firstHtml;
236
+
237
+ var sentinel = '<div id="' + uid + '" style="padding:12px 0;color:var(--muted);font-size:11px">Loading more content...</div>';
238
+
239
+ setTimeout(function() {
240
+ var el = document.getElementById(uid);
241
+ if (!el) return;
242
+ var scrollParent = el.closest('.modal-body') || el.parentElement;
243
+ var idx = 1;
244
+ var obs = null;
245
+ function loadNext() {
246
+ if (idx >= chunks.length) {
247
+ if (obs) obs.disconnect();
248
+ el.remove();
249
+ return;
250
+ }
251
+ el.insertAdjacentHTML('beforebegin', _renderMdCore(chunks[idx]));
252
+ idx++;
253
+ if (idx >= chunks.length) {
254
+ if (obs) obs.disconnect();
255
+ el.remove();
256
+ }
257
+ }
258
+ if (typeof IntersectionObserver !== 'undefined') {
259
+ obs = new IntersectionObserver(function(entries) {
260
+ if (!entries[0].isIntersecting) return;
261
+ loadNext();
262
+ // Yield to browser for paint before re-observing, so rapid chunk loads don't block rendering
263
+ if (idx < chunks.length && el.parentNode) {
264
+ obs.unobserve(el);
265
+ requestAnimationFrame(function() { if (el.parentNode) obs.observe(el); });
266
+ }
267
+ }, { root: scrollParent, rootMargin: '200px' });
268
+ obs.observe(el);
269
+ } else {
270
+ var timer = setInterval(function() { loadNext(); if (idx >= chunks.length) clearInterval(timer); }, 100);
271
+ }
272
+ }, 0);
273
+
274
+ return firstHtml + sentinel;
275
+ }
276
+
202
277
  function openBugReport() {
203
278
  document.getElementById('modal-title').textContent = 'Report a Bug';
204
279
  document.getElementById('modal-body').innerHTML =
@@ -236,6 +236,7 @@
236
236
  .pr-table tr:hover { background: var(--surface2); }
237
237
  .pr-title { color: var(--blue); text-decoration: none; font-weight: 500; }
238
238
  .pr-title:hover { text-decoration: underline; }
239
+ .pr-desc { color: var(--muted); font-size: var(--text-sm); font-weight: 400; margin-top: 2px; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 400px; }
239
240
  .pr-id { color: var(--muted); font-family: Consolas, monospace; font-size: var(--text-base); }
240
241
  .pr-agent { font-size: var(--text-base); color: var(--text); }
241
242
  .pr-branch { font-family: Consolas, monospace; font-size: var(--text-sm); color: var(--muted); background: var(--bg); padding: var(--space-1) var(--space-3); border-radius: 3px; border: 1px solid var(--border); }
package/dashboard.js CHANGED
@@ -230,7 +230,11 @@ function getDiskVersion() {
230
230
  try { diskVersion = require('@yemi33/minions/package.json').version; } catch {}
231
231
  }
232
232
  let diskCommit = null;
233
- try { diskCommit = require('child_process').execSync('git rev-parse --short HEAD', { cwd: MINIONS_DIR, encoding: 'utf8', timeout: 5000, windowsHide: true }).trim(); } catch {}
233
+ // First try .minions-commit (written by minions init from source repo), then fall back to git
234
+ try { diskCommit = fs.readFileSync(path.join(MINIONS_DIR, '.minions-commit'), 'utf8').trim() || null; } catch {}
235
+ if (!diskCommit) {
236
+ try { diskCommit = require('child_process').execSync('git rev-parse --short HEAD', { cwd: MINIONS_DIR, encoding: 'utf8', timeout: 5000, windowsHide: true }).trim(); } catch {}
237
+ }
234
238
  _diskVersionCache = { diskVersion, diskCommit };
235
239
  _diskVersionCacheTs = now;
236
240
  return _diskVersionCache;
@@ -240,7 +244,7 @@ function getMcpServers() {
240
244
  try {
241
245
  const home = os.homedir();
242
246
  const claudeJsonPath = path.join(home, '.claude.json');
243
- const data = JSON.parse(safeRead(claudeJsonPath) || '{}');
247
+ const data = safeJsonObj(claudeJsonPath);
244
248
  const servers = data.mcpServers || {};
245
249
  return Object.entries(servers).map(([name, cfg]) => ({
246
250
  name,
@@ -1082,7 +1086,7 @@ const server = http.createServer(async (req, res) => {
1082
1086
  }) || PROJECTS[0] || null;
1083
1087
  if (project) {
1084
1088
  const wiPath = shared.projectWorkItemsPath(project);
1085
- const items = JSON.parse(safeRead(wiPath) || '[]');
1089
+ const items = safeJsonArr(wiPath);
1086
1090
  const verify = items.find(w => w.sourcePlan === body.file && w.itemType === 'verify');
1087
1091
  if (verify) {
1088
1092
  return jsonReply(res, 200, { ok: true, verifyId: verify.id });
@@ -1150,7 +1154,7 @@ const server = http.createServer(async (req, res) => {
1150
1154
  // Clear cooldown so item isn't blocked by exponential backoff
1151
1155
  try {
1152
1156
  const cooldownPath = path.join(MINIONS_DIR, 'engine', 'cooldowns.json');
1153
- const cooldowns = JSON.parse(safeRead(cooldownPath) || '{}');
1157
+ const cooldowns = safeJsonObj(cooldownPath);
1154
1158
  if (cooldowns[dispatchKey]) {
1155
1159
  delete cooldowns[dispatchKey];
1156
1160
  safeWrite(cooldownPath, cooldowns);
@@ -1201,7 +1205,7 @@ const server = http.createServer(async (req, res) => {
1201
1205
  // Clean cooldown entries so item can be re-created immediately
1202
1206
  try {
1203
1207
  const cooldownPath = path.join(MINIONS_DIR, 'engine', 'cooldowns.json');
1204
- const cooldowns = JSON.parse(safeRead(cooldownPath) || '{}');
1208
+ const cooldowns = safeJsonObj(cooldownPath);
1205
1209
  let cleaned = false;
1206
1210
  for (const key of Object.keys(cooldowns)) {
1207
1211
  if (key.includes(id)) { delete cooldowns[key]; cleaned = true; }
@@ -1525,7 +1529,7 @@ const server = http.createServer(async (req, res) => {
1525
1529
  try {
1526
1530
  const body = await readBody(req);
1527
1531
  const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
1528
- const dispatch = JSON.parse(safeRead(dispatchPath) || '{}');
1532
+ const dispatch = safeJsonObj(dispatchPath);
1529
1533
  const active = dispatch.active || [];
1530
1534
  const cancelled = [];
1531
1535
 
@@ -1537,7 +1541,7 @@ const server = http.createServer(async (req, res) => {
1537
1541
  // Kill agent process
1538
1542
  const statusPath = path.join(MINIONS_DIR, 'agents', d.agent, 'status.json');
1539
1543
  try {
1540
- const status = JSON.parse(safeRead(statusPath) || '{}');
1544
+ const status = safeJsonObj(statusPath);
1541
1545
  if (status.pid) {
1542
1546
  try {
1543
1547
  const safePid = shared.validatePid(status.pid);
@@ -1907,7 +1911,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
1907
1911
  { dir: path.join(PRD_DIR, 'archive'), archived: true },
1908
1912
  ];
1909
1913
  // Load work items to check for completed plan-to-prd conversions
1910
- const centralWi = JSON.parse(safeRead(path.join(MINIONS_DIR, 'work-items.json')) || '[]');
1914
+ const centralWi = safeJsonArr(path.join(MINIONS_DIR, 'work-items.json'));
1911
1915
  const completedPrdFiles = new Set(
1912
1916
  centralWi.filter(w => w.type === 'plan-to-prd' && w.status === 'done' && w.planFile)
1913
1917
  .map(w => w.planFile)
@@ -2048,7 +2052,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2048
2052
  const body = await readBody(req);
2049
2053
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
2050
2054
  const planPath = resolvePlanPath(body.file);
2051
- const plan = JSON.parse(safeRead(planPath) || '{}');
2055
+ const plan = safeJsonObj(planPath);
2052
2056
  plan.status = 'approved';
2053
2057
  plan.approvedAt = new Date().toISOString();
2054
2058
  plan.approvedBy = body.approvedBy || os.userInfo().username;
@@ -2101,7 +2105,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2101
2105
  const body = await readBody(req);
2102
2106
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
2103
2107
  const planPath = resolvePlanPath(body.file);
2104
- const plan = JSON.parse(safeRead(planPath) || '{}');
2108
+ const plan = safeJsonObj(planPath);
2105
2109
  plan.status = 'paused';
2106
2110
  plan.pausedAt = new Date().toISOString();
2107
2111
  safeWrite(planPath, plan);
@@ -2134,7 +2138,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2134
2138
  if (activeEntry) {
2135
2139
  const statusPath = path.join(MINIONS_DIR, 'agents', activeEntry.agent, 'status.json');
2136
2140
  try {
2137
- const agentStatus = JSON.parse(safeRead(statusPath) || '{}');
2141
+ const agentStatus = safeJsonObj(statusPath);
2138
2142
  if (agentStatus.pid) {
2139
2143
  try {
2140
2144
  const safePid = shared.validatePid(agentStatus.pid);
@@ -2293,7 +2297,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2293
2297
  const body = await readBody(req);
2294
2298
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
2295
2299
  const planPath = resolvePlanPath(body.file);
2296
- const plan = JSON.parse(safeRead(planPath) || '{}');
2300
+ const plan = safeJsonObj(planPath);
2297
2301
  plan.status = 'rejected';
2298
2302
  plan.rejectedAt = new Date().toISOString();
2299
2303
  plan.rejectedBy = body.rejectedBy || os.userInfo().username;
@@ -2375,7 +2379,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2375
2379
  // Read PRD content before deleting to get source_plan for cleanup
2376
2380
  let prdSourcePlan = null;
2377
2381
  if (body.file.endsWith('.json')) {
2378
- try { prdSourcePlan = JSON.parse(safeRead(planPath) || '{}').source_plan || null; } catch {}
2382
+ try { prdSourcePlan = safeJsonObj(planPath).source_plan || null; } catch {}
2379
2383
  }
2380
2384
  safeUnlink(planPath);
2381
2385
 
@@ -2442,7 +2446,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2442
2446
  let archivedSource = null;
2443
2447
  if (body.file.endsWith('.json')) {
2444
2448
  try {
2445
- const prd = JSON.parse(safeRead(archivePath) || '{}');
2449
+ const prd = safeJsonObj(archivePath);
2446
2450
  prd.status = 'archived';
2447
2451
  prd.archivedAt = new Date().toISOString();
2448
2452
  safeWrite(archivePath, prd);
@@ -2501,7 +2505,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2501
2505
  const body = await readBody(req);
2502
2506
  if (!body.file || !body.feedback) return jsonReply(res, 400, { error: 'file and feedback required' });
2503
2507
  const planPath = resolvePlanPath(body.file);
2504
- const plan = JSON.parse(safeRead(planPath) || '{}');
2508
+ const plan = safeJsonObj(planPath);
2505
2509
  plan.status = 'revision-requested';
2506
2510
  plan.revision_feedback = body.feedback;
2507
2511
  plan.revisionRequestedAt = new Date().toISOString();
@@ -2546,7 +2550,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2546
2550
  sourcePlanFile = body.sourcePlan;
2547
2551
  } else {
2548
2552
  // Heuristic: find .md plan by matching prefix or by reading PRD's generated_from field
2549
- const prd = JSON.parse(safeRead(prdPath) || '{}');
2553
+ const prd = safeJsonObj(prdPath);
2550
2554
  if (prd.source_plan) {
2551
2555
  sourcePlanFile = prd.source_plan;
2552
2556
  } else {
@@ -2596,7 +2600,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2596
2600
  safeWrite(sourcePlanPath, result.content);
2597
2601
 
2598
2602
  // Step 2: Pause the old PRD so it stops materializing items
2599
- const prd = JSON.parse(safeRead(prdPath) || '{}');
2603
+ const prd = safeJsonObj(prdPath);
2600
2604
  prd.status = 'revision-requested';
2601
2605
  prd.revision_feedback = body.instruction;
2602
2606
  prd.revisionRequestedAt = new Date().toISOString();
@@ -2826,7 +2830,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2826
2830
  const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
2827
2831
  for (const proj of PROJECTS) wiPaths.push(shared.projectWorkItemsPath(proj));
2828
2832
  const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
2829
- const dispatch = JSON.parse(safeRead(dispatchPath) || '{}');
2833
+ const dispatch = safeJsonObj(dispatchPath);
2830
2834
  const killedAgents = new Set();
2831
2835
  const resetItemIds = new Set();
2832
2836
  for (const wiPath of wiPaths) {
@@ -2841,7 +2845,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2841
2845
  if (activeEntry) {
2842
2846
  const statusPath = path.join(MINIONS_DIR, 'agents', activeEntry.agent, 'status.json');
2843
2847
  try {
2844
- const agentStatus = JSON.parse(safeRead(statusPath) || '{}');
2848
+ const agentStatus = safeJsonObj(statusPath);
2845
2849
  if (agentStatus.pid) {
2846
2850
  try {
2847
2851
  const safePid = shared.validatePid(agentStatus.pid);
@@ -3822,6 +3826,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3822
3826
  prs.push({
3823
3827
  id: prId,
3824
3828
  title: (title || 'PR #' + prNum + ' (polling...)').slice(0, 120),
3829
+ description: '',
3825
3830
  agent: 'human',
3826
3831
  branch: '',
3827
3832
  reviewStatus: autoObserve ? 'pending' : 'none',
@@ -3837,7 +3842,47 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3837
3842
  }, { defaultValue: [] });
3838
3843
  if (duplicate) return jsonReply(res, 400, { error: 'PR already tracked' });
3839
3844
  invalidateStatusCache();
3840
- return jsonReply(res, 200, { ok: true, id: prId });
3845
+ jsonReply(res, 200, { ok: true, id: prId });
3846
+
3847
+ // Async-enrich: fetch title, description, branch, author from GitHub/ADO API
3848
+ (async () => {
3849
+ try {
3850
+ let prData = null;
3851
+ const ghMatch = url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
3852
+ const adoMatch = url.match(/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)/);
3853
+ if (ghMatch) {
3854
+ const slug = ghMatch[1];
3855
+ const result = await shared.execAsync(`gh api "repos/${slug}/pulls/${prNum}"`, { timeout: 15000, encoding: 'utf-8' });
3856
+ const d = JSON.parse(result);
3857
+ prData = { title: d.title, description: d.body, branch: d.head?.ref, author: d.user?.login };
3858
+ } else if (adoMatch) {
3859
+ const [, adoOrg, adoProj, adoRepo] = adoMatch;
3860
+ try {
3861
+ const { getAdoToken } = require('./engine/ado');
3862
+ const token = await getAdoToken();
3863
+ if (token) {
3864
+ const apiUrl = `https://dev.azure.com/${adoOrg}/${adoProj}/_apis/git/repositories/${adoRepo}/pullrequests/${prNum}?api-version=7.1`;
3865
+ const result = await shared.execAsync(`curl -s --max-time 10 -H "Authorization: Bearer ${token}" "${apiUrl}"`, { encoding: 'utf-8', timeout: 15000, windowsHide: true });
3866
+ const d = JSON.parse(result);
3867
+ prData = { title: d.title, description: d.description, branch: d.sourceRefName?.replace('refs/heads/', ''), author: d.createdBy?.displayName };
3868
+ }
3869
+ } catch { /* ADO token may not be available */ }
3870
+ }
3871
+ if (!prData) return;
3872
+ mutateJsonFileLocked(prPath, (prs) => {
3873
+ const pr = prs.find(p => p.id === prId);
3874
+ if (!pr) return prs;
3875
+ if (!title && prData.title) pr.title = prData.title.slice(0, 120);
3876
+ if (prData.description) pr.description = prData.description.slice(0, 500);
3877
+ if (!pr.branch && prData.branch) pr.branch = prData.branch;
3878
+ if (pr.agent === 'human' && prData.author) pr.agent = prData.author;
3879
+ return prs;
3880
+ }, { defaultValue: [] });
3881
+ invalidateStatusCache();
3882
+ } catch (e) {
3883
+ shared.log('warn', `PR link enrichment failed for ${prId}: ${e.message}`);
3884
+ }
3885
+ })();
3841
3886
  }},
3842
3887
 
3843
3888
  { method: 'POST', path: '/api/pull-requests/delete', desc: 'Remove a PR from tracking', params: 'id, project?', handler: async (req, res) => {
package/engine/queries.js CHANGED
@@ -547,6 +547,7 @@ function getKnowledgeBaseEntries() {
547
547
  });
548
548
  }
549
549
  }
550
+ entries.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
550
551
  _kbCache = entries;
551
552
  _kbCacheTs = now;
552
553
  return entries;
package/engine.js CHANGED
@@ -1655,61 +1655,21 @@ function discoverFromWorkItems(config, project) {
1655
1655
  commit_message: item.commitMessage || `feat: ${item.title || item.id}`,
1656
1656
  notes_content: '',
1657
1657
  };
1658
- try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch { /* optional */ }
1659
-
1660
- // Inject references and acceptance criteria
1661
- const refs = (item.references || []).filter(r => r && r.url).map(r =>
1662
- '- [' + (r.title || r.url) + '](' + r.url + ')' + (r.type ? ' (' + r.type + ')' : '')
1663
- ).join('\n');
1664
- vars.references = refs ? '## References\n\n' + refs : '';
1665
- const ac = normalizeAc(item.acceptanceCriteria).map(c => '- [ ] ' + c).join('\n');
1666
- vars.acceptance_criteria = ac ? '## Acceptance Criteria\n\n' + ac : '';
1667
-
1668
- // Inject checkpoint context if agent left a checkpoint.json from a prior run
1669
- vars.checkpoint_context = '';
1670
- try {
1671
- const wtPath = vars.worktree_path || root;
1672
- const cpPath = path.join(wtPath, 'checkpoint.json');
1673
- if (fs.existsSync(cpPath)) {
1674
- const cpData = JSON.parse(fs.readFileSync(cpPath, 'utf8'));
1675
- const cpCount = (item._checkpointCount || 0) + 1;
1676
- if (cpCount > 3) {
1677
- log('warn', `Work item ${item.id} exceeded 3 checkpoint-resumes — marking as needs-human-review`);
1678
- item.status = WI_STATUS.NEEDS_REVIEW;
1679
- item._checkpointCount = cpCount;
1680
- needsWrite = true;
1681
- continue;
1682
- }
1683
- item._checkpointCount = cpCount;
1684
- needsWrite = true;
1685
- const cpSummary = [
1686
- `## Checkpoint (Resume #${cpCount}/3)`,
1687
- '',
1688
- 'A previous agent run timed out but left a checkpoint. Continue from where it left off.',
1689
- '',
1690
- cpData.completed && cpData.completed.length > 0 ? `### Completed\n${cpData.completed.map(s => '- ' + s).join('\n')}` : '',
1691
- cpData.remaining && cpData.remaining.length > 0 ? `### Remaining\n${cpData.remaining.map(s => '- ' + s).join('\n')}` : '',
1692
- cpData.blockers && cpData.blockers.length > 0 ? `### Blockers\n${cpData.blockers.map(s => '- ' + s).join('\n')}` : '',
1693
- cpData.branch_state ? `### Branch State\n${cpData.branch_state}` : '',
1694
- ].filter(Boolean).join('\n');
1695
- vars.checkpoint_context = cpSummary;
1696
- log('info', `Injecting checkpoint context for ${item.id} (resume #${cpCount})`);
1697
- }
1698
- } catch (e) { log('warn', `checkpoint read for ${item.id}: ${e.message}`); }
1699
-
1700
- // Inject ask-specific variables for the ask playbook
1701
- if (workType === WORK_TYPE.ASK) {
1702
- vars.question = item.title + (item.description ? '\n\n' + item.description : '');
1703
- vars.task_id = item.id;
1704
- vars.notes_content = '';
1705
- try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch { /* optional */ }
1658
+ // Build common vars: references, acceptance criteria, checkpoint, notes, task context
1659
+ const cpResult = buildWorkItemDispatchVars(item, vars, config, {
1660
+ worktreePath: vars.worktree_path || root,
1661
+ workType,
1662
+ });
1663
+ if (cpResult.needsReview) {
1664
+ log('warn', `Work item ${item.id} exceeded 3 checkpoint-resumes marking as needs-human-review`);
1665
+ item.status = WI_STATUS.NEEDS_REVIEW;
1666
+ item._checkpointCount = cpResult.checkpointCount;
1667
+ needsWrite = true;
1668
+ continue;
1706
1669
  }
1707
-
1708
- // Resolve implicit context references (e.g., "ripley's plan", "the latest plan")
1709
- const resolvedCtx = resolveTaskContext(item, config);
1710
- if (resolvedCtx.additionalContext) {
1711
- vars.additional_context = (vars.additional_context || '') + resolvedCtx.additionalContext;
1712
- vars.task_description = vars.task_description + resolvedCtx.additionalContext;
1670
+ if (cpResult.checkpointCount !== null) {
1671
+ item._checkpointCount = cpResult.checkpointCount;
1672
+ needsWrite = true;
1713
1673
  }
1714
1674
 
1715
1675
  const playbookName = selectPlaybook(workType, item);
@@ -1795,6 +1755,90 @@ function normalizeAc(ac) {
1795
1755
  return [];
1796
1756
  }
1797
1757
 
1758
+ /**
1759
+ * Build common dispatch vars for a work item: references, acceptance criteria,
1760
+ * checkpoint context, notes content, and resolved task context.
1761
+ *
1762
+ * Consolidates duplicated patterns across discoverFromWorkItems, discoverCentralWorkItems
1763
+ * (normal + fan-out). Caller-specific vars (project_name, work_branch, plan vars) are NOT
1764
+ * handled here — they remain in the caller.
1765
+ *
1766
+ * @param {Object} item - Work item
1767
+ * @param {Object} vars - Mutable vars object to populate (must already have base vars)
1768
+ * @param {Object} config - Engine config
1769
+ * @param {Object} [options]
1770
+ * @param {string} [options.worktreePath] - Path for checkpoint lookup (omit to skip checkpoint)
1771
+ * @param {boolean} [options.includeNotes=true] - Whether to read notes.md into vars.notes_content
1772
+ * @param {string} [options.workType] - Work type (used for ASK-specific vars)
1773
+ * @returns {{ needsReview: boolean, checkpointCount: number|null }} checkpoint side-effect info
1774
+ */
1775
+ function buildWorkItemDispatchVars(item, vars, config, options = {}) {
1776
+ const { worktreePath, includeNotes = true, workType } = options;
1777
+
1778
+ // Notes content (uses queries.getNotes instead of inline fs.readFileSync)
1779
+ if (includeNotes) {
1780
+ vars.notes_content = getNotes() || '';
1781
+ }
1782
+
1783
+ // References
1784
+ const refs = (item.references || []).filter(r => r && r.url).map(r =>
1785
+ '- [' + (r.title || r.url) + '](' + r.url + ')' + (r.type ? ' (' + r.type + ')' : '')
1786
+ ).join('\n');
1787
+ vars.references = refs ? '## References\n\n' + refs : '';
1788
+
1789
+ // Acceptance criteria
1790
+ const ac = normalizeAc(item.acceptanceCriteria).map(c => '- [ ] ' + c).join('\n');
1791
+ vars.acceptance_criteria = ac ? '## Acceptance Criteria\n\n' + ac : '';
1792
+
1793
+ // Checkpoint context
1794
+ vars.checkpoint_context = '';
1795
+ const result = { needsReview: false, checkpointCount: null };
1796
+ if (worktreePath) {
1797
+ try {
1798
+ const cpPath = path.join(worktreePath, 'checkpoint.json');
1799
+ if (fs.existsSync(cpPath)) {
1800
+ const cpData = JSON.parse(fs.readFileSync(cpPath, 'utf8'));
1801
+ const cpCount = (item._checkpointCount || 0) + 1;
1802
+ result.checkpointCount = cpCount;
1803
+ if (cpCount > 3) {
1804
+ result.needsReview = true;
1805
+ } else {
1806
+ const cpSummary = [
1807
+ `## Checkpoint (Resume #${cpCount}/3)`,
1808
+ '',
1809
+ 'A previous agent run timed out but left a checkpoint. Continue from where it left off.',
1810
+ '',
1811
+ cpData.completed && cpData.completed.length > 0 ? `### Completed\n${cpData.completed.map(s => '- ' + s).join('\n')}` : '',
1812
+ cpData.remaining && cpData.remaining.length > 0 ? `### Remaining\n${cpData.remaining.map(s => '- ' + s).join('\n')}` : '',
1813
+ cpData.blockers && cpData.blockers.length > 0 ? `### Blockers\n${cpData.blockers.map(s => '- ' + s).join('\n')}` : '',
1814
+ cpData.branch_state ? `### Branch State\n${cpData.branch_state}` : '',
1815
+ ].filter(Boolean).join('\n');
1816
+ vars.checkpoint_context = cpSummary;
1817
+ log('info', `Injecting checkpoint context for ${item.id} (resume #${cpCount})`);
1818
+ }
1819
+ }
1820
+ } catch (e) { log('warn', `checkpoint read for ${item.id}: ${e.message}`); }
1821
+ }
1822
+
1823
+ // ASK-specific variables
1824
+ if (workType === WORK_TYPE.ASK) {
1825
+ vars.question = item.title + (item.description ? '\n\n' + item.description : '');
1826
+ vars.task_id = item.id;
1827
+ vars.notes_content = getNotes() || '';
1828
+ }
1829
+
1830
+ // Resolve implicit context references (e.g., "ripley's plan", "the latest plan")
1831
+ const resolvedCtx = resolveTaskContext(item, config);
1832
+ if (resolvedCtx.additionalContext) {
1833
+ vars.additional_context = (vars.additional_context || '') + resolvedCtx.additionalContext;
1834
+ if (vars.task_description !== undefined) {
1835
+ vars.task_description = vars.task_description + resolvedCtx.additionalContext;
1836
+ }
1837
+ }
1838
+
1839
+ return result;
1840
+ }
1841
+
1798
1842
  function buildProjectContext(projects, assignedProject, isFanOut, agentName, agentRole) {
1799
1843
  const projectList = projects.map(p => {
1800
1844
  let line = `### ${p.name}\n`;
@@ -2028,25 +2072,11 @@ function discoverCentralWorkItems(config) {
2028
2072
  project_path: ap?.localPath || '',
2029
2073
  };
2030
2074
 
2031
- // Inject references and acceptance criteria
2032
- const fanRefs = (item.references || []).filter(r => r && r.url).map(r =>
2033
- '- [' + (r.title || r.url) + '](' + r.url + ')' + (r.type ? ' (' + r.type + ')' : '')
2034
- ).join('\n');
2035
- vars.references = fanRefs ? '## References\n\n' + fanRefs : '';
2036
- const fanAc = normalizeAc(item.acceptanceCriteria).map(c => '- [ ] ' + c).join('\n');
2037
- vars.acceptance_criteria = fanAc ? '## Acceptance Criteria\n\n' + fanAc : '';
2038
-
2039
- if (workType === WORK_TYPE.ASK) {
2040
- vars.question = item.title + (item.description ? '\n\n' + item.description : '');
2041
- vars.task_id = item.id;
2042
- vars.notes_content = '';
2043
- try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch { /* optional */ }
2044
- }
2045
-
2046
- const resolvedCtx = resolveTaskContext(item, config);
2047
- if (resolvedCtx.additionalContext) {
2048
- vars.additional_context = (vars.additional_context || '') + resolvedCtx.additionalContext;
2049
- }
2075
+ // Build common vars: references, acceptance criteria, notes (ASK only), task context
2076
+ buildWorkItemDispatchVars(item, vars, config, {
2077
+ includeNotes: false,
2078
+ workType,
2079
+ });
2050
2080
 
2051
2081
  const playbookName = selectPlaybook(workType, item);
2052
2082
  const prompt = renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars);
@@ -2103,49 +2133,25 @@ function discoverCentralWorkItems(config) {
2103
2133
  additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
2104
2134
  scope_section: buildProjectContext(projects, null, false, agentName, agentRole),
2105
2135
  project_path: firstProject?.localPath || '',
2106
- notes_content: '',
2107
2136
  };
2108
- try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch { /* optional */ }
2109
-
2110
- // Inject references and acceptance criteria
2111
- const normRefs = (item.references || []).filter(r => r && r.url).map(r =>
2112
- '- [' + (r.title || r.url) + '](' + r.url + ')' + (r.type ? ' (' + r.type + ')' : '')
2113
- ).join('\n');
2114
- vars.references = normRefs ? '## References\n\n' + normRefs : '';
2115
- const normAc = normalizeAc(item.acceptanceCriteria).map(c => '- [ ] ' + c).join('\n');
2116
- vars.acceptance_criteria = normAc ? '## Acceptance Criteria\n\n' + normAc : '';
2117
-
2118
- // Inject checkpoint context if agent left a checkpoint.json from a prior run
2119
- vars.checkpoint_context = '';
2120
- try {
2121
- const centralBranch = item.branch || `work/${item.id}`;
2122
- const centralWtPath = firstProject?.localPath
2123
- ? path.resolve(firstProject.localPath, config.engine?.worktreeRoot || '../worktrees', centralBranch)
2124
- : '';
2125
- const cpPath = centralWtPath ? path.join(centralWtPath, 'checkpoint.json') : '';
2126
- if (cpPath && fs.existsSync(cpPath)) {
2127
- const cpData = JSON.parse(fs.readFileSync(cpPath, 'utf8'));
2128
- const cpCount = (item._checkpointCount || 0) + 1;
2129
- if (cpCount > 3) {
2130
- log('warn', `Work item ${item.id} exceeded 3 checkpoint-resumes — marking as needs-human-review`);
2131
- mutations.set(item.id, { status: WI_STATUS.NEEDS_REVIEW, _checkpointCount: cpCount });
2132
- continue;
2133
- }
2134
- mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, { _checkpointCount: cpCount }));
2135
- const cpSummary = [
2136
- `## Checkpoint (Resume #${cpCount}/3)`,
2137
- '',
2138
- 'A previous agent run timed out but left a checkpoint. Continue from where it left off.',
2139
- '',
2140
- cpData.completed && cpData.completed.length > 0 ? `### Completed\n${cpData.completed.map(s => '- ' + s).join('\n')}` : '',
2141
- cpData.remaining && cpData.remaining.length > 0 ? `### Remaining\n${cpData.remaining.map(s => '- ' + s).join('\n')}` : '',
2142
- cpData.blockers && cpData.blockers.length > 0 ? `### Blockers\n${cpData.blockers.map(s => '- ' + s).join('\n')}` : '',
2143
- cpData.branch_state ? `### Branch State\n${cpData.branch_state}` : '',
2144
- ].filter(Boolean).join('\n');
2145
- vars.checkpoint_context = cpSummary;
2146
- log('info', `Injecting checkpoint context for ${item.id} (resume #${cpCount})`);
2147
- }
2148
- } catch (e) { log('warn', `checkpoint read for ${item.id}: ${e.message}`); }
2137
+
2138
+ // Build common vars: references, acceptance criteria, checkpoint, notes, task context
2139
+ const centralBranch = item.branch || `work/${item.id}`;
2140
+ const centralWtPath = firstProject?.localPath
2141
+ ? path.resolve(firstProject.localPath, config.engine?.worktreeRoot || '../worktrees', centralBranch)
2142
+ : '';
2143
+ const cpResult = buildWorkItemDispatchVars(item, vars, config, {
2144
+ worktreePath: centralWtPath || undefined,
2145
+ workType,
2146
+ });
2147
+ if (cpResult.needsReview) {
2148
+ log('warn', `Work item ${item.id} exceeded 3 checkpoint-resumes — marking as needs-human-review`);
2149
+ mutations.set(item.id, { status: WI_STATUS.NEEDS_REVIEW, _checkpointCount: cpResult.checkpointCount });
2150
+ continue;
2151
+ }
2152
+ if (cpResult.checkpointCount !== null) {
2153
+ mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, { _checkpointCount: cpResult.checkpointCount }));
2154
+ }
2149
2155
 
2150
2156
  // Inject plan-specific variables for the plan playbook
2151
2157
  if (workType === WORK_TYPE.PLAN) {
@@ -2156,8 +2162,7 @@ function discoverCentralWorkItems(config) {
2156
2162
  vars.plan_title = item.title;
2157
2163
  vars.plan_file = planFileName;
2158
2164
  vars.task_description = item.title;
2159
- vars.notes_content = '';
2160
- try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch { /* optional */ }
2165
+ // Notes already populated by buildWorkItemDispatchVars — no need to re-read
2161
2166
  // Track expected plan filename in meta for chainPlanToPrd
2162
2167
  mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, { _planFileName: planFileName }));
2163
2168
  }
@@ -2191,20 +2196,7 @@ function discoverCentralWorkItems(config) {
2191
2196
  : 'Choose the best strategy based on your analysis of item dependencies.';
2192
2197
  }
2193
2198
 
2194
- // Inject ask-specific variables for the ask playbook
2195
- if (workType === WORK_TYPE.ASK) {
2196
- vars.question = item.title + (item.description ? '\n\n' + item.description : '');
2197
- vars.task_id = item.id;
2198
- vars.notes_content = '';
2199
- try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch { /* optional */ }
2200
- }
2201
-
2202
- // Resolve implicit context references
2203
- const resolvedCtx = resolveTaskContext(item, config);
2204
- if (resolvedCtx.additionalContext) {
2205
- vars.additional_context = (vars.additional_context || '') + resolvedCtx.additionalContext;
2206
- vars.task_description = vars.task_description + resolvedCtx.additionalContext;
2207
- }
2199
+ // ASK and resolveTaskContext already handled by buildWorkItemDispatchVars above
2208
2200
 
2209
2201
  const playbookName = selectPlaybook(workType, item);
2210
2202
  const prompt = renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars);
@@ -2733,7 +2725,7 @@ module.exports = {
2733
2725
  reconcileItemsWithPrs, detectDependencyCycles,
2734
2726
 
2735
2727
  // Playbooks
2736
- renderPlaybook,
2728
+ renderPlaybook, buildWorkItemDispatchVars,
2737
2729
 
2738
2730
  // Timeout / Steering / Idle (re-exported from engine/timeout.js)
2739
2731
  checkTimeouts, checkSteering, checkIdleThreshold,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.547",
3
+ "version": "0.1.548",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"