@yemi33/minions 0.1.1007 → 0.1.1009

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.1007 (2026-04-16)
3
+ ## 0.1.1009 (2026-04-16)
4
+
5
+ ### Other
6
+ - chore: trigger publish workflow test
7
+
8
+ ## 0.1.1008 (2026-04-16)
4
9
 
5
10
  ### Features
6
11
  - fix duplicate PR creation + cancel stale review dispatches on PR close (#1137)
@@ -25,6 +30,8 @@
25
30
  - implement dashboard loop/watch management panel
26
31
 
27
32
  ### Fixes
33
+ - harden audited state transitions
34
+ - scrub stale temp agent IDs from metrics.json (#1129)
28
35
  - harden repo-scoped PR identity handling
29
36
  - only gate reviews/fixes when no free agent slots remain
30
37
  - extend auto-link fallback to ADO projects
@@ -43,8 +50,6 @@
43
50
  - build fix sets fixDispatched to block same-tick conflict fix
44
51
  - ENGINE_DEFAULTS undefined in tick + playbook empty-var false positives
45
52
  - 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
53
 
49
54
  ### Other
50
55
  - refactor: migrate PR links to array format and consolidate shared helpers
@@ -27,6 +27,19 @@ async function refreshKnowledgeBase() {
27
27
  } catch (e) { console.error('kb refresh:', e.message); }
28
28
  }
29
29
 
30
+ function kbNewestFirst(a, b) {
31
+ return (b.sortTs || 0) - (a.sortTs || 0) ||
32
+ (b.date || '').localeCompare(a.date || '') ||
33
+ (a.title || '').localeCompare(b.title || '');
34
+ }
35
+
36
+ function kbPinnedNewestFirst(a, b) {
37
+ const aPinned = isPinned(kbPinKey(a.category, a.file));
38
+ const bPinned = isPinned(kbPinKey(b.category, b.file));
39
+ if (aPinned !== bPinned) return aPinned ? -1 : 1;
40
+ return kbNewestFirst(a, b);
41
+ }
42
+
30
43
  function renderKnowledgeBase() {
31
44
  _syncPinsFromServer();
32
45
  const tabsEl = document.getElementById('kb-tabs');
@@ -74,18 +87,13 @@ function renderKnowledgeBase() {
74
87
  let items;
75
88
  if (_kbActiveTab === 'pinned') {
76
89
  items = allItems.filter(i => isPinned(kbPinKey(i.category, i.file)));
90
+ items.sort(kbNewestFirst);
77
91
  } else if (_kbActiveTab === 'all') {
78
92
  items = allItems.slice();
79
- items.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
93
+ items.sort(kbPinnedNewestFirst);
80
94
  } else {
81
95
  items = allItems.filter(i => i.category === _kbActiveTab);
82
- }
83
-
84
- // Stable sort — pinned items float to top (skip on pinned tab where all are pinned)
85
- if (_kbActiveTab !== 'pinned') {
86
- items.sort(function(a, b) {
87
- return (isPinned(kbPinKey(a.category, a.file)) ? 0 : 1) - (isPinned(kbPinKey(b.category, b.file)) ? 0 : 1);
88
- });
96
+ items.sort(kbPinnedNewestFirst);
89
97
  }
90
98
 
91
99
  if (items.length === 0) {
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;
@@ -2409,9 +2415,12 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2409
2415
  if (!fs.existsSync(srcPath)) continue;
2410
2416
  if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
2411
2417
  try {
2418
+ const srcStats = fs.statSync(srcPath);
2412
2419
  const content = safeRead(srcPath);
2413
2420
  const updated = content.replace(/^(category:\s*).+$/m, `$1${r.to}`);
2414
- safeWrite(path.join(destDir, entry.file), updated);
2421
+ const destPath = path.join(destDir, entry.file);
2422
+ safeWrite(destPath, updated);
2423
+ fs.utimesSync(destPath, srcStats.atime, srcStats.mtime);
2415
2424
  safeUnlink(srcPath);
2416
2425
  reclassified++;
2417
2426
  } catch (e) { console.error('kb reclassify:', e.message); }
@@ -2431,6 +2440,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2431
2440
 
2432
2441
  const summary = `${merged} duplicates merged, ${removed} stale removed, ${reclassified} reclassified${pruned ? ', ' + pruned + ' old swept files pruned' : ''}`;
2433
2442
  safeWrite(path.join(ENGINE_DIR, 'kb-swept.json'), JSON.stringify({ timestamp: new Date().toISOString(), summary }));
2443
+ queries.invalidateKnowledgeBaseCache();
2434
2444
  global._kbSweepLastResult = { ok: true, summary, plan };
2435
2445
  global._kbSweepLastCompletedAt = Date.now();
2436
2446
  } catch (e) {
@@ -3276,6 +3286,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3276
3286
  if (!fs.existsSync(kbDir)) fs.mkdirSync(kbDir, { recursive: true });
3277
3287
  const kbFile = path.join(kbDir, name);
3278
3288
  safeWrite(kbFile, kbContent);
3289
+ queries.invalidateKnowledgeBaseCache();
3279
3290
 
3280
3291
  // Move inbox item to archive
3281
3292
  const archiveDir = path.join(MINIONS_DIR, 'notes', 'archive');
@@ -4679,6 +4690,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4679
4690
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
4680
4691
  const header = '# ' + title + '\n\n*Created by human teammate on ' + new Date().toISOString().slice(0, 10) + '*\n\n';
4681
4692
  safeWrite(filePath, header + content);
4693
+ queries.invalidateKnowledgeBaseCache();
4682
4694
  invalidateStatusCache();
4683
4695
  return jsonReply(res, 200, { ok: true, path: filePath });
4684
4696
  }},
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/queries.js CHANGED
@@ -630,6 +630,11 @@ let _kbCache = null;
630
630
  let _kbCacheTs = 0;
631
631
  const KB_CACHE_TTL = 30000; // 30s — KB changes infrequently
632
632
 
633
+ function invalidateKnowledgeBaseCache() {
634
+ _kbCache = null;
635
+ _kbCacheTs = 0;
636
+ }
637
+
633
638
  function getKnowledgeBaseEntries() {
634
639
  const now = Date.now();
635
640
  if (_kbCache && (now - _kbCacheTs) < KB_CACHE_TTL) return _kbCache;
@@ -639,23 +644,32 @@ function getKnowledgeBaseEntries() {
639
644
  const catDir = path.join(KNOWLEDGE_DIR, cat);
640
645
  const files = safeReadDir(catDir).filter(f => f.endsWith('.md'));
641
646
  for (const f of files) {
642
- const content = safeRead(path.join(catDir, f)) || '';
647
+ const filePath = path.join(catDir, f);
648
+ const content = safeRead(filePath) || '';
643
649
  const titleMatch = content.match(/^#\s+(.+)/m);
644
650
  const title = titleMatch ? titleMatch[1].trim() : f.replace(/\.md$/, '');
645
651
  const agentMatch = f.match(/^\d{4}-\d{2}-\d{2}-(\w+)-/);
646
- const dateMatch = f.match(/^(\d{4}-\d{2}-\d{2})/);
652
+ const dateMatch = f.match(/^(\d{4}-\d{2}-\d{2})/) || content.match(/^date:\s*(\d{4}-\d{2}-\d{2})$/m);
647
653
  const sourceMatch = content.match(/^source:\s*(.+)/m);
654
+ let sortTs = 0;
655
+ try { sortTs = fs.statSync(filePath).mtimeMs || 0; } catch {}
656
+ const displayDate = dateMatch ? dateMatch[1] : (sortTs ? new Date(sortTs).toISOString().slice(0, 10) : '');
648
657
  entries.push({
649
658
  cat, file: f, title,
650
659
  agent: agentMatch ? agentMatch[1] : '',
651
- date: dateMatch ? dateMatch[1] : '',
660
+ date: displayDate,
661
+ sortTs,
652
662
  source: sourceMatch ? sourceMatch[1].trim() : '',
653
663
  preview: content.slice(0, 200),
654
664
  size: content.length,
655
665
  });
656
666
  }
657
667
  }
658
- entries.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
668
+ entries.sort((a, b) =>
669
+ (b.sortTs || 0) - (a.sortTs || 0) ||
670
+ (b.date || '').localeCompare(a.date || '') ||
671
+ a.title.localeCompare(b.title)
672
+ );
659
673
  _kbCache = entries;
660
674
  _kbCacheTs = now;
661
675
  return entries;
@@ -1092,6 +1106,7 @@ module.exports = {
1092
1106
  readHeadTail, // exported for testing
1093
1107
  detectInFlightTool, // exported for testing
1094
1108
  resetPrdInfoCache,
1109
+ invalidateKnowledgeBaseCache,
1095
1110
 
1096
1111
  // Core state
1097
1112
  getConfig, getControl, getDispatch, getDispatchQueue, invalidateDispatchCache,
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.1009",
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"