@yemi33/minions 0.1.1007 → 0.1.1008

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,6 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1007 (2026-04-16)
3
+ ## 0.1.1008 (2026-04-16)
4
4
 
5
5
  ### Features
6
6
  - fix duplicate PR creation + cancel stale review dispatches on PR close (#1137)
@@ -25,6 +25,8 @@
25
25
  - implement dashboard loop/watch management panel
26
26
 
27
27
  ### Fixes
28
+ - harden audited state transitions
29
+ - scrub stale temp agent IDs from metrics.json (#1129)
28
30
  - harden repo-scoped PR identity handling
29
31
  - only gate reviews/fixes when no free agent slots remain
30
32
  - extend auto-link fallback to ADO projects
@@ -43,8 +45,6 @@
43
45
  - build fix sets fixDispatched to block same-tick conflict fix
44
46
  - ENGINE_DEFAULTS undefined in tick + playbook empty-var false positives
45
47
  - defer review setCooldown to post-gating in discoverWork
46
- - move review verdict check before updateWorkItemStatus(DONE)
47
- - address review feedback — move writeToInbox outside lock, add absolute condition auto-expire
48
48
 
49
49
  ### Other
50
50
  - refactor: migrate PR links to array format and consolidate shared helpers
package/dashboard.js CHANGED
@@ -720,6 +720,7 @@ async function executeCCActions(actions) {
720
720
  const kbDir = path.join(MINIONS_DIR, 'knowledge', category);
721
721
  if (!fs.existsSync(kbDir)) fs.mkdirSync(kbDir, { recursive: true });
722
722
  shared.safeWrite(path.join(kbDir, slug + '.md'), `# ${action.title}\n\n${action.content || action.description || ''}`);
723
+ queries.invalidateKnowledgeBaseCache();
723
724
  results.push({ type: 'knowledge', ok: true });
724
725
  break;
725
726
  }
@@ -1874,13 +1875,18 @@ const server = http.createServer(async (req, res) => {
1874
1875
  if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
1875
1876
  const planPath = resolvePlanPath(body.source);
1876
1877
  if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
1877
- const plan = safeJsonObj(planPath);
1878
- if (!plan) return jsonReply(res, 500, { error: 'failed to read plan file' });
1879
- const idx = (plan.missing_features || []).findIndex(f => f.id === body.itemId);
1880
- if (idx < 0) return jsonReply(res, 404, { error: 'item not found in plan' });
1881
-
1882
- plan.missing_features.splice(idx, 1);
1883
- safeWrite(planPath, plan);
1878
+ let removed = false;
1879
+ mutateJsonFileLocked(planPath, (plan) => {
1880
+ if (!plan || Array.isArray(plan) || typeof plan !== 'object') plan = { missing_features: [] };
1881
+ const features = Array.isArray(plan.missing_features) ? plan.missing_features : [];
1882
+ const idx = features.findIndex(f => f.id === body.itemId);
1883
+ if (idx < 0) return plan;
1884
+ features.splice(idx, 1);
1885
+ plan.missing_features = features;
1886
+ removed = true;
1887
+ return plan;
1888
+ }, { defaultValue: { missing_features: [] } });
1889
+ if (!removed) return jsonReply(res, 404, { error: 'item not found in plan' });
1884
1890
 
1885
1891
  // Also remove any materialized work item for this plan item
1886
1892
  let cancelled = false;
@@ -2237,7 +2243,7 @@ const server = http.createServer(async (req, res) => {
2237
2243
  for (const cat of shared.KB_CATEGORIES) result[cat] = [];
2238
2244
  for (const e of entries) {
2239
2245
  if (!result[e.cat]) result[e.cat] = [];
2240
- result[e.cat].push({ file: e.file, category: e.cat, title: e.title, agent: e.agent, date: e.date, size: e.size, preview: e.preview });
2246
+ result[e.cat].push({ file: e.file, category: e.cat, title: e.title, agent: e.agent, date: e.date, sortTs: e.sortTs, size: e.size, preview: e.preview });
2241
2247
  }
2242
2248
  const swept = safeJson(path.join(ENGINE_DIR, 'kb-swept.json'));
2243
2249
  if (swept) result.lastSwept = swept.timestamp;
@@ -2431,6 +2437,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2431
2437
 
2432
2438
  const summary = `${merged} duplicates merged, ${removed} stale removed, ${reclassified} reclassified${pruned ? ', ' + pruned + ' old swept files pruned' : ''}`;
2433
2439
  safeWrite(path.join(ENGINE_DIR, 'kb-swept.json'), JSON.stringify({ timestamp: new Date().toISOString(), summary }));
2440
+ queries.invalidateKnowledgeBaseCache();
2434
2441
  global._kbSweepLastResult = { ok: true, summary, plan };
2435
2442
  global._kbSweepLastCompletedAt = Date.now();
2436
2443
  } catch (e) {
@@ -3276,6 +3283,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3276
3283
  if (!fs.existsSync(kbDir)) fs.mkdirSync(kbDir, { recursive: true });
3277
3284
  const kbFile = path.join(kbDir, name);
3278
3285
  safeWrite(kbFile, kbContent);
3286
+ queries.invalidateKnowledgeBaseCache();
3279
3287
 
3280
3288
  // Move inbox item to archive
3281
3289
  const archiveDir = path.join(MINIONS_DIR, 'notes', 'archive');
@@ -4679,6 +4687,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4679
4687
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
4680
4688
  const header = '# ' + title + '\n\n*Created by human teammate on ' + new Date().toISOString().slice(0, 10) + '*\n\n';
4681
4689
  safeWrite(filePath, header + content);
4690
+ queries.invalidateKnowledgeBaseCache();
4682
4691
  invalidateStatusCache();
4683
4692
  return jsonReply(res, 200, { ok: true, path: filePath });
4684
4693
  }},
package/engine/cleanup.js CHANGED
@@ -9,7 +9,7 @@ const shared = require('./shared');
9
9
  const queries = require('./queries');
10
10
 
11
11
  const { exec, execSilent, log, ts, ENGINE_DEFAULTS } = shared;
12
- const { safeJson, safeWrite, safeReadDir, mutateWorkItems, getProjects, projectWorkItemsPath, projectPrPath,
12
+ const { safeJson, safeWrite, safeReadDir, mutateWorkItems, mutateJsonFileLocked, getProjects, projectWorkItemsPath, projectPrPath,
13
13
  sanitizeBranch, KB_CATEGORIES } = shared;
14
14
  const { getDispatch, getAgentStatus } = queries;
15
15
 
@@ -667,6 +667,9 @@ function runCleanup(config, verbose = false) {
667
667
  }
668
668
  } catch { /* optional — file may not exist */ }
669
669
 
670
+ // 14. Scrub stale temp agent keys from metrics.json
671
+ try { scrubStaleMetrics(); } catch { /* best-effort cleanup */ }
672
+
670
673
  if (cleaned.ccSessions + cleaned.docSessions + cleaned.cooldowns + cleaned.pidFiles + cleaned.pendingContextsTrimmed > 0) {
671
674
  log('info', `Cleanup (resources): ${cleaned.ccSessions} cc-sessions, ${cleaned.docSessions} doc-sessions, ${cleaned.cooldowns} cooldowns, ${cleaned.pendingContextsTrimmed} pendingCtx trimmed, ${cleaned.pidFiles} PID files`);
672
675
  }
@@ -674,9 +677,27 @@ function runCleanup(config, verbose = false) {
674
677
  return cleaned;
675
678
  }
676
679
 
680
+ // ─── Metrics Scrub ──────────────────────────────────────────────────────────
681
+
682
+ /** Remove stale temp-*, agent1, and _test-* keys from metrics.json.
683
+ * Mirrors the guard in lifecycle.js updateMetrics() — cleans up keys
684
+ * that were written before that guard existed. */
685
+ function scrubStaleMetrics() {
686
+ const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
687
+ if (!fs.existsSync(metricsPath)) return;
688
+ mutateJsonFileLocked(metricsPath, metrics => {
689
+ for (const key of Object.keys(metrics)) {
690
+ if (key.startsWith('temp-') || key === 'agent1' || key.startsWith('_test')) {
691
+ delete metrics[key];
692
+ }
693
+ }
694
+ });
695
+ }
696
+
677
697
  // ─── Exports ─────────────────────────────────────────────────────────────────
678
698
 
679
699
  module.exports = {
680
700
  runCleanup,
701
+ scrubStaleMetrics,
681
702
  worktreeDirMatchesBranch, // exported for testing
682
703
  };
@@ -13,6 +13,7 @@ const { safeRead, safeJson, safeWrite, mutateJsonFileLocked, mutateWorkItems, ex
13
13
  const { trackEngineUsage } = require('./llm');
14
14
  const queries = require('./queries');
15
15
  const { isBranchActive } = require('./cooldown');
16
+ const { worktreeDirMatchesBranch } = require('./cleanup');
16
17
  const { getConfig, getInboxFiles, getNotes, getPrs, getDispatch,
17
18
  MINIONS_DIR, ENGINE_DIR, PLANS_DIR, PRD_DIR, INBOX_DIR, AGENTS_DIR } = queries;
18
19
 
@@ -1065,8 +1066,10 @@ const PENDING_REBASES_PATH = path.join(ENGINE_DIR, 'pending-rebases.json');
1065
1066
 
1066
1067
  function queuePendingRebase(pr, project, mergedItemId) {
1067
1068
  mutateJsonFileLocked(PENDING_REBASES_PATH, (pending) => {
1068
- if (pending.some(e => e.prId === pr.id)) return; // already queued
1069
+ const prDisplayId = shared.getPrDisplayId(pr);
1070
+ if (pending.some(e => e.projectName === project.name && shared.getPrDisplayId(e.prId) === prDisplayId)) return pending; // already queued
1069
1071
  pending.push({ prId: pr.id, branch: pr.branch, projectName: project.name, mergedItemId, queuedAt: ts(), attempts: 0 });
1072
+ return pending;
1070
1073
  }, { defaultValue: [] });
1071
1074
  }
1072
1075
 
@@ -1087,9 +1090,11 @@ async function processPendingRebases(config) {
1087
1090
  const project = shared.getProjects(config).find(p => p.name === entry.projectName);
1088
1091
  if (!project) continue;
1089
1092
 
1090
- const prs = safeJson(projectPrPath(project)) || [];
1091
- const pr = prs.find(p => p.id === entry.prId && p.status === PR_STATUS.ACTIVE);
1093
+ const prs = getPrs(project);
1094
+ const pr = shared.findPrRecord(prs, entry.prId, project);
1095
+ if (pr && pr.id !== entry.prId) entry.prId = pr.id;
1092
1096
  if (!pr) continue; // PR closed/merged since queuing
1097
+ if (pr.status !== PR_STATUS.ACTIVE) continue; // PR closed/merged since queuing
1093
1098
 
1094
1099
  const result = await rebaseBranchOntoMain(pr, project, config);
1095
1100
  if (!result.success) {
@@ -1120,12 +1125,11 @@ async function handlePostMerge(pr, project, config, newStatus) {
1120
1125
  const root = path.resolve(project.localPath);
1121
1126
  const wtRoot = path.resolve(root, config.engine?.worktreeRoot || '../worktrees');
1122
1127
  // Find worktrees matching this branch — dir format is {slug}-{branch}-{suffix}
1123
- const branchSlug = shared.sanitizeBranch(pr.branch).toLowerCase();
1124
1128
  try {
1125
1129
  const dirs = require('fs').readdirSync(wtRoot);
1126
1130
  for (const dir of dirs) {
1127
1131
  const dirLower = dir.toLowerCase();
1128
- if (dirLower.includes(branchSlug) || dir === pr.branch || dir === `bt-${prNum}`) {
1132
+ if (worktreeDirMatchesBranch(dirLower, pr.branch) || dir === pr.branch || dir === `bt-${prNum}`) {
1129
1133
  const wtPath = path.join(wtRoot, dir);
1130
1134
  try {
1131
1135
  if (!require('fs').statSync(wtPath).isDirectory()) continue;
@@ -1802,7 +1806,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1802
1806
  // Find the worktree directory for this dispatch's branch
1803
1807
  const branchSlug = shared.sanitizeBranch ? shared.sanitizeBranch(meta.branch) : meta.branch.replace(/[^a-zA-Z0-9._\-\/]/g, '-');
1804
1808
  const dirs = fs.readdirSync(worktreeRoot).filter(d => {
1805
- return d.includes(branchSlug) && fs.statSync(path.join(worktreeRoot, d)).isDirectory();
1809
+ return worktreeDirMatchesBranch(d.toLowerCase(), meta.branch) && fs.statSync(path.join(worktreeRoot, d)).isDirectory();
1806
1810
  });
1807
1811
  // Only remove if no other active dispatch uses this branch
1808
1812
  const dispatch = getDispatch();
@@ -7,6 +7,7 @@
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
9
  const shared = require('./shared');
10
+ const queries = require('./queries');
10
11
  const { safeJson, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, ENGINE_DEFAULTS } = shared;
11
12
  const http = require('http');
12
13
  const { parseCronExpr, shouldRunNow } = require('./scheduler');
@@ -144,6 +145,46 @@ function resolveStageConfig(stage, run) {
144
145
  return resolved;
145
146
  }
146
147
 
148
+ function collectPipelinePrRefs(pipeline, run) {
149
+ const refs = [];
150
+ const seen = new Set();
151
+ function addPrRef(resource) {
152
+ if (!resource) return;
153
+ let type = '';
154
+ let id = '';
155
+ let url = '';
156
+ if (typeof resource === 'string') {
157
+ id = resource.trim();
158
+ url = /^https?:\/\//i.test(id) ? id : '';
159
+ } else if (typeof resource === 'object') {
160
+ type = String(resource.type || '').trim().toLowerCase();
161
+ id = String(resource.label || resource.id || '').trim();
162
+ url = String(resource.url || '').trim();
163
+ } else {
164
+ return;
165
+ }
166
+ if (type && type !== 'pr') return;
167
+ const refId = id || url;
168
+ if (!refId && !url) return;
169
+ const prNumber = shared.getPrNumber({ id: refId, url });
170
+ if (!type && prNumber == null) return;
171
+ const key = url || refId;
172
+ if (!key || seen.has(key)) return;
173
+ seen.add(key);
174
+ refs.push({ id: refId, url });
175
+ }
176
+ for (const res of (pipeline?.monitoredResources || [])) addPrRef(res);
177
+ for (const stage of (pipeline?.stages || [])) {
178
+ for (const res of (stage?.monitoredResources || [])) addPrRef(res);
179
+ }
180
+ if (run) {
181
+ for (const [, stageState] of Object.entries(run.stages || {})) {
182
+ for (const prRef of (stageState.artifacts?.prs || [])) addPrRef(prRef);
183
+ }
184
+ }
185
+ return refs;
186
+ }
187
+
147
188
  // ── Condition Evaluation ─────────────────────────────────────────────────────
148
189
 
149
190
  /**
@@ -186,27 +227,12 @@ function evaluateCondition(condition, ctx) {
186
227
  return pipelineRuns.length >= threshold;
187
228
  }
188
229
  case 'allBuildsGreen': {
189
- // True when all PRs in monitoredResources (or run artifacts) have buildStatus 'passing'
190
- const projects = shared.getProjects(config);
191
- const allPrs = projects.reduce((acc, p) => {
192
- return acc.concat(safeJson(shared.projectPrPath(p)) || []);
193
- }, []);
194
-
195
- // Collect PR IDs to check: from monitoredResources (type: 'pr') or run artifact PRs
196
- const prIds = new Set();
197
- for (const res of (pipeline?.monitoredResources || [])) {
198
- const r = typeof res === 'string' ? { label: res } : res;
199
- if (r.type === 'pr' && r.label) prIds.add(r.label);
200
- }
201
- // Also check PRs from run artifacts
202
- if (run) {
203
- for (const [, s] of Object.entries(run.stages || {})) {
204
- for (const prId of (s.artifacts?.prs || [])) prIds.add(prId);
205
- }
206
- }
207
- if (prIds.size === 0) return false; // no PRs to check = can't confirm green
208
- for (const prId of prIds) {
209
- const pr = allPrs.find(p => p.id === prId);
230
+ // True when all PRs in monitoredResources (pipeline-level or stage-level) or run artifacts have buildStatus 'passing'
231
+ const allPrs = queries.getPullRequests(config);
232
+ const prRefs = collectPipelinePrRefs(pipeline, run);
233
+ if (prRefs.length === 0) return false; // no PRs to check = can't confirm green
234
+ for (const prRef of prRefs) {
235
+ const pr = shared.findPrRecord(allPrs, prRef);
210
236
  if (!pr || pr.buildStatus !== 'passing') return false;
211
237
  }
212
238
  return true;
package/engine/teams.js CHANGED
@@ -526,7 +526,7 @@ async function teamsNotifyPrEvent(pr, event, project, prFilePath) {
526
526
  if (!cfg.notifyEvents || !cfg.notifyEvents.includes(event)) return;
527
527
 
528
528
  // Dedup check — don't re-notify the same event
529
- if (pr._teamsNotifiedEvents && pr._teamsNotifiedEvents.includes(event)) return;
529
+ if (!prFilePath && pr._teamsNotifiedEvents && pr._teamsNotifiedEvents.includes(event)) return;
530
530
 
531
531
  const card = cards.buildPrCard(pr, event, project);
532
532
 
@@ -535,24 +535,45 @@ async function teamsNotifyPrEvent(pr, event, project, prFilePath) {
535
535
  const convKeys = Object.keys(state.conversations || {});
536
536
  if (convKeys.length === 0) return;
537
537
 
538
+ let claimedEvent = false;
539
+ let shouldPost = true;
538
540
  try {
539
- await teamsPost(convKeys[0], card);
540
- log('info', `Teams PR notification sent: ${event} for ${pr.id}`);
541
-
542
- // Record dedup — update _teamsNotifiedEvents on PR via lock
543
541
  if (prFilePath) {
544
542
  mutateJsonFileLocked(prFilePath, (prs) => {
545
543
  if (!Array.isArray(prs)) return prs;
546
544
  const target = shared.findPrRecord(prs, pr);
547
- if (target) {
548
- if (!target._teamsNotifiedEvents) target._teamsNotifiedEvents = [];
549
- if (!target._teamsNotifiedEvents.includes(event)) {
550
- target._teamsNotifiedEvents.push(event);
551
- }
545
+ if (!target) return prs;
546
+ if (!target._teamsNotifiedEvents) target._teamsNotifiedEvents = [];
547
+ if (target._teamsNotifiedEvents.includes(event)) {
548
+ shouldPost = false;
549
+ return prs;
552
550
  }
551
+ target._teamsNotifiedEvents.push(event);
552
+ claimedEvent = true;
553
+ return prs;
553
554
  }, { defaultValue: [] });
554
555
  }
556
+ if (!shouldPost) return;
557
+ await teamsPost(convKeys[0], card);
558
+ if (pr && typeof pr === 'object') {
559
+ if (!Array.isArray(pr._teamsNotifiedEvents)) pr._teamsNotifiedEvents = [];
560
+ if (!pr._teamsNotifiedEvents.includes(event)) pr._teamsNotifiedEvents.push(event);
561
+ }
562
+ log('info', `Teams PR notification sent: ${event} for ${pr.id}`);
555
563
  } catch (err) {
564
+ if (claimedEvent && prFilePath) {
565
+ try {
566
+ mutateJsonFileLocked(prFilePath, (prs) => {
567
+ if (!Array.isArray(prs)) return prs;
568
+ const target = shared.findPrRecord(prs, pr);
569
+ if (!target || !Array.isArray(target._teamsNotifiedEvents)) return prs;
570
+ target._teamsNotifiedEvents = target._teamsNotifiedEvents.filter(e => e !== event);
571
+ return prs;
572
+ }, { defaultValue: [] });
573
+ } catch (revertErr) {
574
+ log('warn', `Teams PR dedup revert failed for ${pr.id}: ${revertErr.message}`);
575
+ }
576
+ }
556
577
  log('warn', `Teams PR notification failed for ${pr.id}: ${err.message}`);
557
578
  }
558
579
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1007",
3
+ "version": "0.1.1008",
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"