@yemi33/minions 0.1.1552 → 0.1.1554

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1554 (2026-04-27)
4
+
5
+ ### Fixes
6
+ - archive bugs — undefined resetBtn, .backup re-trigger, dispatch leak
7
+
8
+ ## 0.1.1553 (2026-04-27)
9
+
10
+ ### Other
11
+ - refactor(queries): cache PR list, fold ghost-project scan into single readdir pass
12
+
3
13
  ## 0.1.1552 (2026-04-27)
4
14
 
5
15
  ### Fixes
@@ -601,13 +601,12 @@ async function planArchive(file, btn) {
601
601
  if (!confirm(confirmMsg)) return;
602
602
  _stopPlanPoll();
603
603
  markDeleted('plan:' + file);
604
- // Also optimistically hide the linked source plan (server archives both)
605
604
  if (isPrd) {
606
605
  var linkedPlan = (window._lastPlans || []).find(function(p) { return p.file === file && p.sourcePlan; });
607
606
  if (linkedPlan) markDeleted('plan:' + linkedPlan.sourcePlan);
608
607
  }
609
608
  try { closeModal(); } catch { /* may not be open */ }
610
- showToast('cmd-toast', 'Archiving...', true);
609
+ showToast('cmd-toast', 'Archived', true);
611
610
  try {
612
611
  const res = await fetch('/api/plans/archive', {
613
612
  method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -617,16 +616,18 @@ async function planArchive(file, btn) {
617
616
  if (!ct.includes('json')) { refresh(); return; }
618
617
  const d = await res.json().catch(() => ({}));
619
618
  if (res.ok && d.ok) {
620
- var msg = 'Archived';
621
- if (d.archivedSource) msg += ' PRD + source plan (' + d.archivedSource + ')';
622
- if (d.cancelledItems) msg += ', cancelled ' + d.cancelledItems + ' pending item(s)';
623
- showToast('cmd-toast', msg, true);
619
+ if (d.archivedSource || d.cancelledItems) {
620
+ var msg = 'Archived';
621
+ if (d.archivedSource) msg += ' PRD + source plan';
622
+ if (d.cancelledItems) msg += ', cancelled ' + d.cancelledItems + ' pending item(s)';
623
+ showToast('cmd-toast', msg, true);
624
+ }
624
625
  refresh();
625
626
  } else {
626
- resetBtn();
627
- alert('Archive failed: ' + (d.error || 'unknown'));
627
+ showToast('cmd-toast', 'Archive failed: ' + (d.error || 'unknown'), false);
628
+ refresh();
628
629
  }
629
- } catch (e) { resetBtn(); alert('Error: ' + e.message); }
630
+ } catch (e) { showToast('cmd-toast', 'Error: ' + e.message, false); refresh(); }
630
631
  }
631
632
 
632
633
  async function planPause(file, btn) {
@@ -793,8 +794,12 @@ async function planUnarchive(file, btn) {
793
794
  body: JSON.stringify({ file })
794
795
  });
795
796
  if (res.ok) { refreshPlans(); refresh(); }
796
- else { const d = await res.json().catch(() => ({})); alert('Unarchive failed: ' + (d.error || 'unknown')); refresh(); }
797
- } catch (e) { alert('Error: ' + e.message); refresh(); }
797
+ else {
798
+ const d = await res.json().catch(() => ({}));
799
+ showToast('cmd-toast', 'Unarchive failed: ' + (d.error || 'unknown'), false);
800
+ refresh();
801
+ }
802
+ } catch (e) { showToast('cmd-toast', 'Error: ' + e.message, false); refresh(); }
798
803
  }
799
804
 
800
805
  window.MinionsPlans = { openCreatePlanModal, refreshPlans, derivePlanStatus, renderPlans, openArchivedPlansModal, planExecute, planSubmitRevise, planShowRevise, planHideRevise, planView, planApprove, planArchive, planUnarchive, planDelete, planPause, planReject, planDiscuss, planOpenInDocChat, planRegeneratePRD, openVerifyGuide, triggerVerify };
package/dashboard.js CHANGED
@@ -3341,46 +3341,69 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
3341
3341
  try {
3342
3342
  const body = await readBody(req);
3343
3343
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
3344
- shared.sanitizePath(body.file, body.file.endsWith('.json') ? PRD_DIR : PLANS_DIR);
3344
+ const isPrd = body.file.endsWith('.json');
3345
+ shared.sanitizePath(body.file, isPrd ? PRD_DIR : PLANS_DIR);
3345
3346
  const planPath = resolvePlanPath(body.file);
3346
3347
  if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
3347
3348
 
3348
- // Move to archive directory
3349
- const archiveDir = body.file.endsWith('.json') ? path.join(PRD_DIR, 'archive') : path.join(PLANS_DIR, 'archive');
3349
+ const archiveDir = isPrd ? path.join(PRD_DIR, 'archive') : path.join(PLANS_DIR, 'archive');
3350
3350
  if (!fs.existsSync(archiveDir)) fs.mkdirSync(archiveDir, { recursive: true });
3351
3351
  const archivePath = path.join(archiveDir, body.file);
3352
3352
  fs.renameSync(planPath, archivePath);
3353
3353
 
3354
- // Mark archived in JSON if PRD
3355
3354
  let archivedSource = null;
3356
- if (body.file.endsWith('.json')) {
3355
+ let plan = {};
3356
+ if (isPrd) {
3357
3357
  try {
3358
- const prd = safeJsonObj(archivePath);
3359
- prd.status = 'archived';
3360
- prd.archivedAt = new Date().toISOString();
3361
- safeWrite(archivePath, prd);
3362
- // Also archive linked source plan
3363
- if (prd.source_plan) {
3364
- const mdPath = path.join(PLANS_DIR, prd.source_plan);
3358
+ plan = safeJsonObj(archivePath) || {};
3359
+ plan.status = 'archived';
3360
+ plan.archivedAt = new Date().toISOString();
3361
+ safeWrite(archivePath, plan);
3362
+ // Without removing the .backup sidecar, safeJson would auto-restore the
3363
+ // pre-completion snapshot on engine restart, re-triggering plan completion
3364
+ // and spawning duplicate verify tasks (regression of #f28162b0).
3365
+ const backupPath = planPath + '.backup';
3366
+ try { fs.unlinkSync(backupPath); } catch {
3367
+ try { fs.writeFileSync(backupPath, JSON.stringify({ status: 'archived' })); } catch { /* best-effort */ }
3368
+ }
3369
+ if (plan.source_plan) {
3370
+ const mdPath = path.join(PLANS_DIR, plan.source_plan);
3365
3371
  if (fs.existsSync(mdPath)) {
3366
3372
  const planArchive = path.join(PLANS_DIR, 'archive');
3367
3373
  if (!fs.existsSync(planArchive)) fs.mkdirSync(planArchive, { recursive: true });
3368
- fs.renameSync(mdPath, path.join(planArchive, prd.source_plan));
3369
- archivedSource = prd.source_plan;
3374
+ fs.renameSync(mdPath, path.join(planArchive, plan.source_plan));
3375
+ archivedSource = plan.source_plan;
3370
3376
  }
3371
3377
  }
3372
3378
  } catch { /* optional */ }
3373
3379
  }
3374
3380
 
3375
- // Clean up worktrees associated with this plan
3381
+ // Cancel pending work items linked to this plan so the engine stops
3382
+ // dispatching for an archived plan. Done items are preserved as history.
3383
+ let cancelledItems = 0;
3384
+ const wiPaths = [path.join(MINIONS_DIR, 'work-items.json'), ...PROJECTS.map(p => shared.projectWorkItemsPath(p))];
3385
+ for (const wiPath of wiPaths) {
3386
+ try {
3387
+ mutateWorkItems(wiPath, items => {
3388
+ for (const w of items) {
3389
+ if (w.sourcePlan !== body.file) continue;
3390
+ if (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.QUEUED) {
3391
+ w.status = WI_STATUS.CANCELLED;
3392
+ w._cancelledBy = 'plan-archived';
3393
+ cancelledItems++;
3394
+ }
3395
+ }
3396
+ });
3397
+ } catch (e) { console.error('plan archive cancel:', e.message); }
3398
+ }
3399
+
3376
3400
  try {
3377
- const plan = body.file.endsWith('.json') ? (safeJsonObj(archivePath) || {}) : {};
3378
3401
  const { cleanupPlanWorktrees } = require('./engine/lifecycle');
3379
3402
  cleanupPlanWorktrees(body.file, plan, PROJECTS, getConfig());
3380
3403
  } catch (e) { console.error('plan worktree cleanup:', e.message); }
3381
3404
 
3382
3405
  invalidateStatusCache();
3383
- return jsonReply(res, 200, { ok: true, archived: body.file, archivedSource });
3406
+ return jsonReply(res, 200, { ok: true, archived: body.file, archivedSource, cancelledItems });
3384
3407
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
3385
3408
  }
3386
3409
 
@@ -231,8 +231,9 @@ function evaluateCondition(condition, ctx) {
231
231
  return pipelineRuns.length >= threshold;
232
232
  }
233
233
  case 'allBuildsGreen': {
234
- // True when all PRs in monitoredResources (pipeline-level or stage-level) or run artifacts have buildStatus 'passing'
235
- const allPrs = queries.getPullRequests(config);
234
+ // True when all PRs in monitoredResources (pipeline-level or stage-level) or run artifacts have buildStatus 'passing'.
235
+ // Exclude ghost PRs (records from project subdirs not in config) — their buildStatus is stale because no poller updates them.
236
+ const allPrs = queries.getPullRequests(config).filter(pr => !pr._ghost);
236
237
  const prRefs = collectPipelinePrRefs(pipeline, run);
237
238
  if (prRefs.length === 0) return false; // no PRs to check = can't confirm green
238
239
  for (const prRef of prRefs) {
package/engine/queries.js CHANGED
@@ -11,7 +11,7 @@ const shared = require('./shared');
11
11
 
12
12
  const { safeRead, safeReadDir, safeJson, safeWrite, getProjects, mutateJsonFileLocked,
13
13
  projectWorkItemsPath, projectPrPath, parseSkillFrontmatter, KB_CATEGORIES,
14
- WI_STATUS, DONE_STATUSES, PRD_ITEM_STATUS, ENGINE_DEFAULTS } = shared;
14
+ WI_STATUS, DONE_STATUSES, PRD_ITEM_STATUS, PR_STATUS, ENGINE_DEFAULTS } = shared;
15
15
 
16
16
  /**
17
17
  * Read the first `bytes` and last `bytes` of a file efficiently using byte offsets.
@@ -472,34 +472,57 @@ function getPrs(project) {
472
472
  return all;
473
473
  }
474
474
 
475
+ // Cache: getPullRequests is called 3-5x per /api/status (getMetrics, getWorkItems,
476
+ // getPrdInfo, dashboard.js status + count). 1s TTL eliminates redundant fs reads
477
+ // within a single request without masking real updates from polling.
478
+ let _prsCache = null;
479
+ let _prsCacheAt = 0;
480
+
475
481
  function getPullRequests(config) {
482
+ const now = Date.now();
483
+ if (_prsCache && (now - _prsCacheAt) < 1000) return _prsCache;
476
484
  config = config || getConfig();
477
485
  const projects = getProjects(config);
486
+ const projectByName = new Map(projects.map(p => [p.name, p]));
478
487
  const allPrs = [];
479
- for (const project of projects) {
480
- const prs = safeJson(projectPrPath(project));
481
- if (!prs) continue;
488
+ const seenIds = new Set();
489
+ // Single pass over projects/* — configured projects use their full config
490
+ // (prUrlBase fill-in, _project name); unconfigured subdirs are tagged _ghost
491
+ // so engine code can filter them out. Mirrors what shared.getPrLinks scans,
492
+ // so PRD links and PR records stay in sync after a project is removed.
493
+ let projectDirs = [];
494
+ try {
495
+ projectDirs = fs.readdirSync(path.join(MINIONS_DIR, 'projects'), { withFileTypes: true })
496
+ .filter(d => d.isDirectory()).map(d => d.name);
497
+ } catch { /* projects dir missing */ }
498
+ for (const dirName of projectDirs) {
499
+ const project = projectByName.get(dirName) || null;
500
+ const prPath = project ? projectPrPath(project) : path.join(MINIONS_DIR, 'projects', dirName, 'pull-requests.json');
501
+ const prs = safeJson(prPath);
502
+ if (!Array.isArray(prs)) continue;
482
503
  shared.normalizePrRecords(prs, project);
483
- const base = project.prUrlBase || '';
504
+ const base = project?.prUrlBase || '';
484
505
  for (const pr of prs) {
485
- if (!pr.url && base) {
506
+ if (!pr?.id || seenIds.has(pr.id)) continue;
507
+ if (project && !pr.url && base) {
486
508
  const prNumber = shared.getPrNumber(pr);
487
509
  if (prNumber != null) pr.url = base + prNumber;
488
510
  }
489
- pr._project = project.name || 'Project';
511
+ pr._project = project ? (project.name || 'Project') : dirName;
512
+ if (!project) pr._ghost = true;
490
513
  allPrs.push(pr);
514
+ seenIds.add(pr.id);
491
515
  }
492
516
  }
493
- // Also read central pull-requests.json (for manually linked PRs without a project)
494
- const centralPath = path.join(MINIONS_DIR, 'pull-requests.json');
495
- const centralPrs = safeJson(centralPath);
517
+ // Central pull-requests.json manually linked PRs without a project
518
+ const centralPrs = safeJson(path.join(MINIONS_DIR, 'pull-requests.json'));
496
519
  if (centralPrs) {
497
520
  shared.normalizePrRecords(centralPrs, null);
498
521
  for (const pr of centralPrs) {
499
- if (!allPrs.some(p => p.id === pr.id)) {
500
- pr._project = 'central';
501
- allPrs.push(pr);
502
- }
522
+ if (!pr?.id || seenIds.has(pr.id)) continue;
523
+ pr._project = 'central';
524
+ allPrs.push(pr);
525
+ seenIds.add(pr.id);
503
526
  }
504
527
  }
505
528
  allPrs.sort((a, b) => {
@@ -513,9 +536,35 @@ function getPullRequests(config) {
513
536
  const bNum = parseInt((b.id || '').replace(/\D/g, '')) || 0;
514
537
  return bNum - aNum;
515
538
  });
539
+ _prsCache = allPrs;
540
+ _prsCacheAt = now;
516
541
  return allPrs;
517
542
  }
518
543
 
544
+ // Resolve a PR URL by preferring the canonical PR ID's own scope (e.g.
545
+ // `github:owner/repo#N`) so a github PR never gets an ADO URL just because the
546
+ // only configured project happens to be ADO. Falls back to a name-matched
547
+ // project's prUrlBase only when the ID is legacy (`PR-N`) and a real project
548
+ // owns it. Never blindly uses projects[0].
549
+ function buildPrUrlFromId(prId, pr, projects) {
550
+ if (pr?.url) return pr.url;
551
+ const canonical = shared.parseCanonicalPrId(prId);
552
+ if (canonical) {
553
+ const [host, rest] = canonical.scope.split(':');
554
+ if (host === 'github') return `https://github.com/${rest}/pull/${canonical.prNumber}`;
555
+ if (host === 'ado') {
556
+ const [org, adoProject, repo] = rest.split('/');
557
+ if (org && adoProject && repo) {
558
+ return `https://dev.azure.com/${org}/${adoProject}/_git/${repo}/pullrequest/${canonical.prNumber}`;
559
+ }
560
+ }
561
+ }
562
+ const project = pr?._project ? projects.find(p => p.name === pr._project) : null;
563
+ const prNumber = shared.getPrNumber(pr || prId);
564
+ if (project?.prUrlBase && prNumber != null) return project.prUrlBase + prNumber;
565
+ return '';
566
+ }
567
+
519
568
  // ── Skills ──────────────────────────────────────────────────────────────────
520
569
 
521
570
  function collectSkillFiles(config) {
@@ -1017,56 +1066,12 @@ function getPrdInfo(config) {
1017
1066
  for (const wi of centralWi) { if (!wi?.id) { console.warn('[queries] Skipping central work item without id:', JSON.stringify(wi).slice(0, 120)); continue; } if (wi.sourcePlan && !wiById[wi.id]) wiById[wi.id] = wi; }
1018
1067
  } catch { /* optional */ }
1019
1068
 
1020
- // PR-to-PRD linking — derived from PR.prdItems (single source of truth)
1069
+ // PR-to-PRD linking — derived from PR.prdItems (single source of truth).
1070
+ // getPullRequests includes records from unconfigured project subdirs so PRD
1071
+ // links can resolve to last-known status even after a project is removed.
1021
1072
  const allPrs = getPullRequests(config);
1022
1073
  const prById = {};
1023
1074
  for (const pr of allPrs) prById[pr.id] = pr;
1024
- // Also scan project subdirectories that aren't in config (e.g. removed projects
1025
- // whose pull-requests.json still holds last-known status). getPrLinks already
1026
- // reads all subdirs, so without this PRs from those dirs would have status/title
1027
- // missing in the PRD view and fall back to literal 'active'/'' defaults.
1028
- try {
1029
- const projectsDir = path.join(MINIONS_DIR, 'projects');
1030
- const projectsByName = new Map(projects.map(p => [p.name, p]));
1031
- for (const d of fs.readdirSync(projectsDir, { withFileTypes: true })) {
1032
- if (!d.isDirectory() || projectsByName.has(d.name)) continue;
1033
- const ghostPath = path.join(projectsDir, d.name, 'pull-requests.json');
1034
- const ghostPrs = safeJson(ghostPath);
1035
- if (!Array.isArray(ghostPrs)) continue;
1036
- shared.normalizePrRecords(ghostPrs, null);
1037
- for (const pr of ghostPrs) {
1038
- if (pr?.id && !prById[pr.id]) {
1039
- pr._project = d.name;
1040
- prById[pr.id] = pr;
1041
- }
1042
- }
1043
- }
1044
- } catch { /* projects dir missing */ }
1045
-
1046
- // Build URL for a PR when pr.url isn't set. Prefer the PR ID's own scope
1047
- // (e.g. `github:owner/repo#123` → github.com URL) so a github PR never gets
1048
- // an ADO URL just because the only configured project happens to be ADO.
1049
- // Only use a project's prUrlBase when its host actually matches the PR.
1050
- const _buildPrUrlFromId = (prId, pr) => {
1051
- if (pr?.url) return pr.url;
1052
- const canonical = shared.parseCanonicalPrId(prId);
1053
- if (canonical) {
1054
- const [host, rest] = canonical.scope.split(':');
1055
- if (host === 'github') return `https://github.com/${rest}/pull/${canonical.prNumber}`;
1056
- if (host === 'ado') {
1057
- const [org, adoProject, repo] = rest.split('/');
1058
- if (org && adoProject && repo) {
1059
- return `https://dev.azure.com/${org}/${adoProject}/_git/${repo}/pullrequest/${canonical.prNumber}`;
1060
- }
1061
- }
1062
- }
1063
- // Legacy `PR-N` ID with no host scope: only use project's prUrlBase if a
1064
- // matching project (by name) exists. Never blindly fall back to projects[0].
1065
- const project = pr?._project ? projects.find(p => p.name === pr._project) : null;
1066
- const prNumber = shared.getPrNumber(pr || prId);
1067
- if (project?.prUrlBase && prNumber != null) return project.prUrlBase + prNumber;
1068
- return '';
1069
- };
1070
1075
 
1071
1076
  const prdToPr = {};
1072
1077
  const prLinks = shared.getPrLinks(); // { "PR-xxxx": ["P-xxxx", "P-yyyy"] }
@@ -1076,10 +1081,10 @@ function getPrdInfo(config) {
1076
1081
  // (or are typed as verify) and would bleed through as duplicate entries on every
1077
1082
  // constituent item. They are surfaced via renderE2eSection instead. (#1220)
1078
1083
  if ((itemIds || []).length > 1 || pr?.itemType === 'verify' || pr?.title?.startsWith('[E2E]')) continue;
1079
- const url = _buildPrUrlFromId(prId, pr);
1084
+ const url = buildPrUrlFromId(prId, pr, projects);
1080
1085
  for (const itemId of (itemIds || [])) {
1081
1086
  if (!prdToPr[itemId]) prdToPr[itemId] = [];
1082
- prdToPr[itemId].push({ id: prId, url, title: pr?.title || '', status: pr?.status || 'active', _project: pr?._project || '' });
1087
+ prdToPr[itemId].push({ id: prId, url, title: pr?.title || '', status: pr?.status || PR_STATUS.ACTIVE, _project: pr?._project || '' });
1083
1088
  }
1084
1089
  }
1085
1090
  // Fallback: work item _pr field for anything still missing
@@ -1090,21 +1095,20 @@ function getPrdInfo(config) {
1090
1095
  const exactPr = prById[canonicalPrId] || null;
1091
1096
  const displayMatches = exactPr ? [] : Object.values(prById).filter(candidate => shared.getPrDisplayId(candidate) === shared.getPrDisplayId(wi._pr));
1092
1097
  const pr = exactPr || (displayMatches.length === 1 ? displayMatches[0] : null);
1093
- const url = _buildPrUrlFromId(canonicalPrId || wi._pr, pr);
1094
- prdToPr[wi.id] = [{ id: pr?.id || canonicalPrId || wi._pr, url, title: pr?.title || '', status: pr?.status || 'active', _project: project?.name || '' }];
1098
+ const url = buildPrUrlFromId(canonicalPrId || wi._pr, pr, projects);
1099
+ prdToPr[wi.id] = [{ id: pr?.id || canonicalPrId || wi._pr, url, title: pr?.title || '', status: pr?.status || PR_STATUS.ACTIVE, _project: project?.name || '' }];
1095
1100
  }
1096
1101
  // Aggregate sub-task PRs to decomposed parent (sub-tasks aren't PRD items but their PRs should show)
1097
1102
  for (const pr of allPrs) {
1098
1103
  for (const itemId of (pr.prdItems || [])) {
1099
- // Find if this is a sub-task with a parent
1100
1104
  const allItems = Object.values(wiById);
1101
1105
  const wi = allItems.find(w => w.id === itemId && w.parent_id);
1102
1106
  if (!wi) continue;
1103
1107
  const parentId = wi.parent_id;
1104
1108
  if (!prdToPr[parentId]) prdToPr[parentId] = [];
1105
1109
  if (!prdToPr[parentId].some(p => p.id === pr.id)) {
1106
- const url = _buildPrUrlFromId(pr.id, pr);
1107
- prdToPr[parentId].push({ id: pr.id, url, title: pr.title || '', status: pr.status || 'active', _project: pr._project || '' });
1110
+ const url = buildPrUrlFromId(pr.id, pr, projects);
1111
+ prdToPr[parentId].push({ id: pr.id, url, title: pr.title || '', status: pr.status || PR_STATUS.ACTIVE, _project: pr._project || '' });
1108
1112
  }
1109
1113
  }
1110
1114
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1552",
3
+ "version": "0.1.1554",
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"