@yemi33/minions 0.1.1006 → 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 +6 -6
- package/dashboard/js/command-center.js +7 -0
- package/dashboard/js/render-work-items.js +16 -0
- package/dashboard.js +148 -8
- package/engine/ado.js +8 -0
- package/engine/cleanup.js +22 -1
- package/engine/dispatch.js +26 -0
- package/engine/github.js +8 -0
- package/engine/lifecycle.js +34 -6
- package/engine/pipeline.js +47 -21
- package/engine/teams.js +31 -10
- package/package.json +1 -1
- package/prompts/cc-system.md +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.1008 (2026-04-16)
|
|
4
4
|
|
|
5
5
|
### Features
|
|
6
|
+
- fix duplicate PR creation + cancel stale review dispatches on PR close (#1137)
|
|
7
|
+
- intercept /loop skill invocations in CC and convert to watches (#1140)
|
|
8
|
+
- add cancel work item endpoint, UI button, and CC action (#1136)
|
|
6
9
|
- cap pendingContexts in cooldowns.json to prevent bloat (#1126)
|
|
7
10
|
- fix stopAfter=0 watch expiry — run forever for all conditions (#1117)
|
|
8
11
|
- fix doc-chat Clear chat not persisting session deletion to localStorage (#1102)
|
|
@@ -20,11 +23,10 @@
|
|
|
20
23
|
- add quality standard reminder to all agent and CC prompts
|
|
21
24
|
- surface in-flight tool calls in lastAction (#1064)
|
|
22
25
|
- implement dashboard loop/watch management panel
|
|
23
|
-
- reassign tasks when preferred agent is busy too long
|
|
24
|
-
- wire agentBusyReassignMs into settings UI and persist
|
|
25
|
-
- ADO throttle detection, poll guards, and dashboard banner (#1051)
|
|
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
|
|
@@ -860,6 +860,13 @@ async function ccExecuteAction(action, targetTabId) {
|
|
|
860
860
|
status.style.color = 'var(--orange)';
|
|
861
861
|
break;
|
|
862
862
|
}
|
|
863
|
+
case 'cancel-work-item': {
|
|
864
|
+
await _ccFetch('/api/work-items/cancel', { id: action.id, source: action.source || '', reason: action.reason || 'cc' });
|
|
865
|
+
status.innerHTML = '✓ Cancelled work item: <strong>' + escHtml(action.id) + '</strong>';
|
|
866
|
+
status.style.color = 'var(--orange)';
|
|
867
|
+
wakeEngine();
|
|
868
|
+
break;
|
|
869
|
+
}
|
|
863
870
|
case 'plan-edit': {
|
|
864
871
|
// Read the plan, send instruction to doc-chat, show version actions
|
|
865
872
|
var normalizedFile = normalizePlanFile(action.file);
|
|
@@ -70,6 +70,7 @@ function wiRow(item) {
|
|
|
70
70
|
((item.status === 'pending' || item.status === 'failed' || item.status === 'needs-human-review') ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--blue);border-color:var(--blue);margin-right:4px" onclick="event.stopPropagation();editWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Edit work item">✎</button>' : '') +
|
|
71
71
|
((item.status === 'done' || item.status === 'failed' || item.status === 'needs-human-review') ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--muted);border-color:var(--border);margin-right:4px" onclick="event.stopPropagation();archiveWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Archive work item">📦</button>' : '') +
|
|
72
72
|
((item.status === 'done' || item.status === 'failed' || item.status === 'needs-human-review') && !item._humanFeedback ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-right:4px" onclick="event.stopPropagation();feedbackWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Give feedback">👍👎</button>' : (item._humanFeedback ? '<span style="font-size:9px" title="Feedback given">' + (item._humanFeedback.rating === 'up' ? '👍' : '👎') + '</span> ' : '')) +
|
|
73
|
+
((item.status === 'pending' || item.status === 'dispatched' || item.status === 'queued' || item.status === 'failed' || item.status === 'needs-human-review') ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--orange);border-color:var(--orange);margin-right:4px" onclick="event.stopPropagation();cancelWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Cancel work item and kill agent">🚫</button>' : '') +
|
|
73
74
|
'<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--red);border-color:var(--red)" onclick="event.stopPropagation();deleteWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Delete work item and kill agent">✕</button>' +
|
|
74
75
|
'</td>' +
|
|
75
76
|
'</tr>';
|
|
@@ -209,6 +210,21 @@ async function deleteWorkItem(id, source) {
|
|
|
209
210
|
} catch (e) { showToast('cmd-toast', 'Delete error: ' + e.message, false); refresh(); }
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
async function cancelWorkItem(id, source) {
|
|
214
|
+
if (!confirm('Cancel work item ' + id + '? This will kill any running agent and mark it cancelled.')) return;
|
|
215
|
+
markDeleted('wi:' + id);
|
|
216
|
+
var wiTable = document.getElementById('work-items-content');
|
|
217
|
+
(wiTable || document).querySelectorAll('tr').forEach(function(r) { if (r.textContent.includes(id)) r.remove(); });
|
|
218
|
+
showToast('cmd-toast', 'Work item cancelled', true);
|
|
219
|
+
try {
|
|
220
|
+
const res = await fetch('/api/work-items/cancel', {
|
|
221
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
222
|
+
body: JSON.stringify({ id, source: source || undefined })
|
|
223
|
+
});
|
|
224
|
+
if (!res.ok) { const d = await res.json().catch(() => ({})); showToast('cmd-toast', 'Cancel failed: ' + (d.error || 'unknown'), false); refresh(); }
|
|
225
|
+
} catch (e) { showToast('cmd-toast', 'Cancel error: ' + e.message, false); refresh(); }
|
|
226
|
+
}
|
|
227
|
+
|
|
212
228
|
async function archiveWorkItem(id, source) {
|
|
213
229
|
markDeleted('wi:' + id);
|
|
214
230
|
var wiTable = document.getElementById('work-items-content');
|
package/dashboard.js
CHANGED
|
@@ -607,6 +607,65 @@ function parseCCActions(text) {
|
|
|
607
607
|
return { text: displayText, actions };
|
|
608
608
|
}
|
|
609
609
|
|
|
610
|
+
// ── /loop → create-watch safety net ──────────────────────────────────────────
|
|
611
|
+
// CC sometimes invokes the /loop skill instead of emitting a create-watch action.
|
|
612
|
+
// This pure function detects /loop invocation in CC response text and synthesizes
|
|
613
|
+
// a create-watch action as a fallback. Returns null if no conversion needed.
|
|
614
|
+
|
|
615
|
+
function _detectLoopInvocation(text, actions) {
|
|
616
|
+
if (!text) return null;
|
|
617
|
+
// If a create-watch action was already emitted, no fallback needed
|
|
618
|
+
if (actions && actions.some(a => a.type === 'create-watch')) return null;
|
|
619
|
+
|
|
620
|
+
// Check for /loop invocation patterns in CC response
|
|
621
|
+
const loopPatterns = [
|
|
622
|
+
/\/loop\b/i,
|
|
623
|
+
/\bloop skill\b/i,
|
|
624
|
+
/\bSkill.*\bloop\b/i,
|
|
625
|
+
/\bstarted.*\bloop\b/i,
|
|
626
|
+
/\bmonitoring.*\bloop\b/i,
|
|
627
|
+
/\binvok(?:e|ed|ing).*\bloop\b/i,
|
|
628
|
+
];
|
|
629
|
+
if (!loopPatterns.some(p => p.test(text))) return null;
|
|
630
|
+
|
|
631
|
+
// Extract target — PR number or work item ID
|
|
632
|
+
const prMatch = text.match(/\bPR[- #]?(\d+)\b/i) || text.match(/\bpull[- ]request[- #]?(\d+)/i);
|
|
633
|
+
const wiMatch = text.match(/\bW-([a-z0-9]+)\b/);
|
|
634
|
+
|
|
635
|
+
let target = null, targetType = 'pr';
|
|
636
|
+
if (prMatch) {
|
|
637
|
+
target = prMatch[1];
|
|
638
|
+
targetType = 'pr';
|
|
639
|
+
} else if (wiMatch) {
|
|
640
|
+
target = 'W-' + wiMatch[1];
|
|
641
|
+
targetType = 'work-item';
|
|
642
|
+
}
|
|
643
|
+
if (!target) return null; // Can't synthesize without a target
|
|
644
|
+
|
|
645
|
+
// Extract interval (e.g. "every 15 minutes", "every 5m")
|
|
646
|
+
const intervalMatch = text.match(/every\s+(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?)\b/i);
|
|
647
|
+
let interval = '5m';
|
|
648
|
+
if (intervalMatch) interval = intervalMatch[1] + intervalMatch[2][0];
|
|
649
|
+
|
|
650
|
+
// Infer condition from keywords
|
|
651
|
+
let condition = 'any';
|
|
652
|
+
if (/\bbuild\b/i.test(text) && /\b(?:pass(?:es|ing|ed)?|green|succeed(?:s|ed)?|success)\b/i.test(text)) condition = 'build-pass';
|
|
653
|
+
else if (/\bbuild\b/i.test(text) && /\b(?:fail(?:s|ing|ed)?|red|broken|broke)\b/i.test(text)) condition = 'build-fail';
|
|
654
|
+
else if (/\bmerge[d]?\b/i.test(text)) condition = 'merged';
|
|
655
|
+
else if (/\bcomplete[d]?\b/i.test(text)) condition = 'completed';
|
|
656
|
+
|
|
657
|
+
return {
|
|
658
|
+
type: 'create-watch',
|
|
659
|
+
target,
|
|
660
|
+
targetType,
|
|
661
|
+
condition,
|
|
662
|
+
interval,
|
|
663
|
+
owner: 'human',
|
|
664
|
+
description: 'Auto-converted from /loop invocation',
|
|
665
|
+
stopAfter: condition === 'any' ? 0 : 1,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
610
669
|
// ── Server-side CC action execution ──────────────────────────────────────────
|
|
611
670
|
// Actions are executed server-side so all clients (frontend, curl, Teams) get the same behavior.
|
|
612
671
|
// The frontend still shows status toasts but no longer needs to fire the API calls.
|
|
@@ -661,6 +720,7 @@ async function executeCCActions(actions) {
|
|
|
661
720
|
const kbDir = path.join(MINIONS_DIR, 'knowledge', category);
|
|
662
721
|
if (!fs.existsSync(kbDir)) fs.mkdirSync(kbDir, { recursive: true });
|
|
663
722
|
shared.safeWrite(path.join(kbDir, slug + '.md'), `# ${action.title}\n\n${action.content || action.description || ''}`);
|
|
723
|
+
queries.invalidateKnowledgeBaseCache();
|
|
664
724
|
results.push({ type: 'knowledge', ok: true });
|
|
665
725
|
break;
|
|
666
726
|
}
|
|
@@ -1419,6 +1479,63 @@ const server = http.createServer(async (req, res) => {
|
|
|
1419
1479
|
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
1420
1480
|
}
|
|
1421
1481
|
|
|
1482
|
+
async function handleWorkItemsCancel(req, res) {
|
|
1483
|
+
try {
|
|
1484
|
+
const body = await readBody(req);
|
|
1485
|
+
const { id, source, reason } = body;
|
|
1486
|
+
if (!id) return jsonReply(res, 400, { error: 'id required' });
|
|
1487
|
+
|
|
1488
|
+
// Find the right work-items file
|
|
1489
|
+
let wiPath;
|
|
1490
|
+
if (!source || source === 'central') {
|
|
1491
|
+
wiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
1492
|
+
} else {
|
|
1493
|
+
const proj = PROJECTS.find(p => p.name === source);
|
|
1494
|
+
if (proj) wiPath = shared.projectWorkItemsPath(proj);
|
|
1495
|
+
}
|
|
1496
|
+
if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
|
|
1497
|
+
|
|
1498
|
+
let result = null;
|
|
1499
|
+
mutateJsonFileLocked(wiPath, (items) => {
|
|
1500
|
+
if (!Array.isArray(items)) items = [];
|
|
1501
|
+
const item = items.find(i => i.id === id);
|
|
1502
|
+
if (!item) { result = { code: 404, body: { error: 'item not found' } }; return items; }
|
|
1503
|
+
// Reject already-done or already-cancelled items
|
|
1504
|
+
if (DONE_STATUSES.has(item.status) || item.status === WI_STATUS.CANCELLED) {
|
|
1505
|
+
result = { code: 400, body: { error: 'cannot cancel item with status: ' + item.status } };
|
|
1506
|
+
return items;
|
|
1507
|
+
}
|
|
1508
|
+
item.status = WI_STATUS.CANCELLED;
|
|
1509
|
+
item._cancelledBy = reason || 'user';
|
|
1510
|
+
item.cancelledAt = new Date().toISOString();
|
|
1511
|
+
result = { code: 200, body: { ok: true, item } };
|
|
1512
|
+
return items;
|
|
1513
|
+
});
|
|
1514
|
+
if (!result) return jsonReply(res, 500, { error: 'unexpected state' });
|
|
1515
|
+
if (result.code !== 200) return jsonReply(res, result.code, result.body);
|
|
1516
|
+
|
|
1517
|
+
// Clean dispatch entries + kill running agent (outside lock)
|
|
1518
|
+
const dispatchRemoved = cleanDispatchEntries(d =>
|
|
1519
|
+
d.meta?.item?.id === id ||
|
|
1520
|
+
d.meta?.dispatchKey?.endsWith(id)
|
|
1521
|
+
);
|
|
1522
|
+
|
|
1523
|
+
// Clean cooldown entries
|
|
1524
|
+
try {
|
|
1525
|
+
const cooldownPath = path.join(MINIONS_DIR, 'engine', 'cooldowns.json');
|
|
1526
|
+
const cooldowns = safeJsonObj(cooldownPath);
|
|
1527
|
+
let cleaned = false;
|
|
1528
|
+
for (const key of Object.keys(cooldowns)) {
|
|
1529
|
+
if (key.includes(id)) { delete cooldowns[key]; cleaned = true; }
|
|
1530
|
+
}
|
|
1531
|
+
if (cleaned) safeWrite(cooldownPath, cooldowns);
|
|
1532
|
+
} catch (e) { console.error('cooldown cleanup on cancel:', e.message); }
|
|
1533
|
+
|
|
1534
|
+
invalidateStatusCache();
|
|
1535
|
+
return jsonReply(res, 200, { ok: true, id, dispatchRemoved });
|
|
1536
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1422
1539
|
async function handleWorkItemsArchive(req, res) {
|
|
1423
1540
|
try {
|
|
1424
1541
|
const body = await readBody(req);
|
|
@@ -1758,13 +1875,18 @@ const server = http.createServer(async (req, res) => {
|
|
|
1758
1875
|
if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
|
|
1759
1876
|
const planPath = resolvePlanPath(body.source);
|
|
1760
1877
|
if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
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' });
|
|
1768
1890
|
|
|
1769
1891
|
// Also remove any materialized work item for this plan item
|
|
1770
1892
|
let cancelled = false;
|
|
@@ -2121,7 +2243,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2121
2243
|
for (const cat of shared.KB_CATEGORIES) result[cat] = [];
|
|
2122
2244
|
for (const e of entries) {
|
|
2123
2245
|
if (!result[e.cat]) result[e.cat] = [];
|
|
2124
|
-
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 });
|
|
2125
2247
|
}
|
|
2126
2248
|
const swept = safeJson(path.join(ENGINE_DIR, 'kb-swept.json'));
|
|
2127
2249
|
if (swept) result.lastSwept = swept.timestamp;
|
|
@@ -2315,6 +2437,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
2315
2437
|
|
|
2316
2438
|
const summary = `${merged} duplicates merged, ${removed} stale removed, ${reclassified} reclassified${pruned ? ', ' + pruned + ' old swept files pruned' : ''}`;
|
|
2317
2439
|
safeWrite(path.join(ENGINE_DIR, 'kb-swept.json'), JSON.stringify({ timestamp: new Date().toISOString(), summary }));
|
|
2440
|
+
queries.invalidateKnowledgeBaseCache();
|
|
2318
2441
|
global._kbSweepLastResult = { ok: true, summary, plan };
|
|
2319
2442
|
global._kbSweepLastCompletedAt = Date.now();
|
|
2320
2443
|
} catch (e) {
|
|
@@ -3160,6 +3283,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3160
3283
|
if (!fs.existsSync(kbDir)) fs.mkdirSync(kbDir, { recursive: true });
|
|
3161
3284
|
const kbFile = path.join(kbDir, name);
|
|
3162
3285
|
safeWrite(kbFile, kbContent);
|
|
3286
|
+
queries.invalidateKnowledgeBaseCache();
|
|
3163
3287
|
|
|
3164
3288
|
// Move inbox item to archive
|
|
3165
3289
|
const archiveDir = path.join(MINIONS_DIR, 'notes', 'archive');
|
|
@@ -3532,6 +3656,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3532
3656
|
}
|
|
3533
3657
|
|
|
3534
3658
|
const parsed = parseCCActions(result.text);
|
|
3659
|
+
// Safety net: detect /loop invocation and convert to create-watch
|
|
3660
|
+
const _loopWatch = _detectLoopInvocation(parsed.text, parsed.actions);
|
|
3661
|
+
if (_loopWatch) {
|
|
3662
|
+
parsed.actions.push(_loopWatch);
|
|
3663
|
+
console.warn('[CC] /loop invocation detected — converted to create-watch');
|
|
3664
|
+
try { shared.log('warn', '/loop invocation detected in CC response — auto-converted to create-watch'); } catch {}
|
|
3665
|
+
}
|
|
3535
3666
|
if (parsed.actions.length > 0) {
|
|
3536
3667
|
parsed.actionResults = await executeCCActions(parsed.actions);
|
|
3537
3668
|
}
|
|
@@ -3694,6 +3825,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3694
3825
|
|
|
3695
3826
|
// Send final result with actions — execute server-side first
|
|
3696
3827
|
const { text: displayText, actions } = parseCCActions(result.text);
|
|
3828
|
+
// Safety net: detect /loop invocation and convert to create-watch
|
|
3829
|
+
const _loopWatch = _detectLoopInvocation(displayText, actions);
|
|
3830
|
+
if (_loopWatch) {
|
|
3831
|
+
actions.push(_loopWatch);
|
|
3832
|
+
console.warn('[CC] /loop invocation detected — converted to create-watch');
|
|
3833
|
+
try { shared.log('warn', '/loop invocation detected in CC response — auto-converted to create-watch'); } catch {}
|
|
3834
|
+
}
|
|
3697
3835
|
let actionResults;
|
|
3698
3836
|
if (actions.length > 0) {
|
|
3699
3837
|
actionResults = await executeCCActions(actions);
|
|
@@ -4244,6 +4382,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
4244
4382
|
{ method: 'POST', path: '/api/work-items/update', desc: 'Edit a pending/failed work item', params: 'id, source?, title?, description?, type?, priority?, agent?, references?, acceptanceCriteria?', handler: handleWorkItemsUpdate },
|
|
4245
4383
|
{ method: 'POST', path: '/api/work-items/retry', desc: 'Reset a failed/dispatched item to pending', params: 'id, source?', handler: handleWorkItemsRetry },
|
|
4246
4384
|
{ method: 'POST', path: '/api/work-items/delete', desc: 'Remove a work item, kill agent, clear dispatch', params: 'id, source?', handler: handleWorkItemsDelete },
|
|
4385
|
+
{ method: 'POST', path: '/api/work-items/cancel', desc: 'Cancel a work item, kill agent, clear dispatch', params: 'id, source?, reason?', handler: handleWorkItemsCancel },
|
|
4247
4386
|
{ method: 'POST', path: '/api/work-items/archive', desc: 'Move a completed/failed work item to archive', params: 'id, source?', handler: handleWorkItemsArchive },
|
|
4248
4387
|
{ method: 'GET', path: '/api/work-items/archive', desc: 'List archived work items', handler: handleWorkItemsArchiveList },
|
|
4249
4388
|
{ method: 'POST', path: '/api/work-items/reopen', desc: 'Reopen a done/failed work item back to pending', params: 'id, project?, description?', handler: handleWorkItemsReopen },
|
|
@@ -4548,6 +4687,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
4548
4687
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
4549
4688
|
const header = '# ' + title + '\n\n*Created by human teammate on ' + new Date().toISOString().slice(0, 10) + '*\n\n';
|
|
4550
4689
|
safeWrite(filePath, header + content);
|
|
4690
|
+
queries.invalidateKnowledgeBaseCache();
|
|
4551
4691
|
invalidateStatusCache();
|
|
4552
4692
|
return jsonReply(res, 200, { ok: true, path: filePath });
|
|
4553
4693
|
}},
|
package/engine/ado.js
CHANGED
|
@@ -16,6 +16,10 @@ function engine() {
|
|
|
16
16
|
return _engine;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Lazy require for dispatch module (avoids circular dependency via engine)
|
|
20
|
+
let _dispatch = null;
|
|
21
|
+
function dispatchModule() { if (!_dispatch) _dispatch = require('./dispatch'); return _dispatch; }
|
|
22
|
+
|
|
19
23
|
const stripRefsHeads = s => (s || '').replace('refs/heads/', '');
|
|
20
24
|
const getAdoPrUrl = (project, prNumber) => {
|
|
21
25
|
if (project.prUrlBase) return `${project.prUrlBase}${prNumber}`;
|
|
@@ -346,6 +350,10 @@ async function pollPrStatus(config) {
|
|
|
346
350
|
delete pr.buildFixAttempts;
|
|
347
351
|
delete pr.buildFixEscalated;
|
|
348
352
|
}
|
|
353
|
+
// Cancel any pending review/fix dispatches — they're stale now that the PR is closed
|
|
354
|
+
try {
|
|
355
|
+
dispatchModule().cancelPendingDispatchesForPr(pr.id);
|
|
356
|
+
} catch (e) { log('warn', `Cancel dispatches for ${pr.id}: ${e.message}`); }
|
|
349
357
|
await engine().handlePostMerge(pr, project, config, newStatus);
|
|
350
358
|
}
|
|
351
359
|
}
|
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
|
};
|
package/engine/dispatch.js
CHANGED
|
@@ -278,6 +278,31 @@ function updateAgentStatus(dispatchId, status, detail) {
|
|
|
278
278
|
});
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
// ─── Cancel Pending Dispatches for Closed PR ───────────────────────────────
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Cancel all pending dispatch entries that reference a specific PR.
|
|
285
|
+
* Called when a PR transitions to merged/abandoned/closed — any pending
|
|
286
|
+
* review, fix, or re-review dispatches for that PR are stale and should
|
|
287
|
+
* not be spawned.
|
|
288
|
+
* @param {string} prId — PR identifier (e.g. 'PR-100')
|
|
289
|
+
* @returns {number} count of cancelled entries
|
|
290
|
+
*/
|
|
291
|
+
function cancelPendingDispatchesForPr(prId) {
|
|
292
|
+
if (!prId) return 0;
|
|
293
|
+
let cancelled = 0;
|
|
294
|
+
mutateDispatch((dispatch) => {
|
|
295
|
+
const before = dispatch.pending.length;
|
|
296
|
+
dispatch.pending = dispatch.pending.filter(d => d.meta?.pr?.id !== prId);
|
|
297
|
+
cancelled = before - dispatch.pending.length;
|
|
298
|
+
return dispatch;
|
|
299
|
+
});
|
|
300
|
+
if (cancelled > 0) {
|
|
301
|
+
log('info', `Cancelled ${cancelled} pending dispatch(es) for closed PR ${prId}`);
|
|
302
|
+
}
|
|
303
|
+
return cancelled;
|
|
304
|
+
}
|
|
305
|
+
|
|
281
306
|
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
282
307
|
|
|
283
308
|
module.exports = {
|
|
@@ -287,4 +312,5 @@ module.exports = {
|
|
|
287
312
|
completeDispatch,
|
|
288
313
|
writeInboxAlert,
|
|
289
314
|
updateAgentStatus,
|
|
315
|
+
cancelPendingDispatchesForPr,
|
|
290
316
|
};
|
package/engine/github.js
CHANGED
|
@@ -16,6 +16,10 @@ function engine() {
|
|
|
16
16
|
return _engine;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Lazy require for dispatch module (avoids circular dependency via engine)
|
|
20
|
+
let _dispatch = null;
|
|
21
|
+
function dispatchModule() { if (!_dispatch) _dispatch = require('./dispatch'); return _dispatch; }
|
|
22
|
+
|
|
19
23
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
20
24
|
|
|
21
25
|
function isGitHub(project) {
|
|
@@ -327,6 +331,10 @@ async function pollPrStatus(config) {
|
|
|
327
331
|
delete pr.buildFixAttempts;
|
|
328
332
|
delete pr.buildFixEscalated;
|
|
329
333
|
}
|
|
334
|
+
// Cancel any pending review/fix dispatches — they're stale now that the PR is closed
|
|
335
|
+
try {
|
|
336
|
+
dispatchModule().cancelPendingDispatchesForPr(pr.id);
|
|
337
|
+
} catch (e) { log('warn', `Cancel dispatches for ${pr.id}: ${e.message}`); }
|
|
330
338
|
await engine().handlePostMerge(pr, project, config, newStatus);
|
|
331
339
|
}
|
|
332
340
|
}
|
package/engine/lifecycle.js
CHANGED
|
@@ -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
|
|
|
@@ -814,6 +815,8 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
814
815
|
});
|
|
815
816
|
}
|
|
816
817
|
|
|
818
|
+
const entryBranch = meta?.branch || '';
|
|
819
|
+
|
|
817
820
|
for (const [prPath, { name, project: targetProject, entries }] of newPrsByPath) {
|
|
818
821
|
const linksToPersist = [];
|
|
819
822
|
mutateJsonFileLocked(prPath, (data) => {
|
|
@@ -824,6 +827,28 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
824
827
|
}
|
|
825
828
|
for (const { prId, fullId, entry } of entries) {
|
|
826
829
|
if (prs.some(p => p.id === fullId || (p.url && p.url === entry.url))) continue;
|
|
830
|
+
|
|
831
|
+
// Branch-level dedup: skip if an active PR already exists on the same branch.
|
|
832
|
+
// This prevents duplicate PRs when an agent retries and calls `gh pr create` again
|
|
833
|
+
// on the same branch (GitHub allows multiple PRs from one branch).
|
|
834
|
+
// Only block when the existing PR is active — abandoned/merged PRs don't conflict.
|
|
835
|
+
const branch = entry.branch || entryBranch;
|
|
836
|
+
if (branch) {
|
|
837
|
+
const existingOnBranch = prs.find(p => p.branch === branch && p.status === PR_STATUS.ACTIVE && p.id !== fullId);
|
|
838
|
+
if (existingOnBranch) {
|
|
839
|
+
log('warn', `Duplicate PR detected: ${fullId} on branch ${branch} — already tracked as ${existingOnBranch.id}. Skipping.`);
|
|
840
|
+
// Best-effort close the duplicate on GitHub (non-blocking, fire-and-forget)
|
|
841
|
+
try {
|
|
842
|
+
const ghSlug = output.match(/github\.com\/([^/]+\/[^/]+)/)?.[1];
|
|
843
|
+
if (ghSlug) {
|
|
844
|
+
execAsync(`gh pr close ${prId} --repo ${ghSlug} --comment "Closing duplicate — ${existingOnBranch.id} already tracks this branch."`, { timeout: 15000 })
|
|
845
|
+
.catch(() => {});
|
|
846
|
+
}
|
|
847
|
+
} catch { /* best-effort */ }
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
827
852
|
prs.push(entry);
|
|
828
853
|
if (meta?.item?.id) {
|
|
829
854
|
linksToPersist.push({ prId: fullId, itemId: meta.item.id, project: targetProject, prNumber: entry.prNumber, url: entry.url });
|
|
@@ -1041,8 +1066,10 @@ const PENDING_REBASES_PATH = path.join(ENGINE_DIR, 'pending-rebases.json');
|
|
|
1041
1066
|
|
|
1042
1067
|
function queuePendingRebase(pr, project, mergedItemId) {
|
|
1043
1068
|
mutateJsonFileLocked(PENDING_REBASES_PATH, (pending) => {
|
|
1044
|
-
|
|
1069
|
+
const prDisplayId = shared.getPrDisplayId(pr);
|
|
1070
|
+
if (pending.some(e => e.projectName === project.name && shared.getPrDisplayId(e.prId) === prDisplayId)) return pending; // already queued
|
|
1045
1071
|
pending.push({ prId: pr.id, branch: pr.branch, projectName: project.name, mergedItemId, queuedAt: ts(), attempts: 0 });
|
|
1072
|
+
return pending;
|
|
1046
1073
|
}, { defaultValue: [] });
|
|
1047
1074
|
}
|
|
1048
1075
|
|
|
@@ -1063,9 +1090,11 @@ async function processPendingRebases(config) {
|
|
|
1063
1090
|
const project = shared.getProjects(config).find(p => p.name === entry.projectName);
|
|
1064
1091
|
if (!project) continue;
|
|
1065
1092
|
|
|
1066
|
-
const prs =
|
|
1067
|
-
const pr =
|
|
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;
|
|
1068
1096
|
if (!pr) continue; // PR closed/merged since queuing
|
|
1097
|
+
if (pr.status !== PR_STATUS.ACTIVE) continue; // PR closed/merged since queuing
|
|
1069
1098
|
|
|
1070
1099
|
const result = await rebaseBranchOntoMain(pr, project, config);
|
|
1071
1100
|
if (!result.success) {
|
|
@@ -1096,12 +1125,11 @@ async function handlePostMerge(pr, project, config, newStatus) {
|
|
|
1096
1125
|
const root = path.resolve(project.localPath);
|
|
1097
1126
|
const wtRoot = path.resolve(root, config.engine?.worktreeRoot || '../worktrees');
|
|
1098
1127
|
// Find worktrees matching this branch — dir format is {slug}-{branch}-{suffix}
|
|
1099
|
-
const branchSlug = shared.sanitizeBranch(pr.branch).toLowerCase();
|
|
1100
1128
|
try {
|
|
1101
1129
|
const dirs = require('fs').readdirSync(wtRoot);
|
|
1102
1130
|
for (const dir of dirs) {
|
|
1103
1131
|
const dirLower = dir.toLowerCase();
|
|
1104
|
-
if (dirLower.
|
|
1132
|
+
if (worktreeDirMatchesBranch(dirLower, pr.branch) || dir === pr.branch || dir === `bt-${prNum}`) {
|
|
1105
1133
|
const wtPath = path.join(wtRoot, dir);
|
|
1106
1134
|
try {
|
|
1107
1135
|
if (!require('fs').statSync(wtPath).isDirectory()) continue;
|
|
@@ -1778,7 +1806,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
1778
1806
|
// Find the worktree directory for this dispatch's branch
|
|
1779
1807
|
const branchSlug = shared.sanitizeBranch ? shared.sanitizeBranch(meta.branch) : meta.branch.replace(/[^a-zA-Z0-9._\-\/]/g, '-');
|
|
1780
1808
|
const dirs = fs.readdirSync(worktreeRoot).filter(d => {
|
|
1781
|
-
return d.
|
|
1809
|
+
return worktreeDirMatchesBranch(d.toLowerCase(), meta.branch) && fs.statSync(path.join(worktreeRoot, d)).isDirectory();
|
|
1782
1810
|
});
|
|
1783
1811
|
// Only remove if no other active dispatch uses this branch
|
|
1784
1812
|
const dispatch = getDispatch();
|
package/engine/pipeline.js
CHANGED
|
@@ -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
|
|
190
|
-
const
|
|
191
|
-
const
|
|
192
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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.
|
|
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"
|
package/prompts/cc-system.md
CHANGED
|
@@ -91,11 +91,14 @@ Additional actions (all take `id` or `file` as primary key):
|
|
|
91
91
|
- Schedules: schedule (id, title, cron, workType, project, agent, description, priority, enabled), delete-schedule (id)
|
|
92
92
|
- Pipelines: create-pipeline (id, title, stages[], trigger, stopWhen, monitoredResources), edit-pipeline (id, title, stages, trigger), delete-pipeline (id), trigger-pipeline (id), abort-pipeline (id), retrigger-pipeline (id)
|
|
93
93
|
- Meetings: add-meeting-note (id, note), advance-meeting (id), end-meeting (id), archive-meeting (id), unarchive-meeting (id), delete-meeting (id)
|
|
94
|
-
- Work item ops: delete-work-item (id, source), archive-work-item (id), work-item-feedback (id, rating: up/down, comment), reopen-work-item (id, project[, description] — reopen a done/failed item back to pending)
|
|
94
|
+
- Work item ops: delete-work-item (id, source), cancel-work-item (id, source?, reason? — cancel a pending/dispatched/failed item, kills running agent), archive-work-item (id), work-item-feedback (id, rating: up/down, comment), reopen-work-item (id, project[, description] — reopen a done/failed item back to pending)
|
|
95
95
|
- PRD ops: edit-prd-item, remove-prd-item, reopen-prd-item (id, file — re-dispatches on existing branch)
|
|
96
96
|
- **create-watch**: target, targetType (pr/work-item), condition (merged/build-fail/build-pass/completed/failed/status-change/any/new-comments/vote-change), interval ("15m", "1h", "30s" — default "5m"), owner (agent id or "human"), description, project, stopAfter (0=run forever, 1=expire on first match, N=expire after N matches), onNotMet (null=do nothing, "notify"=write to inbox each poll while condition not met)
|
|
97
|
+
**NEVER use the /loop skill for monitoring tasks.** Always use the `create-watch` action — it persists across engine restarts and appears in the Watches page. /loop runs ephemerally in the session and leaves no trace.
|
|
98
|
+
Trigger phrases: user says "keep an eye on X", "watch X every N min", "monitor X", "check X periodically", "ping me when X" → always emit `create-watch`.
|
|
97
99
|
Example: user says "check PR 1065 build every 15 min until green" → `{"type":"create-watch","target":"1065","targetType":"pr","condition":"build-pass","interval":"15m","stopAfter":1,"description":"Watch PR 1065 build until green"}`
|
|
98
100
|
Example: user says "ping me every 15 min while build is still failing" → `{"type":"create-watch","target":"1065","targetType":"pr","condition":"build-pass","interval":"15m","stopAfter":1,"onNotMet":"notify","description":"Watch PR 1065 build — notify each poll"}`
|
|
101
|
+
Example: user says "keep an eye on PR 200 every 5 min" → `{"type":"create-watch","target":"200","targetType":"pr","condition":"any","interval":"5m","stopAfter":0,"description":"Monitor PR 200"}`
|
|
99
102
|
- **delete-watch**: id — remove a watch permanently
|
|
100
103
|
Example: user says "stop watching PR 1065" → `{"type":"delete-watch","id":"watch-abc123"}`
|
|
101
104
|
- **pause-watch**: id — pause a watch without deleting it (can be resumed later)
|