@yemi33/minions 0.1.976 → 0.1.978

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,11 +1,13 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.976 (2026-04-14)
3
+ ## 0.1.978 (2026-04-15)
4
4
 
5
5
  ### Features
6
+ - free-form interval input + CC create-watch action
6
7
  - flush queued CC messages as single combined request
7
8
  - add quality standard reminder to all agent and CC prompts
8
9
  - surface in-flight tool calls in lastAction (#1064)
10
+ - implement dashboard loop/watch management panel
9
11
  - reassign tasks when preferred agent is busy too long
10
12
  - wire agentBusyReassignMs into settings UI and persist
11
13
  - ADO throttle detection, poll guards, and dashboard banner (#1051)
@@ -21,12 +23,15 @@
21
23
  - doc-chat abort kills LLM process + queued messages auto-process
22
24
  - add /api/work-items/reopen endpoint and reopen-work-item CC action (#982)
23
25
  - CC tab unread dot + reopened badge on work items
24
- - fix dep re-merge failure on retry with existing commits (#977)
25
- - audit and harden log buffering implementation (#971)
26
26
 
27
27
  ### Fixes
28
+ - ENGINE_DEFAULTS undefined in tick + playbook empty-var false positives
29
+ - defer review setCooldown to post-gating in discoverWork
28
30
  - move review verdict check before updateWorkItemStatus(DONE)
31
+ - address review feedback — move writeToInbox outside lock, add absolute condition auto-expire
32
+ - fix watches feature gaps — human notifications, branch stub, status-change init, unique keys
29
33
  - skip isAlreadyDispatched in needsReReview to allow re-review within 1hr
34
+ - skip SessionStart hook settings test on CI
30
35
  - loop queue flush so messages queued during combined send aren't orphaned
31
36
  - set lastPushedAt on fix completion to unblock re-review without poller
32
37
  - restore ADO throttle test functions deleted during merge conflict resolution
@@ -40,14 +45,11 @@
40
45
  - defer setCooldown to post-gating in discoverWork
41
46
  - fix CC queued message 'already processing' and thinking UX stacking
42
47
  - enforce --timeout 1 on all azureauth calls to prevent agent orphans (#1063)
43
- - cancel steering kill watcher on resume spawn (#1052) (#1062)
44
- - suppress warn for optional template variables (#1061)
45
- - skip SessionStart hook settings test on CI
46
- - fix boolean settings test and add agentBusyReassignMs to dashboard
47
- - fix ENGINE_DEFAULTS ref and derive boolean settings from defaults
48
48
 
49
49
  ### Other
50
+ - refactor(watches): rename maxTriggers→stopAfter, add onNotMet per-poll action
50
51
  - refactor(dashboard): extract _releaseCCTab helper and CC_LOCK_WAIT_MS constant
52
+ - merge: resolve conflicts with master on PR-1065
51
53
  - test(engine): add regression tests for autoFixConflicts flag and DEFAULTS alias
52
54
  - refactor(ado): simplify ado-status and extract build/review status helpers
53
55
  - refactor(ado): extract stripRefsHeads helper, deduplicate refs/heads/ stripping
@@ -388,15 +388,14 @@ async function ccSend() {
388
388
  }
389
389
  var wasAborted = await _ccDoSend(message, false, originTabId);
390
390
 
391
- // Flush all queued messages at once combine into a single request.
392
- // Loop in case user queues more messages while the combined flush is processing.
391
+ // Flush queued messages to the ORIGINAL tab, even if user switched tabs
393
392
  while (tab._queue && tab._queue.length > 0) {
394
393
  if (wasAborted) {
395
394
  await new Promise(function(r) { setTimeout(r, 1500); });
396
395
  }
397
- var combined = tab._queue.splice(0);
396
+ var next = tab._queue.shift();
398
397
  _renderQueueIndicator();
399
- wasAborted = await _ccDoSend(combined.join('\n\n'), false, originTabId);
398
+ wasAborted = await _ccDoSend(next, false, originTabId);
400
399
  }
401
400
  }
402
401
 
@@ -88,6 +88,7 @@ function _processStatusUpdate(data) {
88
88
  if (_changed('skills', data.skills)) renderSkills(data.skills || []);
89
89
  if (_changed('mcpServers', data.mcpServers)) renderMcpServers(data.mcpServers || []);
90
90
  if (_changed('schedules', data.schedules)) renderSchedules(data.schedules || []);
91
+ if (_changed('watches', data.watches)) renderWatches(data.watches || []);
91
92
  if (_changed('meetings', data.meetings)) renderMeetings(data.meetings || []);
92
93
  if (_changed('pipelines', data.pipelines) && typeof renderPipelines === 'function') renderPipelines(data.pipelines || []);
93
94
  if (_changed('pinned', data.pinned)) renderPinned(data.pinned || []);
@@ -0,0 +1,310 @@
1
+ // render-watches.js — Watch rendering and CRUD functions for the dashboard
2
+
3
+ // ─── Helpers ────────────────────────────────────────────────────────────────
4
+
5
+ const _WATCH_STATUS_BADGES = {
6
+ active: '<span class="pr-badge approved">active</span>',
7
+ paused: '<span class="pr-badge rejected">paused</span>',
8
+ triggered: '<span class="pr-badge" style="background:rgba(210,153,34,0.15);color:var(--yellow);border-color:var(--yellow)">triggered</span>',
9
+ expired: '<span class="pr-badge" style="background:rgba(139,148,158,0.15);color:var(--muted);border-color:var(--muted)">expired</span>',
10
+ };
11
+
12
+ const _WATCH_TARGET_LABELS = {
13
+ pr: 'PR',
14
+ 'work-item': 'Work Item',
15
+ };
16
+
17
+ const _WATCH_CONDITION_LABELS = {
18
+ merged: 'Merged',
19
+ 'build-fail': 'Build Fail',
20
+ 'build-pass': 'Build Pass',
21
+ completed: 'Completed',
22
+ failed: 'Failed',
23
+ 'status-change': 'Status Change',
24
+ any: 'Any Change',
25
+ };
26
+
27
+ function _intervalToHuman(ms) {
28
+ if (!ms) return 'default';
29
+ const sec = Math.floor(ms / 1000);
30
+ if (sec < 60) return sec + 's';
31
+ const min = Math.floor(sec / 60);
32
+ if (min < 60) return min + 'm';
33
+ return Math.floor(min / 60) + 'h ' + (min % 60) + 'm';
34
+ }
35
+
36
+ // Parse human-friendly interval strings: "15m", "2h", "30s", "90000" (ms)
37
+ function _parseIntervalStr(s) {
38
+ if (!s) return 300000;
39
+ s = String(s).trim().toLowerCase();
40
+ if (/^\d+$/.test(s)) {
41
+ const n = parseInt(s, 10);
42
+ return n >= 1000 ? n : n * 1000; // bare numbers: ≥1000 treated as ms, else seconds
43
+ }
44
+ const match = s.match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|hours?)$/);
45
+ if (!match) return 300000;
46
+ const n = parseFloat(match[1]);
47
+ const unit = match[2][0];
48
+ if (unit === 's') return Math.round(n * 1000);
49
+ if (unit === 'm') return Math.round(n * 60000);
50
+ if (unit === 'h') return Math.round(n * 3600000);
51
+ return 300000;
52
+ }
53
+
54
+ // ─── Rendering ──────────────────────────────────────────────────────────────
55
+
56
+ let _watchPage = 0;
57
+ const WATCH_PER_PAGE = 15;
58
+
59
+ function renderWatches(watchesData) {
60
+ var watches = (watchesData || []).filter(function(w) { return !isDeleted('watch:' + w.id); });
61
+ var el = document.getElementById('watches-content');
62
+ var countEl = document.getElementById('watches-count');
63
+ if (!el) return;
64
+ if (countEl) countEl.textContent = watches.length;
65
+ window._lastWatches = watches;
66
+
67
+ if (!watches.length) {
68
+ el.innerHTML = '<p class="empty">No active watches. Create one to monitor PRs or work items.</p>';
69
+ return;
70
+ }
71
+
72
+ var totalPages = Math.ceil(watches.length / WATCH_PER_PAGE);
73
+ if (_watchPage >= totalPages) _watchPage = totalPages - 1;
74
+ var start = _watchPage * WATCH_PER_PAGE;
75
+ var pageItems = watches.slice(start, start + WATCH_PER_PAGE);
76
+
77
+ var html = '<div class="pr-table-wrap"><table class="pr-table"><thead><tr>' +
78
+ '<th>ID</th><th>Target</th><th>Type</th><th>Condition</th><th>Interval</th><th>Owner</th><th>Status</th><th>Triggers</th><th>Last Checked</th><th></th>' +
79
+ '</tr></thead><tbody>';
80
+
81
+ for (var i = 0; i < pageItems.length; i++) {
82
+ var w = pageItems[i];
83
+ var statusBadge = _WATCH_STATUS_BADGES[w.status] || escHtml(w.status || 'unknown');
84
+ var targetLabel = _WATCH_TARGET_LABELS[w.targetType] || escHtml(w.targetType || '');
85
+ var condLabel = _WATCH_CONDITION_LABELS[w.condition] || escHtml(w.condition || '');
86
+ var lastChecked = w.last_checked ? timeAgo(w.last_checked) : 'never';
87
+ var lastTriggered = w.last_triggered ? timeAgo(w.last_triggered) : 'never';
88
+ var triggerInfo = (w.triggerCount || 0) + (w.stopAfter > 0 ? '/' + w.stopAfter : '');
89
+
90
+ html += '<tr style="cursor:pointer" onclick="openWatchDetail(\'' + escHtml(w.id) + '\')">' +
91
+ '<td><span class="pr-id">' + escHtml(w.id) + '</span></td>' +
92
+ '<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escHtml(w.description || w.target) + '">' + escHtml(w.target) + '</td>' +
93
+ '<td><span class="dispatch-type explore">' + escHtml(targetLabel) + '</span></td>' +
94
+ '<td><span style="font-size:11px;color:var(--blue)">' + escHtml(condLabel) + '</span></td>' +
95
+ '<td><span style="font-size:10px;color:var(--muted)">' + escHtml(_intervalToHuman(w.interval)) + '</span></td>' +
96
+ '<td><span class="pr-agent">' + escHtml(w.owner || 'human') + '</span></td>' +
97
+ '<td>' + statusBadge + '</td>' +
98
+ '<td title="Last triggered: ' + escHtml(lastTriggered) + '"><span style="font-size:11px">' + escHtml(triggerInfo) + '</span></td>' +
99
+ '<td><span class="pr-date">' + escHtml(lastChecked) + '</span></td>' +
100
+ '<td style="white-space:nowrap">';
101
+
102
+ // Pause/Resume button
103
+ if (w.status === 'active') {
104
+ html += '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--yellow);border-color:var(--yellow);margin-right:4px" onclick="event.stopPropagation();toggleWatchPause(\'' + escHtml(w.id) + '\',true)" title="Pause">&#x23F8;</button>';
105
+ } else if (w.status === 'paused') {
106
+ html += '<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();toggleWatchPause(\'' + escHtml(w.id) + '\',false)" title="Resume">&#x25B6;</button>';
107
+ }
108
+
109
+ // Delete button
110
+ html += '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--red);border-color:var(--red)" onclick="event.stopPropagation();deleteWatch(\'' + escHtml(w.id) + '\')" title="Delete">&#x2715;</button>';
111
+ html += '</td></tr>';
112
+ }
113
+
114
+ html += '</tbody></table></div>';
115
+
116
+ if (watches.length > WATCH_PER_PAGE) {
117
+ html += '<div class="pr-pager">' +
118
+ '<span class="pr-page-info">Showing ' + (start + 1) + ' to ' + Math.min(start + WATCH_PER_PAGE, watches.length) + ' of ' + watches.length + '</span>' +
119
+ '<div class="pr-pager-btns">' +
120
+ '<button class="pr-pager-btn ' + (_watchPage === 0 ? 'disabled' : '') + '" onclick="_watchPrev()">Prev</button>' +
121
+ '<button class="pr-pager-btn ' + (_watchPage >= totalPages - 1 ? 'disabled' : '') + '" onclick="_watchNext()">Next</button>' +
122
+ '</div>' +
123
+ '</div>';
124
+ }
125
+
126
+ el.innerHTML = html;
127
+ }
128
+
129
+ function _watchPrev() { if (_watchPage > 0) { _watchPage--; renderWatches(window._lastWatches || []); } }
130
+ function _watchNext() { var tp = Math.ceil((window._lastWatches || []).length / WATCH_PER_PAGE); if (_watchPage < tp - 1) { _watchPage++; renderWatches(window._lastWatches || []); } }
131
+
132
+ // ─── Detail Modal ───────────────────────────────────────────────────────────
133
+
134
+ function openWatchDetail(id) {
135
+ var w = (window._lastWatches || []).find(function(x) { return x.id === id; });
136
+ if (!w) return;
137
+ var statusBadge = _WATCH_STATUS_BADGES[w.status] || escHtml(w.status || '');
138
+ var lastChecked = w.last_checked ? new Date(w.last_checked).toLocaleString() : 'never';
139
+ var lastTriggered = w.last_triggered ? new Date(w.last_triggered).toLocaleString() : 'never';
140
+ var createdAt = w.created_at ? new Date(w.created_at).toLocaleString() : 'unknown';
141
+ var targetLabel = _WATCH_TARGET_LABELS[w.targetType] || w.targetType;
142
+ var condLabel = _WATCH_CONDITION_LABELS[w.condition] || w.condition;
143
+
144
+ document.getElementById('modal-title').innerHTML = escHtml(w.description || w.target) +
145
+ ' <div style="display:flex;gap:4px;margin-top:4px">' +
146
+ (w.status === 'active' ? '<button class="pr-pager-btn" style="font-size:10px;padding:2px 10px;color:var(--yellow)" onclick="toggleWatchPause(\'' + escHtml(w.id) + '\',true);closeModal()">Pause</button>' : '') +
147
+ (w.status === 'paused' ? '<button class="pr-pager-btn" style="font-size:10px;padding:2px 10px;color:var(--green)" onclick="toggleWatchPause(\'' + escHtml(w.id) + '\',false);closeModal()">Resume</button>' : '') +
148
+ '<button class="pr-pager-btn" style="font-size:10px;padding:2px 10px;color:var(--red)" onclick="deleteWatch(\'' + escHtml(w.id) + '\');closeModal()">Delete</button>' +
149
+ '</div>';
150
+
151
+ var body = '<div style="display:flex;flex-direction:column;gap:10px;font-size:12px;line-height:1.6">' +
152
+ '<div><strong style="color:var(--muted)">ID:</strong> ' + escHtml(w.id) + '</div>' +
153
+ '<div><strong style="color:var(--muted)">Target:</strong> ' + escHtml(w.target) + '</div>' +
154
+ '<div><strong style="color:var(--muted)">Target Type:</strong> <span class="dispatch-type explore">' + escHtml(targetLabel) + '</span></div>' +
155
+ '<div><strong style="color:var(--muted)">Condition:</strong> <span style="color:var(--blue)">' + escHtml(condLabel) + '</span></div>' +
156
+ '<div><strong style="color:var(--muted)">Check Interval:</strong> ' + escHtml(_intervalToHuman(w.interval)) + '</div>' +
157
+ '<div><strong style="color:var(--muted)">Owner:</strong> ' + escHtml(w.owner || 'human') + '</div>' +
158
+ '<div><strong style="color:var(--muted)">Status:</strong> ' + statusBadge + '</div>' +
159
+ '<div><strong style="color:var(--muted)">Notify:</strong> ' + escHtml(w.notify || 'inbox') + '</div>' +
160
+ '<div><strong style="color:var(--muted)">Triggers:</strong> ' + (w.triggerCount || 0) + (w.stopAfter > 0 ? ' / ' + w.stopAfter + ' (expires after)' : ' (runs forever)') + '</div>' +
161
+ (w.onNotMet ? '<div><strong style="color:var(--muted)">On Each Poll (not met):</strong> ' + escHtml(w.onNotMet) + '</div>' : '') +
162
+ '<div><strong style="color:var(--muted)">Created:</strong> ' + escHtml(createdAt) + '</div>' +
163
+ '<div><strong style="color:var(--muted)">Last Checked:</strong> ' + escHtml(lastChecked) + '</div>' +
164
+ '<div><strong style="color:var(--muted)">Last Triggered:</strong> ' + escHtml(lastTriggered) + '</div>' +
165
+ (w._lastTriggerMessage ? '<div><strong style="color:var(--muted)">Last Trigger Message:</strong><div style="margin-top:4px;padding:8px;background:var(--surface2);border-radius:4px;font-size:11px">' + escHtml(w._lastTriggerMessage) + '</div></div>' : '') +
166
+ (w.project ? '<div><strong style="color:var(--muted)">Project:</strong> ' + escHtml(w.project) + '</div>' : '') +
167
+ (w.description ? '<div><strong style="color:var(--muted)">Description:</strong> ' + escHtml(w.description) + '</div>' : '') +
168
+ '</div>';
169
+
170
+ document.getElementById('modal-body').innerHTML = body;
171
+ document.getElementById('modal-body').style.whiteSpace = 'normal';
172
+ document.getElementById('modal-body').style.fontFamily = "'Segoe UI', system-ui, sans-serif";
173
+ document.getElementById('modal').classList.add('open');
174
+ }
175
+
176
+ // ─── CRUD Actions ───────────────────────────────────────────────────────────
177
+
178
+ function toggleWatchPause(id, pause) {
179
+ var newStatus = pause ? 'paused' : 'active';
180
+ showToast('cmd-toast', (pause ? 'Pausing' : 'Resuming') + ' watch...', true);
181
+ fetch('/api/watches/update', {
182
+ method: 'POST',
183
+ headers: { 'Content-Type': 'application/json' },
184
+ body: JSON.stringify({ id: id, status: newStatus })
185
+ }).then(function(res) { return res.json(); }).then(function(data) {
186
+ if (data.error) showToast('cmd-toast', 'Error: ' + data.error, false);
187
+ }).catch(function(err) {
188
+ showToast('cmd-toast', 'Error: ' + err.message, false);
189
+ });
190
+ }
191
+
192
+ function deleteWatch(id) {
193
+ if (!confirm('Delete this watch?')) return;
194
+ markDeleted('watch:' + id);
195
+ showToast('cmd-toast', 'Deleting watch...', true);
196
+ fetch('/api/watches/delete', {
197
+ method: 'POST',
198
+ headers: { 'Content-Type': 'application/json' },
199
+ body: JSON.stringify({ id: id })
200
+ }).then(function(res) { return res.json(); }).then(function(data) {
201
+ if (data.error) showToast('cmd-toast', 'Error: ' + data.error, false);
202
+ else renderWatches(window._lastWatches || []);
203
+ }).catch(function(err) {
204
+ showToast('cmd-toast', 'Error: ' + err.message, false);
205
+ });
206
+ }
207
+
208
+ // ─── Create Watch Modal ─────────────────────────────────────────────────────
209
+
210
+ function _watchFormHtml() {
211
+ var inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
212
+
213
+ var targetTypes = [
214
+ { value: 'pr', label: 'Pull Request' },
215
+ { value: 'work-item', label: 'Work Item' },
216
+ ];
217
+ var conditions = [
218
+ { value: 'merged', label: 'Merged' },
219
+ { value: 'build-fail', label: 'Build Fail' },
220
+ { value: 'build-pass', label: 'Build Pass' },
221
+ { value: 'completed', label: 'Completed' },
222
+ { value: 'failed', label: 'Failed' },
223
+ { value: 'status-change', label: 'Status Change' },
224
+ { value: 'any', label: 'Any Change' },
225
+ ];
226
+ var ttOpts = targetTypes.map(function(t) { return '<option value="' + t.value + '">' + t.label + '</option>'; }).join('');
227
+ var condOpts = conditions.map(function(c) { return '<option value="' + c.value + '">' + c.label + '</option>'; }).join('');
228
+ var agentOpts = '<option value="">human</option>' + (cmdAgents || []).map(function(a) { return '<option value="' + escHtml(a.id) + '">' + escHtml(a.name) + '</option>'; }).join('');
229
+ var projOpts = '<option value="">Any</option>' + (cmdProjects || []).map(function(p) { return '<option value="' + escHtml(p.name) + '">' + escHtml(p.name) + '</option>'; }).join('');
230
+
231
+ return '<div style="display:flex;flex-direction:column;gap:12px;font-family:inherit">' +
232
+ '<div id="watch-form-error" style="display:none;color:var(--red);font-size:12px;padding:6px 10px;background:rgba(255,50,50,0.1);border-radius:var(--radius-sm)"></div>' +
233
+ '<label style="color:var(--text);font-size:var(--text-md)">Target (PR number or Work Item ID)<input id="watch-edit-target" placeholder="e.g. 1057, W-abc123" style="' + inputStyle + '"></label>' +
234
+ '<label style="color:var(--text);font-size:var(--text-md)">Target Type<select id="watch-edit-target-type" style="' + inputStyle + '">' + ttOpts + '</select></label>' +
235
+ '<label style="color:var(--text);font-size:var(--text-md)">Condition<select id="watch-edit-condition" style="' + inputStyle + '">' + condOpts + '</select></label>' +
236
+ '<label style="color:var(--text);font-size:var(--text-md)">Check Interval <span style="font-size:10px;color:var(--muted)">(e.g. 5m, 15m, 1h — default 5m)</span><input id="watch-edit-interval" placeholder="5m" style="' + inputStyle + '"></label>' +
237
+ '<label style="color:var(--text);font-size:var(--text-md)">Owner (who gets notified)<select id="watch-edit-owner" style="' + inputStyle + '">' + agentOpts + '</select></label>' +
238
+ '<label style="color:var(--text);font-size:var(--text-md)">Project<select id="watch-edit-project" style="' + inputStyle + '">' + projOpts + '</select></label>' +
239
+ '<label style="color:var(--text);font-size:var(--text-md)">Description<input id="watch-edit-desc" placeholder="Optional description" style="' + inputStyle + '"></label>' +
240
+ '<label style="color:var(--text);font-size:var(--text-md)">Stop After N Triggers <span style="font-size:10px;color:var(--muted)">(0 = run forever, 1 = expire on first match)</span><input id="watch-edit-stop-after" type="number" value="0" min="0" style="' + inputStyle + '"></label>' +
241
+ '<label style="color:var(--text);font-size:var(--text-md)">On Each Poll (if condition not met)<select id="watch-edit-on-not-met" style="' + inputStyle + '"><option value="">None — do nothing</option><option value="notify">Notify — write to inbox each poll</option></select></label>' +
242
+ '</div>';
243
+ }
244
+
245
+ function openCreateWatchModal() {
246
+ document.getElementById('modal-title').innerHTML = 'Create Watch' +
247
+ ' <button class="pr-pager-btn" style="font-size:10px;padding:2px 12px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="submitWatch()">Create</button>';
248
+ document.getElementById('modal-body').innerHTML = _watchFormHtml();
249
+ document.getElementById('modal-body').style.whiteSpace = 'normal';
250
+ document.getElementById('modal-body').style.fontFamily = "'Segoe UI', system-ui, sans-serif";
251
+ document.getElementById('modal').classList.add('open');
252
+ }
253
+
254
+ function submitWatch() {
255
+ var target = (document.getElementById('watch-edit-target') || {}).value || '';
256
+ var targetType = (document.getElementById('watch-edit-target-type') || {}).value || 'pr';
257
+ var condition = (document.getElementById('watch-edit-condition') || {}).value || 'merged';
258
+ var interval = _parseIntervalStr((document.getElementById('watch-edit-interval') || {}).value);
259
+ var owner = (document.getElementById('watch-edit-owner') || {}).value || '';
260
+ var project = (document.getElementById('watch-edit-project') || {}).value || '';
261
+ var description = (document.getElementById('watch-edit-desc') || {}).value || '';
262
+ var stopAfter = parseInt((document.getElementById('watch-edit-stop-after') || {}).value, 10) || 0;
263
+ var onNotMet = (document.getElementById('watch-edit-on-not-met') || {}).value || '';
264
+
265
+ if (!target.trim()) {
266
+ var errEl = document.getElementById('watch-form-error');
267
+ if (errEl) { errEl.textContent = 'Target is required'; errEl.style.display = 'block'; }
268
+ return;
269
+ }
270
+
271
+ showToast('cmd-toast', 'Creating watch...', true);
272
+ fetch('/api/watches', {
273
+ method: 'POST',
274
+ headers: { 'Content-Type': 'application/json' },
275
+ body: JSON.stringify({
276
+ target: target.trim(),
277
+ targetType: targetType,
278
+ condition: condition,
279
+ interval: interval,
280
+ owner: owner || 'human',
281
+ project: project || null,
282
+ description: description || null,
283
+ notify: 'inbox',
284
+ stopAfter: stopAfter,
285
+ onNotMet: onNotMet || null,
286
+ })
287
+ }).then(function(res) { return res.json(); }).then(function(data) {
288
+ if (data.error) {
289
+ showToast('cmd-toast', 'Error: ' + data.error, false);
290
+ } else {
291
+ showToast('cmd-toast', 'Watch created: ' + data.watch.id, true);
292
+ closeModal();
293
+ }
294
+ }).catch(function(err) {
295
+ showToast('cmd-toast', 'Error: ' + err.message, false);
296
+ });
297
+ }
298
+
299
+ // ─── Exports ────────────────────────────────────────────────────────────────
300
+
301
+ window.MinionsWatches = {
302
+ renderWatches: renderWatches,
303
+ openCreateWatchModal: openCreateWatchModal,
304
+ openWatchDetail: openWatchDetail,
305
+ submitWatch: submitWatch,
306
+ toggleWatchPause: toggleWatchPause,
307
+ deleteWatch: deleteWatch,
308
+ _watchPrev: _watchPrev,
309
+ _watchNext: _watchNext,
310
+ };
@@ -72,6 +72,7 @@
72
72
  <a class="sidebar-link" data-page="inbox" href="/inbox">Notes & KB</a>
73
73
  <a class="sidebar-link" data-page="tools" href="/tools">Skills & MCP</a>
74
74
  <a class="sidebar-link" data-page="schedule" href="/schedule" title="Cron-based recurring tasks (single task per schedule)">Schedules</a>
75
+ <a class="sidebar-link" data-page="watches" href="/watches" title="Persistent watches that monitor PRs, work items, and branches">Watches</a>
75
76
  <a class="sidebar-link" data-page="pipelines" href="/pipelines" title="Multi-stage workflows — chain tasks, meetings, plans with dependencies">Pipelines</a>
76
77
  <a class="sidebar-link" data-page="meetings" href="/meetings">Meetings</a>
77
78
  <a class="sidebar-link" data-page="engine" href="/engine">Engine</a>
@@ -0,0 +1,7 @@
1
+ <section id="watches-section">
2
+ <h2>Watches <span class="count" id="watches-count">0</span>
3
+ <button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="openCreateWatchModal()">+ New</button>
4
+ <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">persistent watches that monitor PRs, work items, and branches for changes</span>
5
+ </h2>
6
+ <div id="watches-content"><p class="empty">No active watches. Create one to monitor PRs, work items, or branches.</p></div>
7
+ </section>
@@ -20,7 +20,7 @@ function buildDashboardHtml() {
20
20
  const layout = safeRead(layoutPath);
21
21
  const css = safeRead(path.join(dashDir, 'styles.css'));
22
22
 
23
- const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'pipelines', 'meetings', 'engine'];
23
+ const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'engine'];
24
24
  let pageHtml = '';
25
25
  for (const p of pages) {
26
26
  const content = safeRead(path.join(dashDir, 'pages', p + '.html'));
@@ -32,7 +32,7 @@ function buildDashboardHtml() {
32
32
  'utils', 'state', 'detail-panel', 'live-stream',
33
33
  'render-agents', 'render-dispatch', 'render-work-items', 'render-prd',
34
34
  'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
35
- 'render-other', 'render-schedules', 'render-pipelines', 'render-meetings', 'render-pinned',
35
+ 'render-other', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
36
36
  'command-parser', 'command-input', 'command-center', 'command-history',
37
37
  'modal', 'modal-qa', 'settings', 'refresh'
38
38
  ];
package/dashboard.js CHANGED
@@ -23,6 +23,7 @@ const queries = require('./engine/queries');
23
23
  const teams = require('./engine/teams');
24
24
  const ado = require('./engine/ado');
25
25
  const gh = require('./engine/github');
26
+ const watchesMod = require('./engine/watches');
26
27
  const os = require('os');
27
28
 
28
29
  const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, reopenWorkItem } = shared;
@@ -77,7 +78,7 @@ function buildDashboardHtml() {
77
78
  const css = safeRead(path.join(dashDir, 'styles.css'));
78
79
 
79
80
  // Assemble page fragments
80
- const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'pipelines', 'meetings', 'engine'];
81
+ const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'engine'];
81
82
  let pageHtml = '';
82
83
  for (const p of pages) {
83
84
  const content = safeRead(path.join(dashDir, 'pages', p + '.html'));
@@ -90,7 +91,7 @@ function buildDashboardHtml() {
90
91
  'utils', 'state', 'render-utils', 'detail-panel', 'live-stream',
91
92
  'render-agents', 'render-dispatch', 'render-work-items', 'render-prd',
92
93
  'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
93
- 'render-other', 'render-schedules', 'render-pipelines', 'render-meetings', 'render-pinned',
94
+ 'render-other', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
94
95
  'command-parser', 'command-input', 'command-center', 'command-history',
95
96
  'modal', 'modal-qa', 'settings', 'refresh'
96
97
  ];
@@ -396,6 +397,7 @@ function getStatus() {
396
397
  const runs = shared.safeJson(path.join(MINIONS_DIR, 'engine', 'schedule-runs.json')) || {};
397
398
  return scheds.map(s => ({ ...s, _lastRun: runs[s.id] || null }));
398
399
  })(),
400
+ watches: watchesMod.getWatches(),
399
401
  meetings: (() => { try { return require('./engine/meeting').getMeetings(); } catch { return []; } })(),
400
402
  pipelines: (() => { try { const pl = require('./engine/pipeline'); return pl.getPipelines().map(p => ({ ...p, runs: (pl.getPipelineRuns()[p.id] || []).slice(-5) })); } catch { return []; } })(),
401
403
  pinned: (() => { try { return parsePinnedEntries(safeRead(path.join(MINIONS_DIR, 'pinned.md'))); } catch { return []; } })(),
@@ -596,6 +598,18 @@ function parseCCActions(text) {
596
598
  // Actions are executed server-side so all clients (frontend, curl, Teams) get the same behavior.
597
599
  // The frontend still shows status toasts but no longer needs to fire the API calls.
598
600
 
601
+ // Parse interval from CC action — accepts ms number, "15m", "1h", "30s", or null (default 5m).
602
+ function _parseWatchInterval(val) {
603
+ if (!val) return 300000;
604
+ if (typeof val === 'number') return Math.max(60000, val);
605
+ const s = String(val).trim().toLowerCase();
606
+ if (/^\d+$/.test(s)) { const n = parseInt(s, 10); return Math.max(60000, n >= 1000 ? n : n * 1000); }
607
+ const m = s.match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|hours?)$/);
608
+ if (!m) return 300000;
609
+ const n = parseFloat(m[1]), u = m[2][0];
610
+ return Math.max(60000, Math.round(u === 's' ? n * 1000 : u === 'm' ? n * 60000 : n * 3600000));
611
+ }
612
+
599
613
  async function executeCCActions(actions) {
600
614
  const results = [];
601
615
  for (const action of actions) {
@@ -672,6 +686,23 @@ async function executeCCActions(actions) {
672
686
  results.push({ type: 'reopen-work-item', id: action.id, ...(reopenResult || { error: 'unexpected' }) });
673
687
  break;
674
688
  }
689
+ case 'create-watch': {
690
+ const intervalMs = _parseWatchInterval(action.interval);
691
+ const watch = watchesMod.createWatch({
692
+ target: action.target,
693
+ targetType: action.targetType || 'pr',
694
+ condition: action.condition || 'build-pass',
695
+ interval: intervalMs,
696
+ owner: action.owner || 'human',
697
+ description: action.description || null,
698
+ project: action.project || null,
699
+ notify: 'inbox',
700
+ stopAfter: Number(action.stopAfter) || 0,
701
+ onNotMet: action.onNotMet || null,
702
+ });
703
+ results.push({ type: 'create-watch', id: watch.id, ok: true });
704
+ break;
705
+ }
675
706
  default:
676
707
  // Server didn't handle — frontend must execute
677
708
  results.push({ type: action.type });
@@ -3745,6 +3776,49 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3745
3776
  }
3746
3777
  }
3747
3778
 
3779
+ // ── Watches API Handlers ─────────────────────────────────────────────────
3780
+
3781
+ async function handleWatchesList(req, res) {
3782
+ return jsonReply(res, 200, { watches: watchesMod.getWatches() });
3783
+ }
3784
+
3785
+ async function handleWatchesCreate(req, res) {
3786
+ const body = await readBody(req);
3787
+ const { target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet } = body;
3788
+ if (!target) return jsonReply(res, 400, { error: 'target is required' });
3789
+ try {
3790
+ const watch = watchesMod.createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet });
3791
+ invalidateStatusCache();
3792
+ return jsonReply(res, 200, { ok: true, watch });
3793
+ } catch (e) {
3794
+ return jsonReply(res, 400, { error: e.message });
3795
+ }
3796
+ }
3797
+
3798
+ async function handleWatchesUpdate(req, res) {
3799
+ const body = await readBody(req);
3800
+ const { id, ...updates } = body;
3801
+ if (!id) return jsonReply(res, 400, { error: 'id is required' });
3802
+ try {
3803
+ const watch = watchesMod.updateWatch(id, updates);
3804
+ if (!watch) return jsonReply(res, 404, { error: 'Watch not found' });
3805
+ invalidateStatusCache();
3806
+ return jsonReply(res, 200, { ok: true, watch });
3807
+ } catch (e) {
3808
+ return jsonReply(res, 400, { error: e.message });
3809
+ }
3810
+ }
3811
+
3812
+ async function handleWatchesDelete(req, res) {
3813
+ const body = await readBody(req);
3814
+ const { id } = body;
3815
+ if (!id) return jsonReply(res, 400, { error: 'id is required' });
3816
+ const deleted = watchesMod.deleteWatch(id);
3817
+ if (!deleted) return jsonReply(res, 404, { error: 'Watch not found' });
3818
+ invalidateStatusCache();
3819
+ return jsonReply(res, 200, { ok: true });
3820
+ }
3821
+
3748
3822
  async function handleEngineRestart(req, res) {
3749
3823
  try {
3750
3824
  const newPid = restartEngine();
@@ -4442,6 +4516,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4442
4516
  { method: 'POST', path: '/api/schedules/update', desc: 'Update an existing schedule', params: 'id, cron?, title?, type?, project?, agent?, description?, priority?, enabled?', handler: handleSchedulesUpdate },
4443
4517
  { method: 'POST', path: '/api/schedules/delete', desc: 'Delete a schedule', params: 'id', handler: handleSchedulesDelete },
4444
4518
 
4519
+ // Watches
4520
+ { method: 'GET', path: '/api/watches', desc: 'List all watches', handler: handleWatchesList },
4521
+ { method: 'POST', path: '/api/watches', desc: 'Create a new watch', params: 'target, targetType, condition, interval?, owner?, description?, project?, notify?, stopAfter?, onNotMet?', handler: handleWatchesCreate },
4522
+ { method: 'POST', path: '/api/watches/update', desc: 'Update a watch (pause/resume/modify)', params: 'id, status?, interval?, description?, notify?, stopAfter?, onNotMet?, condition?', handler: handleWatchesUpdate },
4523
+ { method: 'POST', path: '/api/watches/delete', desc: 'Delete a watch', params: 'id', handler: handleWatchesDelete },
4524
+
4445
4525
  // Pipelines
4446
4526
  { method: 'GET', path: '/api/pipelines', desc: 'List all pipelines with runs', handler: async (req, res) => {
4447
4527
  const { getPipelines, getPipelineRuns } = require('./engine/pipeline');
@@ -268,10 +268,32 @@ function validatePlaybookVars(playbookName, vars) {
268
268
  return { valid: missing.length === 0, missing };
269
269
  }
270
270
 
271
+ // ─── Playbook Path Resolution ───────────────────────────────────────────────
272
+
273
+ /**
274
+ * Resolve playbook file path, checking project-local override first.
275
+ * If projects/<projectName>/playbooks/<playbookType>.md exists, use it.
276
+ * Otherwise fall back to the global playbooks/<playbookType>.md.
277
+ * @param {string|null|undefined} projectName — project name (directory under projects/)
278
+ * @param {string} playbookType — playbook type name (e.g. 'implement', 'review')
279
+ * @returns {string} absolute path to the playbook file
280
+ */
281
+ function resolvePlaybookPath(projectName, playbookType) {
282
+ if (projectName) {
283
+ const localPath = path.join(MINIONS_DIR, 'projects', projectName, 'playbooks', `${playbookType}.md`);
284
+ try {
285
+ fs.accessSync(localPath, fs.constants.R_OK);
286
+ return localPath;
287
+ } catch { /* no local override — fall through to global */ }
288
+ }
289
+ return path.join(PLAYBOOKS_DIR, `${playbookType}.md`);
290
+ }
291
+
271
292
  // ─── Playbook Renderer ──────────────────────────────────────────────────────
272
293
 
273
294
  function renderPlaybook(type, vars) {
274
- const pbPath = path.join(PLAYBOOKS_DIR, `${type}.md`);
295
+ const projectName = vars.project_name || null;
296
+ const pbPath = resolvePlaybookPath(projectName, type);
275
297
  let content;
276
298
  try { content = fs.readFileSync(pbPath, 'utf8'); } catch {
277
299
  log('warn', `Playbook not found: ${type}`);
@@ -361,6 +383,9 @@ function renderPlaybook(type, vars) {
361
383
  return null;
362
384
  }
363
385
 
386
+ // Capture which vars are actually referenced in the template before substitution
387
+ const referencedVars = new Set((content.match(/\{\{(\w+)\}\}/g) || []).map(m => m.slice(2, -2)));
388
+
364
389
  // Process conditional blocks: {{#key}}...{{/key}} — include block only if key is truthy
365
390
  content = content.replace(/\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g, (_, key, block) => {
366
391
  const val = allVars[key];
@@ -380,9 +405,9 @@ function renderPlaybook(type, vars) {
380
405
  log('warn', `Playbook "${type}": substituted values contain unresolved {{...}} patterns (potential self-reference): ${selfRefVars.join(', ')}`);
381
406
  }
382
407
 
383
- // Warn on variables that resolved to empty string (skip known-optional vars)
408
+ // Warn on variables that resolved to empty string only for vars actually used in the template
384
409
  const emptyVars = Object.entries(allVars)
385
- .filter(([key, val]) => String(val) === '' && !PLAYBOOK_OPTIONAL_VARS.has(key))
410
+ .filter(([key, val]) => String(val) === '' && referencedVars.has(key) && !PLAYBOOK_OPTIONAL_VARS.has(key))
386
411
  .map(([key]) => key);
387
412
  if (emptyVars.length > 0) {
388
413
  log('warn', `Playbook "${type}": template variables resolved to empty string: ${emptyVars.join(', ')}`);
@@ -565,6 +590,7 @@ function buildPrDispatch(agentId, config, project, pr, type, extraVars, taskLabe
565
590
 
566
591
  module.exports = {
567
592
  renderPlaybook,
593
+ resolvePlaybookPath,
568
594
  validatePlaybookVars,
569
595
  PLAYBOOK_REQUIRED_VARS,
570
596
  PLAYBOOK_OPTIONAL_VARS,
package/engine/shared.js CHANGED
@@ -613,6 +613,11 @@ const PR_STATUS = { ACTIVE: 'active', MERGED: 'merged', ABANDONED: 'abandoned',
613
613
  // PRs eligible for polling (status/build/comment checks) — excludes terminal statuses
614
614
  const PR_POLLABLE_STATUSES = new Set([PR_STATUS.ACTIVE, PR_STATUS.LINKED]);
615
615
 
616
+ // Watch statuses — engine-level persistent watches that survive restarts
617
+ const WATCH_STATUS = { ACTIVE: 'active', PAUSED: 'paused', TRIGGERED: 'triggered', EXPIRED: 'expired' };
618
+ const WATCH_TARGET_TYPE = { PR: 'pr', WORK_ITEM: 'work-item' };
619
+ const WATCH_CONDITION = { MERGED: 'merged', BUILD_FAIL: 'build-fail', BUILD_PASS: 'build-pass', COMPLETED: 'completed', FAILED: 'failed', STATUS_CHANGE: 'status-change', ANY: 'any' };
620
+
616
621
  /** Update per-agent review metrics (prsApproved/prsRejected). Only writes for configured agents. */
617
622
  function trackReviewMetric(pr, newReviewStatus, config) {
618
623
  if (newReviewStatus !== 'approved' && newReviewStatus !== 'changes-requested') return;
@@ -1138,6 +1143,7 @@ module.exports = {
1138
1143
  classifyInboxItem,
1139
1144
  ENGINE_DEFAULTS,
1140
1145
  WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, DISPATCH_RESULT, trackReviewMetric, queuePlanToPrd,
1146
+ WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION,
1141
1147
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
1142
1148
  FAILURE_CLASS, ESCALATION_POLICY, COMPLETION_FIELDS,
1143
1149
  DEFAULT_AGENT_METRICS,
@@ -0,0 +1,306 @@
1
+ /**
2
+ * engine/watches.js -- Persistent watch jobs that survive engine restarts.
3
+ * Zero dependencies -- uses only Node.js built-ins + engine/shared.js.
4
+ *
5
+ * Watches monitor targets (PRs, work items, branches) for conditions (merged,
6
+ * build-fail, completed, etc.) and fire inbox notifications when triggered.
7
+ *
8
+ * State stored in engine/watches.json — concurrency-safe via mutateJsonFileLocked.
9
+ */
10
+
11
+ const path = require('path');
12
+ const shared = require('./shared');
13
+ const { safeJson, mutateJsonFileLocked, ts, uid, log, writeToInbox,
14
+ WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS } = shared;
15
+
16
+ // Dynamic path — respects MINIONS_TEST_DIR for test isolation
17
+ function _watchesPath() { return path.join(shared.MINIONS_DIR, 'engine', 'watches.json'); }
18
+
19
+ // Default check interval: 5 minutes (300000ms). Engine tick runs every 60s,
20
+ // so watches are checked every N ticks where N = ceil(interval / tickInterval).
21
+ const DEFAULT_WATCH_INTERVAL = 300000;
22
+
23
+ /**
24
+ * Read all watches from disk.
25
+ * @returns {Array<object>}
26
+ */
27
+ function getWatches() {
28
+ return safeJson(_watchesPath()) || [];
29
+ }
30
+
31
+ /**
32
+ * Create a new watch. Validates required fields and writes atomically.
33
+ * @param {object} opts - Watch definition
34
+ * @returns {object} - Created watch
35
+ */
36
+ function createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet }) {
37
+ if (!target) throw new Error('target is required');
38
+ if (!targetType || !Object.values(WATCH_TARGET_TYPE).includes(targetType)) {
39
+ throw new Error(`targetType must be one of: ${Object.values(WATCH_TARGET_TYPE).join(', ')}`);
40
+ }
41
+ if (!condition || !Object.values(WATCH_CONDITION).includes(condition)) {
42
+ throw new Error(`condition must be one of: ${Object.values(WATCH_CONDITION).join(', ')}`);
43
+ }
44
+
45
+ const watch = {
46
+ id: 'watch-' + uid(),
47
+ target,
48
+ targetType,
49
+ condition,
50
+ interval: Math.max(60000, Number(interval) || DEFAULT_WATCH_INTERVAL),
51
+ owner: owner || 'human',
52
+ status: WATCH_STATUS.ACTIVE,
53
+ description: description || `Watch ${target} for ${condition}`,
54
+ project: project || null,
55
+ notify: notify || 'inbox',
56
+ stopAfter: Number(stopAfter) || 0, // 0 = run forever; N = expire after N triggers
57
+ onNotMet: onNotMet || null, // null | 'notify' — action per poll when condition not met
58
+ triggerCount: 0,
59
+ created_at: ts(),
60
+ last_checked: null,
61
+ last_triggered: null,
62
+ };
63
+
64
+ mutateJsonFileLocked(_watchesPath(), (watches) => {
65
+ if (!Array.isArray(watches)) watches = [];
66
+ watches.push(watch);
67
+ return watches;
68
+ }, { defaultValue: [] });
69
+
70
+ log('info', `Watch created: ${watch.id} → ${watch.target} (${watch.condition})`);
71
+ return watch;
72
+ }
73
+
74
+ /**
75
+ * Update a watch by ID. Only updates provided fields.
76
+ * @param {string} id - Watch ID
77
+ * @param {object} updates - Fields to update
78
+ * @returns {object|null} - Updated watch or null if not found
79
+ */
80
+ function updateWatch(id, updates) {
81
+ if (!id) throw new Error('id is required');
82
+ // Validate status before entering the lock — reject early, never persist invalid values
83
+ if (updates.status !== undefined && !Object.values(WATCH_STATUS).includes(updates.status)) {
84
+ log('warn', `Invalid watch status: ${updates.status}`);
85
+ return null;
86
+ }
87
+ let found = null;
88
+ mutateJsonFileLocked(_watchesPath(), (watches) => {
89
+ if (!Array.isArray(watches)) return watches;
90
+ const watch = watches.find(w => w.id === id);
91
+ if (!watch) return watches;
92
+ // Only allow safe field updates
93
+ const allowed = ['status', 'interval', 'description', 'notify', 'stopAfter', 'onNotMet', 'condition'];
94
+ for (const key of allowed) {
95
+ if (updates[key] !== undefined) watch[key] = updates[key];
96
+ }
97
+ found = { ...watch };
98
+ return watches;
99
+ }, { defaultValue: [] });
100
+ return found;
101
+ }
102
+
103
+ /**
104
+ * Delete a watch by ID.
105
+ * @param {string} id
106
+ * @returns {boolean} - true if deleted
107
+ */
108
+ function deleteWatch(id) {
109
+ if (!id) return false;
110
+ let deleted = false;
111
+ mutateJsonFileLocked(_watchesPath(), (watches) => {
112
+ if (!Array.isArray(watches)) return watches;
113
+ const idx = watches.findIndex(w => w.id === id);
114
+ if (idx >= 0) {
115
+ watches.splice(idx, 1);
116
+ deleted = true;
117
+ }
118
+ return watches;
119
+ }, { defaultValue: [] });
120
+ if (deleted) log('info', `Watch deleted: ${id}`);
121
+ return deleted;
122
+ }
123
+
124
+ /**
125
+ * Evaluate whether a watch condition is met given current state.
126
+ * @param {object} watch - The watch object
127
+ * @param {object} state - { pullRequests, workItems } current state
128
+ * @returns {{ triggered: boolean, message: string }}
129
+ */
130
+ function evaluateWatch(watch, state) {
131
+ const { target, targetType, condition } = watch;
132
+
133
+ if (targetType === WATCH_TARGET_TYPE.PR) {
134
+ const pr = (state.pullRequests || []).find(p =>
135
+ String(p.prNumber) === String(target) || p.id === target
136
+ );
137
+ if (!pr) return { triggered: false, message: `PR ${target} not found` };
138
+
139
+ // Store previous state for status-change detection
140
+ const prevState = watch._lastState || {};
141
+
142
+ switch (condition) {
143
+ case WATCH_CONDITION.MERGED:
144
+ return { triggered: pr.status === 'merged', message: pr.status === 'merged' ? `PR ${target} was merged` : '' };
145
+ case WATCH_CONDITION.BUILD_FAIL:
146
+ return { triggered: pr.buildStatus === 'failing', message: pr.buildStatus === 'failing' ? `PR ${target} build is failing` : '' };
147
+ case WATCH_CONDITION.BUILD_PASS:
148
+ return { triggered: pr.buildStatus === 'passing', message: pr.buildStatus === 'passing' ? `PR ${target} build is passing` : '' };
149
+ case WATCH_CONDITION.STATUS_CHANGE: {
150
+ const changed = prevState.status !== undefined && prevState.status !== pr.status;
151
+ return { triggered: changed, message: changed ? `PR ${target} status changed: ${prevState.status} → ${pr.status}` : '' };
152
+ }
153
+ case WATCH_CONDITION.ANY: {
154
+ const anyChanged = prevState.status !== undefined && (
155
+ prevState.status !== pr.status ||
156
+ prevState.buildStatus !== pr.buildStatus ||
157
+ prevState.reviewStatus !== pr.reviewStatus
158
+ );
159
+ return { triggered: anyChanged, message: anyChanged ? `PR ${target} changed` : '' };
160
+ }
161
+ default:
162
+ return { triggered: false, message: `Unknown condition: ${condition}` };
163
+ }
164
+ }
165
+
166
+ if (targetType === WATCH_TARGET_TYPE.WORK_ITEM) {
167
+ const wi = (state.workItems || []).find(w => w.id === target);
168
+ if (!wi) return { triggered: false, message: `Work item ${target} not found` };
169
+
170
+ const prevState = watch._lastState || {};
171
+
172
+ switch (condition) {
173
+ case WATCH_CONDITION.COMPLETED:
174
+ return { triggered: shared.DONE_STATUSES.has(wi.status), message: shared.DONE_STATUSES.has(wi.status) ? `Work item ${target} completed (${wi.status})` : '' };
175
+ case WATCH_CONDITION.FAILED:
176
+ return { triggered: wi.status === shared.WI_STATUS.FAILED, message: wi.status === shared.WI_STATUS.FAILED ? `Work item ${target} failed` : '' };
177
+ case WATCH_CONDITION.STATUS_CHANGE: {
178
+ const changed = prevState.status !== undefined && prevState.status !== wi.status;
179
+ return { triggered: changed, message: changed ? `Work item ${target} status: ${prevState.status} → ${wi.status}` : '' };
180
+ }
181
+ case WATCH_CONDITION.ANY: {
182
+ const anyChanged = prevState.status !== undefined && prevState.status !== wi.status;
183
+ return { triggered: anyChanged, message: anyChanged ? `Work item ${target} changed (${wi.status})` : '' };
184
+ }
185
+ default:
186
+ return { triggered: false, message: `Unknown condition: ${condition}` };
187
+ }
188
+ }
189
+
190
+ return { triggered: false, message: `Unknown target type: ${targetType}` };
191
+ }
192
+
193
+ /**
194
+ * Check all active watches against current state. Called from engine tick.
195
+ * @param {object} config - Engine config
196
+ * @param {object} state - { pullRequests, workItems } from queries
197
+ */
198
+ function checkWatches(config, state) {
199
+ const now = Date.now();
200
+ // Collect notifications to fire AFTER lock is released — never do I/O inside the lock callback
201
+ const notifications = [];
202
+
203
+ mutateJsonFileLocked(_watchesPath(), (watches) => {
204
+ if (!Array.isArray(watches) || watches.length === 0) return watches;
205
+
206
+ for (const watch of watches) {
207
+ try {
208
+ if (watch.status !== WATCH_STATUS.ACTIVE) continue;
209
+
210
+ // Check interval — skip if checked too recently
211
+ if (watch.last_checked) {
212
+ const elapsed = now - new Date(watch.last_checked).getTime();
213
+ if (elapsed < watch.interval) continue;
214
+ }
215
+
216
+ watch.last_checked = ts();
217
+
218
+ // Initialize baseline state on first check for change-detection conditions.
219
+ // Without this, status-change/any conditions would have no previous state to compare.
220
+ if (!watch._lastState || Object.keys(watch._lastState).length === 0) {
221
+ watch._lastState = _captureState(watch, state);
222
+ }
223
+
224
+ const result = evaluateWatch(watch, state);
225
+ if (result.triggered) {
226
+ watch.triggerCount = (watch.triggerCount || 0) + 1;
227
+ watch.last_triggered = ts();
228
+ watch._lastTriggerMessage = result.message;
229
+
230
+ // Queue trigger notification — unique key per trigger to avoid overwriting previous messages
231
+ if (watch.notify === 'inbox' && watch.owner) {
232
+ notifications.push({
233
+ type: 'trigger', owner: watch.owner,
234
+ slug: `watch-${watch.id}-${watch.triggerCount}`,
235
+ body: `## Watch Triggered: ${watch.description}\n\n${result.message}\n\nWatch ID: ${watch.id} | Target: ${watch.target} | Condition: ${watch.condition}`,
236
+ });
237
+ }
238
+ log('info', `Watch triggered: ${watch.id} — ${result.message}`);
239
+
240
+ // Expire: absolute conditions auto-expire when stopAfter is 0 (fire-once semantics),
241
+ // change-based conditions (status-change, any) respect stopAfter literally (0 = run forever).
242
+ const isAbsolute = WATCH_ABSOLUTE_CONDITIONS.has(watch.condition);
243
+ if (watch.stopAfter > 0 && watch.triggerCount >= watch.stopAfter) {
244
+ watch.status = WATCH_STATUS.EXPIRED;
245
+ log('info', `Watch expired (stopAfter limit reached): ${watch.id}`);
246
+ } else if (isAbsolute && watch.stopAfter === 0) {
247
+ watch.status = WATCH_STATUS.EXPIRED;
248
+ log('info', `Watch expired (absolute condition auto-expire): ${watch.id}`);
249
+ }
250
+ } else if (watch.onNotMet === 'notify' && watch.owner) {
251
+ // Queue per-poll notification when condition is not yet met — unique key per poll
252
+ notifications.push({
253
+ type: 'poll', owner: watch.owner,
254
+ slug: `watch-poll-${watch.id}-${Date.now()}`,
255
+ body: `## Watch Polling: ${watch.description}\n\nCondition not yet met (${watch.condition}) — still watching.\n\nWatch ID: ${watch.id} | Target: ${watch.target} | Checks so far: ${watch.triggerCount || 0}`,
256
+ });
257
+ }
258
+
259
+ // Capture state for change detection on next check
260
+ watch._lastState = _captureState(watch, state);
261
+ } catch (err) {
262
+ log('warn', `Watch check error (${watch.id}): ${err.message}`);
263
+ }
264
+ }
265
+
266
+ return watches;
267
+ }, { defaultValue: [] });
268
+
269
+ // Fire notifications outside the lock — writeToInbox does disk I/O
270
+ for (const n of notifications) {
271
+ try {
272
+ writeToInbox(n.owner, n.slug, n.body);
273
+ } catch (err) {
274
+ log('warn', `Watch notification error: ${err.message}`);
275
+ }
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Internal: capture state snapshot for a watch target.
281
+ */
282
+ function _captureState(watch, state) {
283
+ if (watch.targetType === WATCH_TARGET_TYPE.PR) {
284
+ const pr = (state.pullRequests || []).find(p =>
285
+ String(p.prNumber) === String(watch.target) || p.id === watch.target
286
+ );
287
+ if (pr) return { status: pr.status, buildStatus: pr.buildStatus, reviewStatus: pr.reviewStatus };
288
+ }
289
+ if (watch.targetType === WATCH_TARGET_TYPE.WORK_ITEM) {
290
+ const wi = (state.workItems || []).find(w => w.id === watch.target);
291
+ if (wi) return { status: wi.status };
292
+ }
293
+ return {};
294
+ }
295
+
296
+ module.exports = {
297
+ DEFAULT_WATCH_INTERVAL,
298
+ getWatches,
299
+ createWatch,
300
+ updateWatch,
301
+ deleteWatch,
302
+ evaluateWatch,
303
+ checkWatches,
304
+ _captureState, // exported for testing
305
+ _watchesPath, // exported for testing — dynamic, respects MINIONS_TEST_DIR
306
+ };
package/engine.js CHANGED
@@ -1984,46 +1984,6 @@ async function discoverFromPrs(config, project) {
1984
1984
  if (item) { newWork.push(item); }
1985
1985
  }
1986
1986
 
1987
- // Re-review after fix: only trigger when fixedAt > lastReviewedAt (not a broad !alreadyReviewed
1988
- // fallback — that caused infinite re-review loops on GitHub where self-approval is blocked)
1989
- const fixedAfterReview = !!(pr.minionsReview?.fixedAt &&
1990
- pr.lastReviewedAt && pr.minionsReview.fixedAt > pr.lastReviewedAt);
1991
- const needsReReview = autoReview && reviewStatus === 'waiting' &&
1992
- fixedAfterReview && !evalEscalated;
1993
- if (needsReReview) {
1994
- const key = `review-${project?.name || 'default'}-${pr.id}`;
1995
- // Skip isAlreadyDispatched — fixedAfterReview/alreadyReviewed already dedup; the 1hr
1996
- // completed-dispatch window would block legitimate re-reviews within the hour after a fix
1997
- if (isOnCooldown(key, cooldownMs)) continue;
1998
-
1999
- // Pre-dispatch live vote check — cached 'waiting' may be stale if reviewer already acted
2000
- try {
2001
- const checkFn = project.repoHost === 'github' ? ghCheckLiveReview : adoCheckLiveReview;
2002
- const liveStatus = await checkFn(pr, project);
2003
- if (liveStatus && liveStatus !== 'waiting') {
2004
- log('info', `Pre-dispatch vote check: ${pr.id} is ${liveStatus} (cached was waiting) — skipping re-review`);
2005
- if (pr.reviewStatus !== 'approved') pr.reviewStatus = liveStatus;
2006
- try {
2007
- mutateJsonFileLocked(projectPrPath(project), data => {
2008
- if (!Array.isArray(data)) return data;
2009
- const target = data.find(p => p.id === pr.id);
2010
- if (target && target.reviewStatus !== 'approved') target.reviewStatus = liveStatus;
2011
- return data;
2012
- });
2013
- } catch {}
2014
- continue;
2015
- }
2016
- } catch (e) { log('warn', `Pre-dispatch vote check for ${pr.id}: ${e.message}`); }
2017
-
2018
- const agentId = resolveAgent('review', config);
2019
- if (!agentId) continue;
2020
- const item = buildPrDispatch(agentId, config, project, pr, 'review', {
2021
- pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: pr.branch || '',
2022
- pr_author: pr.agent || '', pr_url: pr.url || '',
2023
- }, `Review ${pr.id}: ${pr.title}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
2024
- if (item) { newWork.push(item); setCooldown(key); }
2025
- }
2026
-
2027
1987
  // PRs with changes requested → route back to author for fix
2028
1988
  let fixDispatched = false;
2029
1989
  if (reviewStatus === 'changes-requested' && !awaitingReReview && !evalEscalated) {
@@ -2037,7 +1997,7 @@ async function discoverFromPrs(config, project) {
2037
1997
  review_note: pr.minionsReview?.note || pr.reviewNote || 'See PR thread comments',
2038
1998
  }, `Fix ${pr.id}: ${pr.title || ''} — review feedback`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
2039
1999
  if (item) {
2040
- newWork.push(item); fixDispatched = true;
2000
+ newWork.push(item); setCooldown(key); fixDispatched = true;
2041
2001
  // Increment review→fix cycle counter
2042
2002
  try {
2043
2003
  mutatePullRequests(projectPrPath(project), prs => {
@@ -2075,7 +2035,7 @@ async function discoverFromPrs(config, project) {
2075
2035
  reviewer: 'Human Reviewer',
2076
2036
  review_note: reviewNote,
2077
2037
  }, `Fix ${pr.id}: ${pr.title || ''} — human feedback`, { dispatchKey: key, source: 'pr-human-feedback', pr, branch: pr.branch, project: projMeta });
2078
- if (item) { newWork.push(item); fixDispatched = true; }
2038
+ if (item) { newWork.push(item); setCooldown(key); fixDispatched = true; }
2079
2039
  }
2080
2040
 
2081
2041
  // PRs with build failures — route to author (has session context from implementing)
@@ -2124,7 +2084,7 @@ async function discoverFromPrs(config, project) {
2124
2084
  review_note: reviewNote,
2125
2085
  }, `Fix build failure on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
2126
2086
  if (item) {
2127
- newWork.push(item);
2087
+ newWork.push(item); setCooldown(key);
2128
2088
  // Increment build fix attempts counter
2129
2089
  try {
2130
2090
  const prPath = projectPrPath(project);
@@ -2181,6 +2141,7 @@ async function discoverFromPrs(config, project) {
2181
2141
  }, `Fix merge conflicts on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
2182
2142
  if (item) {
2183
2143
  newWork.push(item);
2144
+ setCooldown(key);
2184
2145
  // Record dispatch timestamp so re-dispatch is suppressed during ADO lag window
2185
2146
  try {
2186
2147
  mutatePullRequests(projectPrPath(project), prs => {
@@ -3166,6 +3127,24 @@ async function tickInner() {
3166
3127
  safe('runCleanup', () => runCleanup(config));
3167
3128
  }
3168
3129
 
3130
+ // 2.55. Check persistent watches (every 3 ticks = ~3 minutes)
3131
+ if (tickCount % 3 === 0) {
3132
+ safe('checkWatches', () => {
3133
+ const { checkWatches } = require('./engine/watches');
3134
+ const pullRequests = PROJECTS.flatMap(p => {
3135
+ const prPath = path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json');
3136
+ return safeJson(prPath) || [];
3137
+ });
3138
+ const workItems = PROJECTS.flatMap(p => {
3139
+ const wiPath = path.join(MINIONS_DIR, 'projects', p.name, 'work-items.json');
3140
+ return safeJson(wiPath) || [];
3141
+ });
3142
+ // Also include central work items
3143
+ const centralWi = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
3144
+ checkWatches(config, { pullRequests, workItems: [...workItems, ...centralWi] });
3145
+ });
3146
+ }
3147
+
3169
3148
  const adoPollEnabled = config.engine?.adoPollEnabled ?? DEFAULTS.adoPollEnabled;
3170
3149
  const ghPollEnabled = config.engine?.ghPollEnabled ?? DEFAULTS.ghPollEnabled;
3171
3150
  const adoPollStatusEvery = Math.max(1, Number(config.engine?.adoPollStatusEvery) || DEFAULTS.adoPollStatusEvery);
@@ -3372,7 +3351,7 @@ async function tickInner() {
3372
3351
  if (busyAgents.has(item.agent)) {
3373
3352
  // Agent busy reassignment: if item has been waiting on a busy agent past the threshold,
3374
3353
  // try to find an alternative agent via routing. Skip explicitly assigned items.
3375
- const reassignMs = config.engine?.agentBusyReassignMs ?? ENGINE_DEFAULTS.agentBusyReassignMs;
3354
+ const reassignMs = config.engine?.agentBusyReassignMs ?? DEFAULTS.agentBusyReassignMs;
3376
3355
  const isExplicitReassign = !!item.meta?.item?.agent;
3377
3356
  if (!isExplicitReassign && reassignMs > 0 && item._agentBusySince) {
3378
3357
  const busySinceMs = new Date(item._agentBusySince).getTime();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.976",
3
+ "version": "0.1.978",
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"
@@ -82,6 +82,9 @@ Additional actions (all take `id` or `file` as primary key):
82
82
  - Meetings: add-meeting-note (id, note), advance-meeting (id), end-meeting (id), archive-meeting (id), unarchive-meeting (id), delete-meeting (id)
83
83
  - 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)
84
84
  - PRD ops: edit-prd-item, remove-prd-item, reopen-prd-item (id, file — re-dispatches on existing branch)
85
+ - **create-watch**: target, targetType (pr/work-item/branch), condition (merged/build-fail/build-pass/completed/failed/status-change/any), 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)
86
+ 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"}`
87
+ 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"}`
85
88
  - KB/Inbox: promote-to-kb (file, category), kb-sweep, toggle-kb-pin (key)
86
89
  - Plan lifecycle: revise-plan (file, feedback — dispatches agent to revise)
87
90
  - Pipeline: continue-pipeline (id — resume past wait stage)