@yemi33/minions 0.1.976 → 0.1.977

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.977 (2026-04-14)
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,14 @@
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
+ - defer review setCooldown to post-gating in discoverWork
28
29
  - move review verdict check before updateWorkItemStatus(DONE)
30
+ - address review feedback — move writeToInbox outside lock, add absolute condition auto-expire
31
+ - fix watches feature gaps — human notifications, branch stub, status-change init, unique keys
29
32
  - skip isAlreadyDispatched in needsReReview to allow re-review within 1hr
33
+ - skip SessionStart hook settings test on CI
30
34
  - loop queue flush so messages queued during combined send aren't orphaned
31
35
  - set lastPushedAt on fix completion to unblock re-review without poller
32
36
  - restore ADO throttle test functions deleted during merge conflict resolution
@@ -41,13 +45,11 @@
41
45
  - fix CC queued message 'already processing' and thinking UX stacking
42
46
  - enforce --timeout 1 on all azureauth calls to prevent agent orphans (#1063)
43
47
  - 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
@@ -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');
package/engine/shared.js CHANGED
@@ -613,6 +613,13 @@ 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
+ // Absolute conditions check point-in-time state, not transitions — auto-expire after first trigger when stopAfter is 0
621
+ const WATCH_ABSOLUTE_CONDITIONS = new Set([WATCH_CONDITION.MERGED, WATCH_CONDITION.BUILD_FAIL, WATCH_CONDITION.BUILD_PASS, WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED]);
622
+
616
623
  /** Update per-agent review metrics (prsApproved/prsRejected). Only writes for configured agents. */
617
624
  function trackReviewMetric(pr, newReviewStatus, config) {
618
625
  if (newReviewStatus !== 'approved' && newReviewStatus !== 'changes-requested') return;
@@ -1138,6 +1145,7 @@ module.exports = {
1138
1145
  classifyInboxItem,
1139
1146
  ENGINE_DEFAULTS,
1140
1147
  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,
1148
+ WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS,
1141
1149
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
1142
1150
  FAILURE_CLASS, ESCALATION_POLICY, COMPLETION_FIELDS,
1143
1151
  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
@@ -2021,7 +2021,7 @@ async function discoverFromPrs(config, project) {
2021
2021
  pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: pr.branch || '',
2022
2022
  pr_author: pr.agent || '', pr_url: pr.url || '',
2023
2023
  }, `Review ${pr.id}: ${pr.title}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
2024
- if (item) { newWork.push(item); setCooldown(key); }
2024
+ if (item) { newWork.push(item); }
2025
2025
  }
2026
2026
 
2027
2027
  // PRs with changes requested → route back to author for fix
@@ -3166,6 +3166,24 @@ async function tickInner() {
3166
3166
  safe('runCleanup', () => runCleanup(config));
3167
3167
  }
3168
3168
 
3169
+ // 2.55. Check persistent watches (every 3 ticks = ~3 minutes)
3170
+ if (tickCount % 3 === 0) {
3171
+ safe('checkWatches', () => {
3172
+ const { checkWatches } = require('./engine/watches');
3173
+ const pullRequests = PROJECTS.flatMap(p => {
3174
+ const prPath = path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json');
3175
+ return safeJson(prPath) || [];
3176
+ });
3177
+ const workItems = PROJECTS.flatMap(p => {
3178
+ const wiPath = path.join(MINIONS_DIR, 'projects', p.name, 'work-items.json');
3179
+ return safeJson(wiPath) || [];
3180
+ });
3181
+ // Also include central work items
3182
+ const centralWi = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
3183
+ checkWatches(config, { pullRequests, workItems: [...workItems, ...centralWi] });
3184
+ });
3185
+ }
3186
+
3169
3187
  const adoPollEnabled = config.engine?.adoPollEnabled ?? DEFAULTS.adoPollEnabled;
3170
3188
  const ghPollEnabled = config.engine?.ghPollEnabled ?? DEFAULTS.ghPollEnabled;
3171
3189
  const adoPollStatusEvery = Math.max(1, Number(config.engine?.adoPollStatusEvery) || DEFAULTS.adoPollStatusEvery);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.976",
3
+ "version": "0.1.977",
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)