@yemi33/minions 0.1.1857 → 0.1.1859

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,9 +1,23 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1857 (2026-05-10)
3
+ ## 0.1.1859 (2026-05-10)
4
+
5
+ ### Features
6
+ - support follow-up actions on watch trigger (#2323)
7
+ - persist KB sweep state + remove frontend timeout (#2319)
8
+
9
+ ### Other
10
+ - ui(toast): auto-route generic cmd-toast calls to the active page's inline slot
11
+ - ui(prs): show PR link/unlink feedback inline under the section header
12
+
13
+ ## 0.1.1858 (2026-05-10)
14
+
15
+ ### Other
16
+ - ui(meetings,kb): make plan-from-meeting and KB sweep optimistically immediate
17
+
18
+ ## 0.1.1856 (2026-05-10)
4
19
 
5
20
  ### Other
6
- - ci: re-run PR Tests when latest result is older than 3h (#2331)
7
21
  - ui(toast): add inline toast variant + scope KB sweep / meeting plan feedback locally
8
22
 
9
23
  ## 0.1.1855 (2026-05-10)
@@ -12,7 +12,14 @@ function cmdUpdateProjectList(projects) {
12
12
  }
13
13
 
14
14
  function showToast(id, msg, ok, durationMs) {
15
- const el = document.getElementById(id);
15
+ let el = document.getElementById(id);
16
+ // Auto-route the generic 'cmd-toast' id to the active page's inline slot
17
+ // (a .page-toast injected by dashboard.js). Falls back to the floating
18
+ // top-level #cmd-toast when no page is active (modals, etc.).
19
+ if (id === 'cmd-toast') {
20
+ const pageInline = document.querySelector('.page.active .page-toast');
21
+ if (pageInline) el = pageInline;
22
+ }
16
23
  if (!el) { console.warn('[showToast] no element with id', id, '— message:', msg); return; }
17
24
  el.classList.add('cmd-toast');
18
25
  el.classList.remove('success', 'error');
@@ -20,6 +20,16 @@ let _kbPage = 0;
20
20
  function _kbPrev() { if (_kbPage > 0) { _kbPage--; renderKnowledgeBase(); } }
21
21
  function _kbNext() { _kbPage++; renderKnowledgeBase(); }
22
22
 
23
+ function _formatSweepElapsed(startedAt) {
24
+ if (!startedAt) return '';
25
+ const elapsedMs = Date.now() - Number(startedAt);
26
+ if (!isFinite(elapsedMs) || elapsedMs < 0) return '';
27
+ const mins = Math.floor(elapsedMs / 60000);
28
+ if (mins < 1) return Math.max(1, Math.floor(elapsedMs / 1000)) + 's';
29
+ if (mins < 60) return mins + 'm';
30
+ return Math.floor(mins / 60) + 'h ' + (mins % 60) + 'm';
31
+ }
32
+
23
33
  async function refreshKnowledgeBase() {
24
34
  try {
25
35
  _kbData = await fetch('/api/knowledge').then(r => r.json());
@@ -60,7 +70,16 @@ function renderKnowledgeBase() {
60
70
  countEl.textContent = allItems.length;
61
71
 
62
72
  const sweptEl = document.getElementById('kb-swept-time');
63
- if (sweptEl) sweptEl.textContent = _kbData.lastSwept ? 'swept ' + timeSinceStr(new Date(_kbData.lastSwept)) : '';
73
+ if (sweptEl) {
74
+ let sweptText = _kbData.lastSwept ? 'swept ' + timeSinceStr(new Date(_kbData.lastSwept)) : '';
75
+ if (_kbData.sweepInFlight) {
76
+ const dur = _formatSweepElapsed(_kbData.sweepStartedAt);
77
+ sweptText = sweptText
78
+ ? sweptText + ' \u00b7 now sweeping' + (dur ? ' (' + dur + ')' : '')
79
+ : 'now sweeping' + (dur ? ' (' + dur + ')' : '');
80
+ }
81
+ sweptEl.textContent = sweptText;
82
+ }
64
83
 
65
84
  if (allItems.length === 0) {
66
85
  tabsEl.innerHTML = '';
@@ -145,6 +164,8 @@ function kbSetTab(tab) {
145
164
  }
146
165
 
147
166
  async function kbSweep() {
167
+ // Optimistic: instant feedback before any network round-trip.
168
+ showToast('kb-sweep-toast', 'KB sweep queued — runs in the background, this can take many minutes for a large KB', true, 8000);
148
169
  try {
149
170
  const res = await fetch('/api/knowledge/sweep', {
150
171
  method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -155,7 +176,10 @@ async function kbSweep() {
155
176
  showToast('kb-sweep-toast', 'Sweep failed: ' + (data.error || 'unknown'), false);
156
177
  return;
157
178
  }
158
- showToast('kb-sweep-toast', data.alreadyRunning ? 'KB sweep already running' : 'KB sweep queued', true);
179
+ if (data.alreadyRunning) {
180
+ const dur = _formatSweepElapsed(data.startedAt);
181
+ showToast('kb-sweep-toast', 'KB sweep already running' + (dur ? ' (' + dur + ' elapsed)' : '') + ' — let it finish first', true, 8000);
182
+ }
159
183
  } catch (e) {
160
184
  showToast('kb-sweep-toast', 'Sweep error: ' + e.message, false);
161
185
  }
@@ -420,17 +420,14 @@ function _findLinkedPlan(meeting) {
420
420
  }
421
421
 
422
422
  async function _createPlanFromMeeting(id, btn) {
423
+ // Optimistic: instant feedback before fetching the meeting + posting the WI.
424
+ showToast('meeting-plan-toast', 'Plan task queued — agent will draft the plan from this meeting', true);
423
425
  try {
424
426
  const res = await fetch('/api/meetings/' + encodeURIComponent(id));
425
427
  const data = await res.json();
426
428
  if (!data.meeting) { showToast('meeting-plan-toast', 'Meeting not found', false); return; }
427
429
  const m = data.meeting;
428
430
 
429
- const existing = _findLinkedPlan(m);
430
- if (existing) {
431
- if (!confirm('A plan already exists: "' + existing.summary + '"\n\nQueue another plan task anyway?')) return;
432
- }
433
-
434
431
  const transcript = (m.transcript || []).map(function(t) {
435
432
  return '### ' + (t.agent || 'agent') + ' (' + (t.type || '') + ', Round ' + (t.round || '?') + ')\n\n' + (t.content || '');
436
433
  }).join('\n\n---\n\n');
@@ -461,7 +458,9 @@ async function _createPlanFromMeeting(id, btn) {
461
458
  showToast('meeting-plan-toast', 'Failed to queue plan task: ' + (planData.error || 'unknown'), false);
462
459
  return;
463
460
  }
464
- showToast('meeting-plan-toast', 'Plan task queued' + (planData.id ? ' (' + planData.id + ')' : ''), true);
461
+ if (planData.id) {
462
+ showToast('meeting-plan-toast', 'Plan task queued (' + planData.id + ')', true);
463
+ }
465
464
  if (typeof wakeEngine === 'function') wakeEngine();
466
465
  if (typeof refresh === 'function') refresh();
467
466
  } catch (e) {
@@ -158,7 +158,7 @@ async function _submitLinkPr(e) {
158
158
  });
159
159
  const data = await res.json();
160
160
  if (res.ok) {
161
- showToast('cmd-toast', (data.message || 'PR linked') + (autoObserve ? ' (auto-observe on)' : ''), true, 6000);
161
+ showToast('pr-toast', (data.message || 'PR linked') + (autoObserve ? ' (auto-observe on)' : ''), true, 6000);
162
162
  refresh();
163
163
  } else { alert('Failed: ' + (data.error || 'unknown')); openAddPrModal(); }
164
164
  } catch (e) { alert('Error: ' + e.message); openAddPrModal(); }
@@ -166,7 +166,7 @@ async function _submitLinkPr(e) {
166
166
 
167
167
  async function unlinkPr(id) {
168
168
  if (!confirm('Remove ' + id + ' from tracking?')) return;
169
- showToast('cmd-toast', id + ' removed', true);
169
+ showToast('pr-toast', id + ' removed', true);
170
170
  markDeleted('pr:' + id);
171
171
  const escId = window.CSS && CSS.escape ? CSS.escape(id) : id;
172
172
  const row = document.querySelector('[data-pr-id="' + escId + '"]')?.closest('tr');
@@ -176,8 +176,8 @@ async function unlinkPr(id) {
176
176
  method: 'POST', headers: { 'Content-Type': 'application/json' },
177
177
  body: JSON.stringify({ id })
178
178
  });
179
- if (!res.ok) { clearDeleted('pr:' + id); const d = await res.json().catch(() => ({})); showToast('cmd-toast', 'Failed: ' + (d.error || 'unknown'), false); refresh(); return; }
180
- } catch (e) { clearDeleted('pr:' + id); showToast('cmd-toast', 'Error: ' + e.message, false); refresh(); }
179
+ if (!res.ok) { clearDeleted('pr:' + id); const d = await res.json().catch(() => ({})); showToast('pr-toast', 'Failed: ' + (d.error || 'unknown'), false); refresh(); return; }
180
+ } catch (e) { clearDeleted('pr:' + id); showToast('pr-toast', 'Error: ' + e.message, false); refresh(); }
181
181
  }
182
182
 
183
183
  window.MinionsPrs = { prRow, prTableHtml, renderPrs, prPrev, prNext, openAllPrs, openModal, openAddPrModal, unlinkPr };
@@ -14,6 +14,11 @@ const _WATCH_STATUS_BADGES = {
14
14
  // { value, label, conditions: [...], description }
15
15
  let _watchTargetTypesCache = null;
16
16
 
17
+ // Cache of action types fetched from /api/watches/action-types. Populated on
18
+ // first modal open. Each entry:
19
+ // { value, label, description, params: [{ key, required, description }] }
20
+ let _watchActionTypesCache = null;
21
+
17
22
  // Built-in fallback labels — used when the API response hasn't loaded yet, or
18
23
  // for legacy targetTypes/conditions that may appear on stored watches but not
19
24
  // in the live registry. The registry from the API is authoritative when present.
@@ -113,7 +118,7 @@ function renderWatches(watchesData) {
113
118
  var pageItems = watches.slice(start, start + WATCH_PER_PAGE);
114
119
 
115
120
  var html = '<div class="pr-table-wrap"><table class="pr-table"><thead><tr>' +
116
- '<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>' +
121
+ '<th>ID</th><th>Target</th><th>Type</th><th>Condition</th><th>Action</th><th>Interval</th><th>Owner</th><th>Status</th><th>Triggers</th><th>Last Checked</th><th></th>' +
117
122
  '</tr></thead><tbody>';
118
123
 
119
124
  for (var i = 0; i < pageItems.length; i++) {
@@ -121,6 +126,7 @@ function renderWatches(watchesData) {
121
126
  var statusBadge = _WATCH_STATUS_BADGES[w.status] || escHtml(w.status || 'unknown');
122
127
  var targetLabel = escHtml(_targetTypeLabel(w.targetType));
123
128
  var condLabel = escHtml(_conditionLabel(w.condition));
129
+ var actionLabel = (w.action && w.action.type) ? escHtml(w.action.type) : '<span style="color:var(--muted)">notify</span>';
124
130
  var lastChecked = w.last_checked ? timeAgo(w.last_checked) : 'never';
125
131
  var lastTriggered = w.last_triggered ? timeAgo(w.last_triggered) : 'never';
126
132
  var triggerInfo = (w.triggerCount || 0) + (w.stopAfter > 0 ? '/' + w.stopAfter : '');
@@ -130,6 +136,7 @@ function renderWatches(watchesData) {
130
136
  '<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escHtml(w.description || w.target) + '">' + escHtml(w.target) + '</td>' +
131
137
  '<td><span class="dispatch-type explore">' + escHtml(targetLabel) + '</span></td>' +
132
138
  '<td><span style="font-size:11px;color:var(--blue)">' + escHtml(condLabel) + '</span></td>' +
139
+ '<td><span style="font-size:10px">' + actionLabel + '</span></td>' +
133
140
  '<td><span style="font-size:10px;color:var(--muted)">' + escHtml(_intervalToHuman(w.interval)) + '</span></td>' +
134
141
  '<td><span class="pr-agent">' + escHtml(w.owner || 'human') + '</span></td>' +
135
142
  '<td>' + statusBadge + '</td>' +
@@ -197,6 +204,10 @@ function openWatchDetail(id) {
197
204
  '<div><strong style="color:var(--muted)">Notify:</strong> ' + escHtml(w.notify || 'inbox') + '</div>' +
198
205
  '<div><strong style="color:var(--muted)">Triggers:</strong> ' + (w.triggerCount || 0) + (w.stopAfter > 0 ? ' / ' + w.stopAfter + ' (expires after)' : ' (runs forever)') + '</div>' +
199
206
  (w.onNotMet ? '<div><strong style="color:var(--muted)">On Each Poll (not met):</strong> ' + escHtml(w.onNotMet) + '</div>' : '') +
207
+ (w.action && w.action.type ? '<div><strong style="color:var(--muted)">Follow-up Action:</strong> <span class="dispatch-type explore">' + escHtml(w.action.type) + '</span>' +
208
+ (w.action.params && Object.keys(w.action.params).length ? '<div style="margin-top:4px;padding:8px;background:var(--surface2);border-radius:4px;font-size:11px;font-family:monospace;white-space:pre-wrap">' + escHtml(JSON.stringify(w.action.params, null, 2)) + '</div>' : '') +
209
+ '</div>' : '') +
210
+ (w._lastActionResult ? '<div><strong style="color:var(--muted)">Last Action Result:</strong> <span style="color:' + (w._lastActionResult.ok ? 'var(--green)' : 'var(--red)') + '">' + (w._lastActionResult.ok ? 'OK' : 'FAILED') + '</span> — ' + escHtml(w._lastActionResult.summary || '') + (w._lastActionResult.dispatchedItemId ? ' (dispatched: ' + escHtml(w._lastActionResult.dispatchedItemId) + ')' : '') + '</div>' : '') +
200
211
  '<div><strong style="color:var(--muted)">Created:</strong> ' + escHtml(createdAt) + '</div>' +
201
212
  '<div><strong style="color:var(--muted)">Last Checked:</strong> ' + escHtml(lastChecked) + '</div>' +
202
213
  '<div><strong style="color:var(--muted)">Last Triggered:</strong> ' + escHtml(lastTriggered) + '</div>' +
@@ -279,6 +290,14 @@ function _watchFormHtml() {
279
290
  var agentOpts = '<option value="">human</option>' + (cmdAgents || []).map(function(a) { return '<option value="' + escHtml(a.id) + '">' + escHtml(a.name) + '</option>'; }).join('');
280
291
  var projOpts = '<option value="">Any</option>' + (cmdProjects || []).map(function(p) { return '<option value="' + escHtml(p.name) + '">' + escHtml(p.name) + '</option>'; }).join('');
281
292
 
293
+ // Action picker: empty value = no follow-up action (legacy notify-only behavior).
294
+ var actionOpts = '<option value="">None — notify only</option>';
295
+ if (_watchActionTypesCache && _watchActionTypesCache.length) {
296
+ actionOpts += _watchActionTypesCache.map(function(a) {
297
+ return '<option value="' + escHtml(a.value) + '" title="' + escHtml(a.description || '') + '">' + escHtml(a.label) + '</option>';
298
+ }).join('');
299
+ }
300
+
282
301
  return '<div style="display:flex;flex-direction:column;gap:12px;font-family:inherit">' +
283
302
  '<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>' +
284
303
  '<label style="color:var(--text);font-size:var(--text-md)">Target (e.g. PR number, work item ID, meeting/plan/schedule/pipeline/dispatch/agent ID)<input id="watch-edit-target" placeholder="e.g. 1057, W-abc123, M-xyz, schedule-42" style="' + inputStyle + '"></label>' +
@@ -290,9 +309,27 @@ function _watchFormHtml() {
290
309
  '<label style="color:var(--text);font-size:var(--text-md)">Description<input id="watch-edit-desc" placeholder="Optional description" style="' + inputStyle + '"></label>' +
291
310
  '<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>' +
292
311
  '<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>' +
312
+ '<label style="color:var(--text);font-size:var(--text-md)">Follow-up Action <span style="font-size:10px;color:var(--muted)">(runs after the inbox notification when the watch fires)</span><select id="watch-edit-action-type" style="' + inputStyle + '" onchange="_updateWatchActionParamsHint()">' + actionOpts + '</select></label>' +
313
+ '<label style="color:var(--text);font-size:var(--text-md)" id="watch-edit-action-params-label" style="display:none">Action Params (JSON) <span style="font-size:10px;color:var(--muted)" id="watch-edit-action-params-hint"></span><textarea id="watch-edit-action-params" rows="4" placeholder=\'{"title": "Re-check {{target}} after merge", "type": "verify"}\' style="' + inputStyle + ';font-family:monospace"></textarea></label>' +
293
314
  '</div>';
294
315
  }
295
316
 
317
+ // Refresh the action params textarea hint based on the selected action type.
318
+ function _updateWatchActionParamsHint() {
319
+ var sel = document.getElementById('watch-edit-action-type');
320
+ var label = document.getElementById('watch-edit-action-params-label');
321
+ var hint = document.getElementById('watch-edit-action-params-hint');
322
+ if (!sel || !label || !hint) return;
323
+ if (!sel.value) { label.style.display = 'none'; return; }
324
+ label.style.display = '';
325
+ var entry = (_watchActionTypesCache || []).find(function(a) { return a.value === sel.value; });
326
+ if (!entry) { hint.textContent = ''; return; }
327
+ var paramHints = (entry.params || []).map(function(p) {
328
+ return p.key + (p.required ? ' (required)' : '?');
329
+ }).join(', ');
330
+ hint.textContent = paramHints ? '— ' + paramHints : '';
331
+ }
332
+
296
333
  // Refresh the condition <select> based on the currently selected target type.
297
334
  function _updateWatchConditionOptions() {
298
335
  var ttSel = document.getElementById('watch-edit-target-type');
@@ -319,16 +356,27 @@ function openCreateWatchModal() {
319
356
  _renderCreateWatchModal();
320
357
  // Fetch the live registry and re-render so newly registered target types
321
358
  // appear without requiring a page reload.
322
- fetch('/api/watches/target-types').then(function(res) { return res.json(); }).then(function(data) {
323
- if (data && Array.isArray(data.targetTypes) && data.targetTypes.length > 0) {
324
- _watchTargetTypesCache = data.targetTypes;
325
- // Only re-render if the modal is still showing the create-watch form
359
+ Promise.all([
360
+ fetch('/api/watches/target-types').then(function(res) { return res.json(); }).catch(function() { return null; }),
361
+ fetch('/api/watches/action-types').then(function(res) { return res.json(); }).catch(function() { return null; }),
362
+ ]).then(function(results) {
363
+ var ttData = results[0], atData = results[1];
364
+ var changed = false;
365
+ if (ttData && Array.isArray(ttData.targetTypes) && ttData.targetTypes.length > 0) {
366
+ _watchTargetTypesCache = ttData.targetTypes;
367
+ changed = true;
368
+ }
369
+ if (atData && Array.isArray(atData.actionTypes) && atData.actionTypes.length > 0) {
370
+ _watchActionTypesCache = atData.actionTypes;
371
+ changed = true;
372
+ }
373
+ if (changed) {
326
374
  var titleEl = document.getElementById('modal-title');
327
375
  if (titleEl && titleEl.textContent.indexOf('Create Watch') === 0) {
328
376
  _renderCreateWatchModal();
329
377
  }
330
378
  }
331
- }).catch(function() { /* fall back to baked-in defaults */ });
379
+ });
332
380
  }
333
381
 
334
382
  function submitWatch() {
@@ -341,11 +389,25 @@ function submitWatch() {
341
389
  var description = (document.getElementById('watch-edit-desc') || {}).value || '';
342
390
  var stopAfter = parseInt((document.getElementById('watch-edit-stop-after') || {}).value, 10) || 0;
343
391
  var onNotMet = (document.getElementById('watch-edit-on-not-met') || {}).value || '';
344
-
345
- if (!target.trim()) {
346
- var errEl = document.getElementById('watch-form-error');
347
- if (errEl) { errEl.textContent = 'Target is required'; errEl.style.display = 'block'; }
348
- return;
392
+ var actionType = (document.getElementById('watch-edit-action-type') || {}).value || '';
393
+ var actionParamsRaw = (document.getElementById('watch-edit-action-params') || {}).value || '';
394
+
395
+ var errEl = document.getElementById('watch-form-error');
396
+ function showErr(msg) { if (errEl) { errEl.textContent = msg; errEl.style.display = 'block'; } }
397
+
398
+ if (!target.trim()) { showErr('Target is required'); return; }
399
+
400
+ var action = null;
401
+ if (actionType) {
402
+ var params = {};
403
+ if (actionParamsRaw.trim()) {
404
+ try { params = JSON.parse(actionParamsRaw); }
405
+ catch (e) { showErr('Action Params must be valid JSON: ' + e.message); return; }
406
+ if (params === null || typeof params !== 'object' || Array.isArray(params)) {
407
+ showErr('Action Params must be a JSON object'); return;
408
+ }
409
+ }
410
+ action = { type: actionType, params: params };
349
411
  }
350
412
 
351
413
  showToast('cmd-toast', 'Creating watch...', true);
@@ -363,6 +425,7 @@ function submitWatch() {
363
425
  notify: 'inbox',
364
426
  stopAfter: stopAfter,
365
427
  onNotMet: onNotMet || null,
428
+ action: action,
366
429
  })
367
430
  }).then(async function(res) {
368
431
  var data = await res.json().catch(function() { return {}; });
@@ -3,5 +3,6 @@
3
3
  <button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="openAddPrModal()">+ Link PR</button>
4
4
  <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">created by agents, tracked with review + build status</span>
5
5
  </h2>
6
+ <div id="pr-toast" class="cmd-toast cmd-toast-inline" style="margin:6px 0"></div>
6
7
  <div id="pr-content"><p class="pr-empty">No pull requests yet.</p></div>
7
8
  </section>
package/dashboard.js CHANGED
@@ -833,13 +833,16 @@ function buildDashboardHtml() {
833
833
  const layout = safeRead(layoutPath);
834
834
  const css = safeRead(path.join(dashDir, 'styles.css'));
835
835
 
836
- // Assemble page fragments
836
+ // Assemble page fragments. Each wrapper gets a `.page-toast` inline slot at
837
+ // the top — showToast('cmd-toast', …) auto-routes here when a page is active,
838
+ // so feedback lands near the action instead of the floating top-right toast.
837
839
  const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'engine'];
840
+ const pageToast = ' <div class="cmd-toast cmd-toast-inline page-toast" style="margin:6px 0"></div>\n';
838
841
  let pageHtml = '';
839
842
  for (const p of pages) {
840
843
  const content = safeRead(path.join(dashDir, 'pages', p + '.html'));
841
844
  const activeClass = p === 'home' ? ' active' : '';
842
- pageHtml += ` <div class="page${activeClass}" id="page-${p}">\n${content}\n </div>\n\n`;
845
+ pageHtml += ` <div class="page${activeClass}" id="page-${p}">\n${pageToast}${content}\n </div>\n\n`;
843
846
  }
844
847
 
845
848
  // Assemble JS modules (order matters: utils → state → renderers → commands → refresh)
@@ -4221,6 +4224,16 @@ const server = http.createServer(async (req, res) => {
4221
4224
  }
4222
4225
  const swept = safeJson(path.join(ENGINE_DIR, 'kb-swept.json'));
4223
4226
  if (swept) result.lastSwept = swept.timestamp;
4227
+ // Surface in-flight sweep state so the UI can render a 'now sweeping (Xm)'
4228
+ // badge alongside the previous-completion 'swept N days ago' indicator.
4229
+ // Memory wins when present, disk fallback survives dashboard restarts.
4230
+ const sweepState = safeJson(path.join(ENGINE_DIR, 'kb-sweep-state.json'));
4231
+ const memInFlight = !!global._kbSweepInFlight;
4232
+ const diskInFlight = !!(sweepState && sweepState.status === 'in-flight');
4233
+ if (memInFlight || diskInFlight) {
4234
+ result.sweepInFlight = true;
4235
+ result.sweepStartedAt = global._kbSweepStartedAt || (sweepState && sweepState.startedAt) || null;
4236
+ }
4224
4237
  return jsonReply(res, 200, result);
4225
4238
  }
4226
4239
 
@@ -4247,8 +4260,23 @@ const server = http.createServer(async (req, res) => {
4247
4260
  console.log(`[kb-sweep] Auto-releasing stale guard (>${Math.round(guardMs / 60000)}min for ${entryCount} entries)`);
4248
4261
  global._kbSweepInFlight = false;
4249
4262
  }
4250
- if (global._kbSweepInFlight) {
4251
- return jsonReply(res, 200, { ok: true, alreadyRunning: true, startedAt: global._kbSweepStartedAt });
4263
+ // Disk-state fallback: if a previous dashboard process died mid-sweep, the
4264
+ // state file says 'in-flight' forever. Treat it as stale past the guard so
4265
+ // a new sweep can start.
4266
+ const sweepStateFile = path.join(ENGINE_DIR, 'kb-sweep-state.json');
4267
+ const diskState = safeJson(sweepStateFile);
4268
+ const diskInFlight = !!(diskState && diskState.status === 'in-flight');
4269
+ const diskStartedAt = diskState && diskState.startedAt ? Number(diskState.startedAt) : 0;
4270
+ const diskStale = diskInFlight && diskStartedAt && Date.now() - diskStartedAt > guardMs;
4271
+ if (diskStale) {
4272
+ console.log(`[kb-sweep] Auto-releasing stale disk-state guard (>${Math.round(guardMs / 60000)}min)`);
4273
+ try { shared.safeUnlink(sweepStateFile); } catch { /* ignore */ }
4274
+ }
4275
+ if (global._kbSweepInFlight || (diskInFlight && !diskStale)) {
4276
+ return jsonReply(res, 200, {
4277
+ ok: true, alreadyRunning: true,
4278
+ startedAt: global._kbSweepStartedAt || diskStartedAt || null,
4279
+ });
4252
4280
  }
4253
4281
  const sweepToken = Date.now() + Math.random();
4254
4282
  global._kbSweepToken = sweepToken;
@@ -4274,12 +4302,25 @@ const server = http.createServer(async (req, res) => {
4274
4302
 
4275
4303
 
4276
4304
  function handleKnowledgeSweepStatus(req, res) {
4277
- return jsonReply(res, 200, {
4278
- inFlight: !!global._kbSweepInFlight,
4279
- startedAt: global._kbSweepStartedAt || null,
4280
- lastResult: global._kbSweepLastResult || null,
4281
- lastCompletedAt: global._kbSweepLastCompletedAt || null
4282
- });
4305
+ // Disk-state fallback: when the dashboard restarts mid-sweep the in-memory
4306
+ // globals get reset, but engine/kb-sweep-state.json survives. Memory still
4307
+ // wins when present (faster, no disk read on every poll).
4308
+ const diskState = safeJson(path.join(ENGINE_DIR, 'kb-sweep-state.json'));
4309
+ const memInFlight = !!global._kbSweepInFlight;
4310
+ const diskInFlight = !!(diskState && diskState.status === 'in-flight');
4311
+ const inFlight = memInFlight || diskInFlight;
4312
+ const startedAt = global._kbSweepStartedAt || (diskState && diskState.startedAt) || null;
4313
+ let lastResult = global._kbSweepLastResult || null;
4314
+ let lastCompletedAt = global._kbSweepLastCompletedAt || null;
4315
+ if (!lastResult && diskState && (diskState.status === 'completed' || diskState.status === 'failed')) {
4316
+ if (diskState.status === 'failed') {
4317
+ lastResult = { ok: false, error: diskState.error || 'sweep failed' };
4318
+ } else {
4319
+ lastResult = diskState.lastResult || { ok: true, summary: diskState.summary };
4320
+ }
4321
+ if (!lastCompletedAt && diskState.completedAt) lastCompletedAt = diskState.completedAt;
4322
+ }
4323
+ return jsonReply(res, 200, { inFlight, startedAt, lastResult, lastCompletedAt });
4283
4324
  }
4284
4325
 
4285
4326
  async function handlePlansList(req, res) {
@@ -6305,12 +6346,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6305
6346
  return jsonReply(res, 200, { targetTypes: watchesMod.listTargetTypes() });
6306
6347
  }
6307
6348
 
6349
+ async function handleWatchesActionTypes(req, res) {
6350
+ return jsonReply(res, 200, { actionTypes: watchesMod.listActionTypes() });
6351
+ }
6352
+
6308
6353
  async function handleWatchesCreate(req, res) {
6309
6354
  const body = await readBody(req);
6310
- const { target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet } = body;
6355
+ const { target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet, action } = body;
6311
6356
  if (!target) return jsonReply(res, 400, { error: 'target is required' });
6312
6357
  try {
6313
- const watch = watchesMod.createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet });
6358
+ const watch = watchesMod.createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet, action });
6314
6359
  invalidateStatusCache();
6315
6360
  recordCcTurnIfPresent(req, {
6316
6361
  kind: 'watch', id: watch?.id || null, title: `Watch ${target}` + (condition ? ` (${condition})` : ''), project: project || null,
@@ -7318,8 +7363,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7318
7363
  // Watches
7319
7364
  { method: 'GET', path: '/api/watches', desc: 'List all watches', handler: handleWatchesList },
7320
7365
  { method: 'GET', path: '/api/watches/target-types', desc: 'List registered watch target types and their valid conditions', handler: handleWatchesTargetTypes },
7321
- { method: 'POST', path: '/api/watches', desc: 'Create a new watch', params: 'target, targetType, condition, interval?, owner?, description?, project?, notify?, stopAfter?, onNotMet?', handler: handleWatchesCreate },
7322
- { method: 'POST', path: '/api/watches/update', desc: 'Update a watch (pause/resume/modify)', params: 'id, status?, interval?, description?, notify?, stopAfter?, onNotMet?, condition?', handler: handleWatchesUpdate },
7366
+ { method: 'GET', path: '/api/watches/action-types', desc: 'List registered follow-up action types (notify, dispatch-work-item, webhook, ...)', handler: handleWatchesActionTypes },
7367
+ { method: 'POST', path: '/api/watches', desc: 'Create a new watch', params: 'target, targetType, condition, interval?, owner?, description?, project?, notify?, stopAfter?, onNotMet?, action?', handler: handleWatchesCreate },
7368
+ { method: 'POST', path: '/api/watches/update', desc: 'Update a watch (pause/resume/modify)', params: 'id, status?, interval?, description?, notify?, stopAfter?, onNotMet?, condition?, action?', handler: handleWatchesUpdate },
7323
7369
  { method: 'POST', path: '/api/watches/delete', desc: 'Delete a watch', params: 'id', handler: handleWatchesDelete },
7324
7370
 
7325
7371
  // Pipelines
@@ -19,6 +19,7 @@ const { MINIONS_DIR, ENGINE_DIR } = queries;
19
19
 
20
20
  const KB_DIR = path.join(MINIONS_DIR, 'knowledge');
21
21
  const SWEPT_DIR = path.join(KB_DIR, '_swept');
22
+ const KB_SWEEP_STATE_PATH = path.join(ENGINE_DIR, 'kb-sweep-state.json');
22
23
  const SWEPT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
23
24
  const COMPRESS_THRESHOLD_BYTES = 5000;
24
25
  const LLM_BATCH_SIZE = 30;
@@ -277,15 +278,48 @@ function _applyLlmPlan(plan, manifest, opts = {}) {
277
278
  return { removed, merged, reclassified };
278
279
  }
279
280
 
281
+ function _writeSweepState(state) {
282
+ try { safeWrite(KB_SWEEP_STATE_PATH, JSON.stringify(state)); } catch { /* ignore */ }
283
+ }
284
+
280
285
  /**
281
286
  * Run the full sweep. Returns a rich summary.
282
287
  *
288
+ * Writes `engine/kb-sweep-state.json` at start (`{ status: 'in-flight', startedAt }`)
289
+ * and at completion (`{ status: 'completed' | 'failed', ... }`) so the dashboard
290
+ * can surface in-flight state across process restarts. The file is the persistent
291
+ * fallback signal; in-memory globals in dashboard.js still win when present.
292
+ *
283
293
  * @param {object} opts
284
294
  * @param {string[]} [opts.pinnedKeys] - extra pinned keys (e.g. from request body)
285
295
  * @param {boolean} [opts.dryRun] - count actions but don't mutate files
286
296
  * @returns {Promise<object>} summary
287
297
  */
288
298
  async function runKbSweep(opts = {}) {
299
+ const dryRun = !!opts.dryRun;
300
+ const startedAt = Date.now();
301
+ if (!dryRun) _writeSweepState({ status: 'in-flight', startedAt, startedAtIso: ts() });
302
+ try {
303
+ const result = await _runKbSweepImpl(opts);
304
+ if (!dryRun) {
305
+ _writeSweepState({
306
+ status: 'completed', startedAt, completedAt: Date.now(), completedAtIso: ts(),
307
+ durationMs: result.durationMs, summary: result.summary, lastResult: result,
308
+ });
309
+ }
310
+ return result;
311
+ } catch (e) {
312
+ if (!dryRun) {
313
+ _writeSweepState({
314
+ status: 'failed', startedAt, completedAt: Date.now(), completedAtIso: ts(),
315
+ error: e && e.message ? e.message : String(e),
316
+ });
317
+ }
318
+ throw e;
319
+ }
320
+ }
321
+
322
+ async function _runKbSweepImpl(opts = {}) {
289
323
  const { callLLM, trackEngineUsage } = require('./llm');
290
324
  const summary = {
291
325
  ok: true,
@@ -385,6 +419,7 @@ function staleGuardMs(entryCount) {
385
419
  module.exports = {
386
420
  runKbSweep,
387
421
  staleGuardMs,
422
+ KB_SWEEP_STATE_PATH,
388
423
  // Exported for tests
389
424
  _hashEntry,
390
425
  _parseFrontmatter,
package/engine/shared.js CHANGED
@@ -1595,6 +1595,21 @@ const WATCH_ABSOLUTE_CONDITIONS = new Set([
1595
1595
  WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED,
1596
1596
  WATCH_CONDITION.CONCLUDED, WATCH_CONDITION.APPROVED, WATCH_CONDITION.REJECTED,
1597
1597
  ]);
1598
+ // Built-in follow-up action types invoked by the engine when a watch fires.
1599
+ // The action registry in engine/watch-actions.js is the source of truth; these
1600
+ // constants exist so engine/dashboard code can reference action keys without
1601
+ // magic strings. New action types can be registered at runtime via
1602
+ // registerActionType() — unknown keys here are fine if registered elsewhere.
1603
+ const WATCH_ACTION_TYPE = {
1604
+ NOTIFY: 'notify',
1605
+ DISPATCH_WORK_ITEM: 'dispatch-work-item',
1606
+ RUN_SKILL: 'run-skill',
1607
+ WEBHOOK: 'webhook',
1608
+ CANCEL_WORK_ITEM: 'cancel-work-item',
1609
+ TRIGGER_PIPELINE: 'trigger-pipeline',
1610
+ ARCHIVE_PLAN: 'archive-plan',
1611
+ RESUME_PLAN: 'resume-plan',
1612
+ };
1598
1613
 
1599
1614
  /** Update per-agent review metrics (prsApproved/prsRejected). Only writes for configured agents. */
1600
1615
  function trackReviewMetric(pr, newReviewStatus, config) {
@@ -3499,7 +3514,7 @@ module.exports = {
3499
3514
  projectWorkSourceWarnings,
3500
3515
  backfillProjectWorkSourceDefaults,
3501
3516
  WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, DISPATCH_RESULT, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
3502
- WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS,
3517
+ WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
3503
3518
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
3504
3519
  FAILURE_CLASS, ESCALATION_POLICY, COMPLETION_FIELDS,
3505
3520
  DEFAULT_AGENT_METRICS,
@@ -0,0 +1,514 @@
1
+ /**
2
+ * engine/watch-actions.js — Follow-up action registry for watches.
3
+ *
4
+ * When a watch fires, the engine first writes the inbox alert (the existing
5
+ * `notify` field) and then — if the watch carries a structured `action` —
6
+ * looks up the action's handler in this registry and invokes it with the
7
+ * trigger context. Handlers are plain async functions:
8
+ *
9
+ * handler(watch, ctx) => Promise<{ ok, summary, dispatchedItemId? }>
10
+ *
11
+ * Handlers MUST swallow their own errors and return `{ ok: false, summary }`
12
+ * rather than throwing — this keeps a single bad action from poisoning the
13
+ * watches tick (other watches should still fire). The caller in
14
+ * engine/watches.js wraps the invocation in try/catch as a backstop.
15
+ *
16
+ * Templating: action params support {{var}} substitution from `ctx`. Strings
17
+ * and one level of nested string fields are templated recursively. Built-in
18
+ * vars: target, targetType, condition, newState, previousState, plus
19
+ * target-specific helpers (e.g. {{prNumber}}, {{branch}}, {{workItemId}}).
20
+ *
21
+ * Built-in actions: notify, dispatch-work-item, run-skill, webhook,
22
+ * cancel-work-item, trigger-pipeline, archive-plan, resume-plan.
23
+ */
24
+
25
+ const path = require('path');
26
+ const fs = require('fs');
27
+ const http = require('http');
28
+ const https = require('https');
29
+ const { URL } = require('url');
30
+
31
+ const shared = require('./shared');
32
+ const {
33
+ WATCH_ACTION_TYPE, WI_STATUS, WORK_TYPE, DONE_STATUSES, PLAN_STATUS,
34
+ log, ts, uid, mutateWorkItems, mutateJsonFileLocked, projectWorkItemsPath,
35
+ } = shared;
36
+
37
+ // ── Action registry ──────────────────────────────────────────────────────────
38
+
39
+ const ACTION_TYPES = {};
40
+
41
+ /**
42
+ * Register a follow-up action handler.
43
+ * @param {string} type - Unique action key (e.g. 'webhook', 'my-custom').
44
+ * @param {object} spec - { label, description, params, handler }
45
+ * - label: string shown in dashboard pickers
46
+ * - description: human-readable description
47
+ * - params: array of { key, required, description } for UI/validation hints
48
+ * - handler: async (watch, ctx) => ({ ok, summary, dispatchedItemId? })
49
+ */
50
+ function registerActionType(type, spec) {
51
+ if (!type || typeof type !== 'string') throw new Error('registerActionType: type must be a non-empty string');
52
+ if (!spec || typeof spec !== 'object') throw new Error('registerActionType: spec must be an object');
53
+ if (typeof spec.handler !== 'function') throw new Error(`registerActionType(${type}): spec.handler must be a function`);
54
+ ACTION_TYPES[type] = {
55
+ label: spec.label || type,
56
+ description: spec.description || '',
57
+ params: Array.isArray(spec.params) ? spec.params : [],
58
+ handler: spec.handler,
59
+ };
60
+ }
61
+
62
+ /** Returns the registered spec for an action type, or null. */
63
+ function getActionType(type) { return ACTION_TYPES[type] || null; }
64
+
65
+ /** Returns the registered action types as a serializable array (for /api/watches/action-types). */
66
+ function listActionTypes() {
67
+ return Object.keys(ACTION_TYPES).sort().map(key => ({
68
+ value: key,
69
+ label: ACTION_TYPES[key].label,
70
+ description: ACTION_TYPES[key].description,
71
+ params: ACTION_TYPES[key].params.map(p => ({ ...p })),
72
+ }));
73
+ }
74
+
75
+ // ── Templating ───────────────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * Substitute {{var}} placeholders in `value` with values from `ctx`.
79
+ * Operates on strings; recurses one level into plain objects/arrays so action
80
+ * params like { headers: { 'X-Foo': '{{prNumber}}' } } work transparently.
81
+ * Unknown vars are left as `{{var}}` so callers can detect them downstream.
82
+ */
83
+ function substituteTemplate(value, ctx) {
84
+ if (value == null) return value;
85
+ if (typeof value === 'string') {
86
+ return value.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (match, key) => {
87
+ if (ctx && Object.prototype.hasOwnProperty.call(ctx, key) && ctx[key] !== undefined && ctx[key] !== null) {
88
+ return String(ctx[key]);
89
+ }
90
+ return match;
91
+ });
92
+ }
93
+ if (Array.isArray(value)) return value.map(v => substituteTemplate(v, ctx));
94
+ if (typeof value === 'object') {
95
+ const out = {};
96
+ for (const k of Object.keys(value)) out[k] = substituteTemplate(value[k], ctx);
97
+ return out;
98
+ }
99
+ return value;
100
+ }
101
+
102
+ /**
103
+ * Build the trigger context passed to action handlers and used for templating.
104
+ * Pulls type-specific extras from the entity captured for the watch (e.g. PR
105
+ * number/branch for pr targets, work item id for work-item targets).
106
+ */
107
+ function buildTriggerContext(watch, opts = {}) {
108
+ const ctx = {
109
+ target: watch.target,
110
+ targetType: watch.targetType,
111
+ condition: watch.condition,
112
+ watchId: watch.id,
113
+ triggerCount: watch.triggerCount || 0,
114
+ previousState: opts.previousState || {},
115
+ newState: opts.newState || {},
116
+ message: opts.message || '',
117
+ };
118
+
119
+ // Promote previous/new state scalars to top-level template vars for
120
+ // convenience, plus a few well-known target-specific aliases.
121
+ for (const k of Object.keys(ctx.newState || {})) {
122
+ const v = ctx.newState[k];
123
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') ctx[k] = v;
124
+ }
125
+
126
+ const entity = opts.entity || null;
127
+ if (entity) {
128
+ if (watch.targetType === 'pr') {
129
+ if (entity.prNumber !== undefined) ctx.prNumber = entity.prNumber;
130
+ if (entity.branch) ctx.branch = entity.branch;
131
+ if (entity.id) ctx.prId = entity.id;
132
+ if (entity.url) ctx.prUrl = entity.url;
133
+ if (entity.project) ctx.project = entity.project;
134
+ } else if (watch.targetType === 'work-item') {
135
+ ctx.workItemId = entity.id;
136
+ if (entity.project) ctx.project = entity.project;
137
+ if (entity.title) ctx.workItemTitle = entity.title;
138
+ } else if (watch.targetType === 'meeting') {
139
+ ctx.meetingId = entity.id;
140
+ if (entity.title) ctx.meetingTitle = entity.title;
141
+ } else if (watch.targetType === 'plan') {
142
+ if (entity._source) ctx.planFile = entity._source;
143
+ if (entity._sourcePlan) ctx.planMd = entity._sourcePlan;
144
+ } else if (watch.targetType === 'pipeline') {
145
+ if (entity._pipelineId) ctx.pipelineId = entity._pipelineId;
146
+ if (entity.runId) ctx.runId = entity.runId;
147
+ } else if (watch.targetType === 'dispatch') {
148
+ if (entity.id) ctx.dispatchId = entity.id;
149
+ } else if (watch.targetType === 'agent') {
150
+ if (entity.id) ctx.agentId = entity.id;
151
+ }
152
+ }
153
+ return ctx;
154
+ }
155
+
156
+ /**
157
+ * Run a watch's configured action — async, error-isolated.
158
+ * Returns a result object the caller persists on `watch._lastActionResult`.
159
+ */
160
+ async function runWatchAction(watch, ctx) {
161
+ const action = watch && watch.action;
162
+ if (!action || typeof action !== 'object') return { ok: false, summary: 'no action configured', skipped: true };
163
+ const type = String(action.type || '').toLowerCase();
164
+ if (!type) return { ok: false, summary: 'action.type missing' };
165
+ const spec = ACTION_TYPES[type];
166
+ if (!spec) return { ok: false, summary: `unknown action type: ${type}` };
167
+
168
+ const params = substituteTemplate(action.params || {}, ctx);
169
+ try {
170
+ const result = await spec.handler(watch, { ...ctx, params });
171
+ const out = result && typeof result === 'object' ? result : { ok: !!result };
172
+ if (out.ok === undefined) out.ok = true;
173
+ return out;
174
+ } catch (err) {
175
+ log('warn', `watch action ${type} for ${watch.id} threw: ${err.message}`);
176
+ return { ok: false, summary: `${type} threw: ${err.message}` };
177
+ }
178
+ }
179
+
180
+ // ── Built-in action handlers ─────────────────────────────────────────────────
181
+
182
+ // notify — explicit no-op redundant with the watch.notify field. Useful when a
183
+ // caller wants to set notify=none and still configure the alert via the action
184
+ // surface (e.g. to template the body), or as a placeholder.
185
+ registerActionType(WATCH_ACTION_TYPE.NOTIFY, {
186
+ label: 'Notify (inbox)',
187
+ description: 'Write a notification to the configured owner inbox. Redundant with the legacy `notify` field but useful as an explicit action.',
188
+ params: [
189
+ { key: 'owner', required: false, description: 'Override owner; defaults to watch.owner.' },
190
+ { key: 'body', required: false, description: 'Override body; defaults to standard trigger message.' },
191
+ ],
192
+ handler: async (watch, ctx) => {
193
+ const owner = ctx.params.owner || watch.owner;
194
+ if (!owner) return { ok: false, summary: 'no owner — skipping notify action' };
195
+ const body = ctx.params.body
196
+ || `## Watch Triggered: ${watch.description || watch.id}\n\n${ctx.message || ''}\n\nWatch ID: ${watch.id} | Target: ${watch.target} | Condition: ${watch.condition}`;
197
+ const slug = `watch-${watch.id}-action-${watch.triggerCount || 0}-${Date.now()}`;
198
+ shared.writeToInbox(owner, slug, body);
199
+ return { ok: true, summary: `notified ${owner}` };
200
+ },
201
+ });
202
+
203
+ // dispatch-work-item — append a new WI to the project (or central) work-items.json.
204
+ registerActionType(WATCH_ACTION_TYPE.DISPATCH_WORK_ITEM, {
205
+ label: 'Dispatch Work Item',
206
+ description: 'Create and queue a new work item when the watch fires. Templated params support {{target}}, {{condition}}, {{prNumber}}, {{workItemId}}, etc.',
207
+ params: [
208
+ { key: 'title', required: true, description: 'Work item title.' },
209
+ { key: 'description', required: false, description: 'Work item description.' },
210
+ { key: 'type', required: false, description: 'Work type (implement|fix|review|...). Defaults to implement.' },
211
+ { key: 'agent', required: false, description: 'Specific agent to route to.' },
212
+ { key: 'project', required: false, description: 'Target project (defaults to watch.project, or central).' },
213
+ { key: 'priority', required: false, description: 'low|medium|high. Defaults to medium.' },
214
+ ],
215
+ handler: async (watch, ctx) => {
216
+ const p = ctx.params || {};
217
+ const title = String(p.title || '').trim();
218
+ if (!title) return { ok: false, summary: 'dispatch-work-item: title is required' };
219
+
220
+ const project = p.project || watch.project || null;
221
+ const wiPath = project
222
+ ? projectWorkItemsPath({ name: project })
223
+ : path.join(shared.MINIONS_DIR, 'work-items.json');
224
+
225
+ const id = 'W-' + uid();
226
+ const item = {
227
+ id,
228
+ title,
229
+ type: p.type || WORK_TYPE.IMPLEMENT,
230
+ priority: p.priority || 'medium',
231
+ description: p.description || '',
232
+ status: WI_STATUS.PENDING,
233
+ created: ts(),
234
+ createdBy: `watch:${watch.id}`,
235
+ };
236
+ if (project) item.project = project;
237
+ if (p.agent) item.agent = String(p.agent);
238
+
239
+ let appended = false;
240
+ mutateWorkItems(wiPath, (items) => {
241
+ items.push(item);
242
+ appended = true;
243
+ });
244
+ if (!appended) return { ok: false, summary: 'dispatch-work-item: write failed' };
245
+ log('info', `Watch ${watch.id} dispatched work item ${id} (${title})`);
246
+ return { ok: true, summary: `dispatched work item ${id}`, dispatchedItemId: id };
247
+ },
248
+ });
249
+
250
+ // run-skill — convenience wrapper that dispatches a work item instructing the
251
+ // assigned agent to run a specific .claude skill.
252
+ registerActionType(WATCH_ACTION_TYPE.RUN_SKILL, {
253
+ label: 'Run Skill',
254
+ description: 'Dispatch a work item that asks the assigned agent to run a specific .claude skill with optional args.',
255
+ params: [
256
+ { key: 'skill', required: true, description: 'Skill name (e.g. "run-tests").' },
257
+ { key: 'args', required: false, description: 'Args/instructions to pass to the skill.' },
258
+ { key: 'agent', required: false, description: 'Specific agent to route to.' },
259
+ { key: 'project', required: false, description: 'Target project.' },
260
+ ],
261
+ handler: async (watch, ctx) => {
262
+ const p = ctx.params || {};
263
+ const skill = String(p.skill || '').trim();
264
+ if (!skill) return { ok: false, summary: 'run-skill: skill name is required' };
265
+ const args = p.args ? `\n\nArgs/instructions:\n${p.args}` : '';
266
+ const dispatchSpec = getActionType(WATCH_ACTION_TYPE.DISPATCH_WORK_ITEM);
267
+ return dispatchSpec.handler(watch, {
268
+ ...ctx,
269
+ params: {
270
+ title: `Run skill: ${skill}`,
271
+ description: `Watch ${watch.id} (target ${watch.target}, condition ${watch.condition}) requested running the \`${skill}\` skill.${args}`,
272
+ type: WORK_TYPE.IMPLEMENT,
273
+ agent: p.agent,
274
+ project: p.project,
275
+ priority: p.priority || 'medium',
276
+ },
277
+ });
278
+ },
279
+ });
280
+
281
+ // webhook — POST/GET/PUT to an arbitrary http(s) URL. Bare-bones (no shell exec).
282
+ registerActionType(WATCH_ACTION_TYPE.WEBHOOK, {
283
+ label: 'Webhook',
284
+ description: 'Make an HTTP(S) request to an arbitrary URL when the watch fires. Only http and https schemes are allowed.',
285
+ params: [
286
+ { key: 'url', required: true, description: 'Full http:// or https:// URL.' },
287
+ { key: 'method', required: false, description: 'HTTP method (default POST).' },
288
+ { key: 'body', required: false, description: 'Request body (object → JSON, string → raw).' },
289
+ { key: 'headers', required: false, description: 'Object map of extra headers.' },
290
+ ],
291
+ handler: async (watch, ctx) => {
292
+ const p = ctx.params || {};
293
+ const rawUrl = String(p.url || '').trim();
294
+ if (!rawUrl) return { ok: false, summary: 'webhook: url is required' };
295
+ let parsed;
296
+ try { parsed = new URL(rawUrl); } catch { return { ok: false, summary: `webhook: invalid url ${rawUrl}` }; }
297
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
298
+ return { ok: false, summary: `webhook: only http/https allowed, got ${parsed.protocol}` };
299
+ }
300
+
301
+ const method = String(p.method || 'POST').toUpperCase();
302
+ let body = null;
303
+ const headers = { 'User-Agent': 'minions-watch/1.0', ...(p.headers || {}) };
304
+ if (p.body !== undefined && p.body !== null && method !== 'GET' && method !== 'HEAD') {
305
+ if (typeof p.body === 'string') {
306
+ body = p.body;
307
+ if (!headers['Content-Type'] && !headers['content-type']) headers['Content-Type'] = 'text/plain';
308
+ } else {
309
+ body = JSON.stringify(p.body);
310
+ if (!headers['Content-Type'] && !headers['content-type']) headers['Content-Type'] = 'application/json';
311
+ }
312
+ headers['Content-Length'] = Buffer.byteLength(body);
313
+ }
314
+
315
+ const lib = parsed.protocol === 'https:' ? https : http;
316
+ return new Promise((resolve) => {
317
+ const req = lib.request({
318
+ hostname: parsed.hostname,
319
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
320
+ path: parsed.pathname + parsed.search,
321
+ method,
322
+ headers,
323
+ }, (res) => {
324
+ // Drain to free socket; we don't care about the body.
325
+ res.on('data', () => { });
326
+ res.on('end', () => {
327
+ const ok = res.statusCode >= 200 && res.statusCode < 400;
328
+ resolve({ ok, summary: `webhook ${method} ${rawUrl} → ${res.statusCode}`, statusCode: res.statusCode });
329
+ });
330
+ });
331
+ req.on('error', (err) => {
332
+ resolve({ ok: false, summary: `webhook ${method} ${rawUrl} failed: ${err.message}` });
333
+ });
334
+ // 10s safety timeout — watches must not hang the tick.
335
+ req.setTimeout(10000, () => {
336
+ req.destroy(new Error('timeout'));
337
+ });
338
+ if (body !== null) req.write(body);
339
+ req.end();
340
+ });
341
+ },
342
+ });
343
+
344
+ // cancel-work-item — flip a WI to CANCELLED across project + central files.
345
+ registerActionType(WATCH_ACTION_TYPE.CANCEL_WORK_ITEM, {
346
+ label: 'Cancel Work Item',
347
+ description: 'Cancel a work item by ID. Searches the central work-items.json plus all project work-items.json files.',
348
+ params: [
349
+ { key: 'id', required: true, description: 'Work item ID to cancel.' },
350
+ { key: 'reason', required: false, description: 'Reason recorded on the item (defaults to "watch:<id>").' },
351
+ ],
352
+ handler: async (watch, ctx) => {
353
+ const p = ctx.params || {};
354
+ const targetId = String(p.id || ctx.workItemId || '').trim();
355
+ if (!targetId) return { ok: false, summary: 'cancel-work-item: id is required' };
356
+
357
+ const reason = p.reason || `watch:${watch.id}`;
358
+ const candidates = [path.join(shared.MINIONS_DIR, 'work-items.json')];
359
+ try {
360
+ const config = shared.safeJson(path.join(shared.MINIONS_DIR, 'config.json')) || {};
361
+ for (const proj of (config.projects || [])) {
362
+ if (proj && proj.name) candidates.push(projectWorkItemsPath(proj));
363
+ }
364
+ } catch { /* config optional */ }
365
+
366
+ let cancelled = false;
367
+ for (const wiPath of candidates) {
368
+ if (!fs.existsSync(wiPath)) continue;
369
+ mutateWorkItems(wiPath, (items) => {
370
+ const it = items.find(i => i && i.id === targetId);
371
+ if (!it) return items;
372
+ if (DONE_STATUSES.has(it.status) || it.status === WI_STATUS.CANCELLED) return items;
373
+ it.status = WI_STATUS.CANCELLED;
374
+ it._cancelledBy = reason;
375
+ it.cancelledAt = ts();
376
+ cancelled = true;
377
+ return items;
378
+ });
379
+ if (cancelled) break;
380
+ }
381
+ return cancelled
382
+ ? { ok: true, summary: `cancelled work item ${targetId}` }
383
+ : { ok: false, summary: `cancel-work-item: ${targetId} not found or already terminal` };
384
+ },
385
+ });
386
+
387
+ // trigger-pipeline — start a new run of a pipeline (no-op if already active).
388
+ registerActionType(WATCH_ACTION_TYPE.TRIGGER_PIPELINE, {
389
+ label: 'Trigger Pipeline',
390
+ description: 'Start a new run of a pipeline. Skipped if the pipeline already has an active run.',
391
+ params: [
392
+ { key: 'pipelineId', required: true, description: 'Pipeline ID to trigger.' },
393
+ ],
394
+ handler: async (watch, ctx) => {
395
+ const p = ctx.params || {};
396
+ const pipelineId = String(p.pipelineId || ctx.pipelineId || '').trim();
397
+ if (!pipelineId) return { ok: false, summary: 'trigger-pipeline: pipelineId is required' };
398
+ let pipelineMod;
399
+ try { pipelineMod = require('./pipeline'); }
400
+ catch (err) { return { ok: false, summary: `trigger-pipeline: pipeline module unavailable (${err.message})` }; }
401
+ const pipeline = pipelineMod.getPipeline(pipelineId);
402
+ if (!pipeline) return { ok: false, summary: `trigger-pipeline: pipeline ${pipelineId} not found` };
403
+ if (pipelineMod.getActiveRun(pipelineId)) {
404
+ return { ok: false, summary: `trigger-pipeline: ${pipelineId} already has an active run` };
405
+ }
406
+ const run = pipelineMod.startRun(pipelineId, pipeline);
407
+ if (!run) return { ok: false, summary: `trigger-pipeline: ${pipelineId} startRun returned null` };
408
+ return { ok: true, summary: `triggered pipeline ${pipelineId} run ${run.runId}`, runId: run.runId };
409
+ },
410
+ });
411
+
412
+ // archive-plan — mark the PRD JSON file as archived (status=archived, archivedAt=now).
413
+ registerActionType(WATCH_ACTION_TYPE.ARCHIVE_PLAN, {
414
+ label: 'Archive Plan',
415
+ description: 'Mark a PRD/plan as archived. Sets status="archived" and archivedAt on the PRD JSON.',
416
+ params: [
417
+ { key: 'planFile', required: false, description: 'PRD .json filename (defaults to {{planFile}} from trigger context).' },
418
+ ],
419
+ handler: async (watch, ctx) => {
420
+ const p = ctx.params || {};
421
+ const planFile = String(p.planFile || ctx.planFile || '').trim();
422
+ if (!planFile) return { ok: false, summary: 'archive-plan: planFile is required' };
423
+ const prdPath = path.join(shared.MINIONS_DIR, 'prd', planFile);
424
+ if (!fs.existsSync(prdPath)) return { ok: false, summary: `archive-plan: ${planFile} not found` };
425
+ let updated = false;
426
+ mutateJsonFileLocked(prdPath, (data) => {
427
+ if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
428
+ data.status = 'archived';
429
+ data.archivedAt = ts();
430
+ updated = true;
431
+ return data;
432
+ }, { defaultValue: {} });
433
+ return updated
434
+ ? { ok: true, summary: `archived plan ${planFile}` }
435
+ : { ok: false, summary: `archive-plan: ${planFile} write failed` };
436
+ },
437
+ });
438
+
439
+ // resume-plan — flip a paused/awaiting-approval plan to active and clear planStale.
440
+ registerActionType(WATCH_ACTION_TYPE.RESUME_PLAN, {
441
+ label: 'Resume Plan',
442
+ description: 'Resume a paused plan by setting status="active" and clearing the planStale flag.',
443
+ params: [
444
+ { key: 'planFile', required: false, description: 'PRD .json filename (defaults to {{planFile}}).' },
445
+ ],
446
+ handler: async (watch, ctx) => {
447
+ const p = ctx.params || {};
448
+ const planFile = String(p.planFile || ctx.planFile || '').trim();
449
+ if (!planFile) return { ok: false, summary: 'resume-plan: planFile is required' };
450
+ const prdPath = path.join(shared.MINIONS_DIR, 'prd', planFile);
451
+ if (!fs.existsSync(prdPath)) return { ok: false, summary: `resume-plan: ${planFile} not found` };
452
+ let updated = false;
453
+ mutateJsonFileLocked(prdPath, (data) => {
454
+ if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
455
+ data.status = PLAN_STATUS.ACTIVE;
456
+ delete data.planStale;
457
+ updated = true;
458
+ return data;
459
+ }, { defaultValue: {} });
460
+ return updated
461
+ ? { ok: true, summary: `resumed plan ${planFile}` }
462
+ : { ok: false, summary: `resume-plan: ${planFile} write failed` };
463
+ },
464
+ });
465
+
466
+ // ── Validation ───────────────────────────────────────────────────────────────
467
+
468
+ /**
469
+ * Validate that a candidate `action` object is a usable shape: type registered,
470
+ * required params present (after templating). Returns null when valid, or an
471
+ * error message string. Used by createWatch/updateWatch.
472
+ */
473
+ function validateAction(action) {
474
+ if (action === null || action === undefined) return null; // optional field
475
+ if (typeof action !== 'object' || Array.isArray(action)) return 'action must be an object';
476
+ const type = String(action.type || '').toLowerCase();
477
+ if (!type) return 'action.type is required';
478
+ const spec = ACTION_TYPES[type];
479
+ if (!spec) return `unknown action type: ${type} (must be one of: ${Object.keys(ACTION_TYPES).sort().join(', ')})`;
480
+ const params = action.params || {};
481
+ if (typeof params !== 'object' || Array.isArray(params)) return 'action.params must be an object';
482
+ // Required params check is best-effort — values may contain {{templates}}
483
+ // resolved at trigger time, so we only require the key to exist.
484
+ for (const p of spec.params) {
485
+ if (p.required && (params[p.key] === undefined || params[p.key] === null || params[p.key] === '')) {
486
+ return `action.params.${p.key} is required for action type ${type}`;
487
+ }
488
+ }
489
+ // Extra validation for webhook URL — fail fast on unsupported schemes.
490
+ if (type === WATCH_ACTION_TYPE.WEBHOOK && params.url) {
491
+ const url = String(params.url);
492
+ // Allow templated URLs (containing {{...}}) — they'll be re-validated at run time.
493
+ if (!/\{\{/.test(url)) {
494
+ try {
495
+ const parsed = new URL(url);
496
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
497
+ return `webhook url must use http:// or https://, got ${parsed.protocol}`;
498
+ }
499
+ } catch { return `webhook url is invalid: ${url}`; }
500
+ }
501
+ }
502
+ return null;
503
+ }
504
+
505
+ module.exports = {
506
+ registerActionType,
507
+ getActionType,
508
+ listActionTypes,
509
+ runWatchAction,
510
+ buildTriggerContext,
511
+ substituteTemplate,
512
+ validateAction,
513
+ _ACTION_TYPES: ACTION_TYPES, // exported for testing
514
+ };
package/engine/watches.js CHANGED
@@ -25,6 +25,7 @@ const path = require('path');
25
25
  const shared = require('./shared');
26
26
  const { safeJsonArr, mutateJsonFileLocked, ts, uid, log, writeToInbox,
27
27
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS } = shared;
28
+ const watchActions = require('./watch-actions');
28
29
 
29
30
  // Dynamic path — respects MINIONS_TEST_DIR for test isolation
30
31
  function _watchesPath() { return path.join(shared.MINIONS_DIR, 'engine', 'watches.json'); }
@@ -99,7 +100,7 @@ function getWatches() {
99
100
  * @param {object} opts - Watch definition
100
101
  * @returns {object} - Created watch
101
102
  */
102
- function createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet }) {
103
+ function createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet, action }) {
103
104
  if (!target) throw new Error('target is required');
104
105
  if (!targetType || !TARGET_TYPES[targetType]) {
105
106
  throw new Error(`targetType must be one of: ${Object.keys(TARGET_TYPES).sort().join(', ')}`);
@@ -108,6 +109,10 @@ function createWatch({ target, targetType, condition, interval, owner, descripti
108
109
  if (!condition || !tt.conditions.includes(condition)) {
109
110
  throw new Error(`condition must be one of: ${tt.conditions.join(', ')} (for targetType ${targetType})`);
110
111
  }
112
+ if (action !== undefined && action !== null) {
113
+ const actionErr = watchActions.validateAction(action);
114
+ if (actionErr) throw new Error(actionErr);
115
+ }
111
116
 
112
117
  const watch = {
113
118
  id: 'watch-' + uid(),
@@ -122,6 +127,7 @@ function createWatch({ target, targetType, condition, interval, owner, descripti
122
127
  notify: notify || 'inbox',
123
128
  stopAfter: Number(stopAfter) || 0, // 0 = run forever; N = expire after N triggers
124
129
  onNotMet: onNotMet || null, // null | 'notify' — action per poll when condition not met
130
+ action: action || null, // optional follow-up action — see engine/watch-actions.js
125
131
  triggerCount: 0,
126
132
  created_at: ts(),
127
133
  last_checked: null,
@@ -151,13 +157,17 @@ function updateWatch(id, updates) {
151
157
  log('warn', `Invalid watch status: ${updates.status}`);
152
158
  return null;
153
159
  }
160
+ if (updates.action !== undefined && updates.action !== null) {
161
+ const actionErr = watchActions.validateAction(updates.action);
162
+ if (actionErr) throw new Error(actionErr);
163
+ }
154
164
  let found = null;
155
165
  mutateJsonFileLocked(_watchesPath(), (watches) => {
156
166
  if (!Array.isArray(watches)) return watches;
157
167
  const watch = watches.find(w => w.id === id);
158
168
  if (!watch) return watches;
159
169
  // Only allow safe field updates
160
- const allowed = ['status', 'interval', 'description', 'notify', 'stopAfter', 'onNotMet', 'condition'];
170
+ const allowed = ['status', 'interval', 'description', 'notify', 'stopAfter', 'onNotMet', 'condition', 'action'];
161
171
  for (const key of allowed) {
162
172
  if (updates[key] !== undefined) watch[key] = updates[key];
163
173
  }
@@ -217,11 +227,22 @@ function evaluateWatch(watch, state) {
217
227
  * Check all active watches against current state. Called from engine tick.
218
228
  * @param {object} config - Engine config
219
229
  * @param {object} state - { pullRequests, workItems } from queries
230
+ *
231
+ * Order of operations when a watch fires:
232
+ * 1. Increment trigger count and update last_triggered (inside the lock).
233
+ * 2. Queue the legacy inbox notification (watch.notify === 'inbox').
234
+ * 3. Queue the configured action (watch.action) for async invocation.
235
+ * 4. Release the lock.
236
+ * 5. Fire notifications first (so the human/agent inbox gets the alert),
237
+ * then fire actions in parallel — failures isolated per action so one
238
+ * bad action doesn't stop the others. Each action's result is written
239
+ * back onto the watch as `_lastActionResult` in a follow-up locked write.
220
240
  */
221
241
  function checkWatches(config, state) {
222
242
  const now = Date.now();
223
- // Collect notifications to fire AFTER lock is released — never do I/O inside the lock callback
243
+ // Collect notifications and actions to fire AFTER lock is released — never do I/O inside the lock callback
224
244
  const notifications = [];
245
+ const actionsToRun = [];
225
246
 
226
247
  mutateJsonFileLocked(_watchesPath(), (watches) => {
227
248
  if (!Array.isArray(watches) || watches.length === 0) return watches;
@@ -244,11 +265,13 @@ function checkWatches(config, state) {
244
265
  watch._lastState = _captureState(watch, state);
245
266
  }
246
267
 
268
+ const previousState = { ...watch._lastState };
247
269
  const result = evaluateWatch(watch, state);
248
270
  if (result.triggered) {
249
271
  watch.triggerCount = (watch.triggerCount || 0) + 1;
250
272
  watch.last_triggered = ts();
251
273
  watch._lastTriggerMessage = result.message;
274
+ const newState = _captureState(watch, state);
252
275
 
253
276
  // Queue trigger notification — unique key per trigger to avoid overwriting previous messages
254
277
  if (watch.notify === 'inbox' && watch.owner) {
@@ -260,6 +283,22 @@ function checkWatches(config, state) {
260
283
  }
261
284
  log('info', `Watch triggered: ${watch.id} — ${result.message}`);
262
285
 
286
+ // Queue follow-up action for after-lock invocation. We snapshot the
287
+ // watch + entity here so the async handler can run with the same
288
+ // state that triggered the watch even if the live state changes.
289
+ if (watch.action && typeof watch.action === 'object' && watch.action.type) {
290
+ const tt = TARGET_TYPES[watch.targetType];
291
+ const entity = tt ? tt.fetchEntity(watch.target, state || {}) : null;
292
+ actionsToRun.push({
293
+ watchId: watch.id,
294
+ snapshot: JSON.parse(JSON.stringify(watch)),
295
+ previousState,
296
+ newState,
297
+ entity: entity ? JSON.parse(JSON.stringify(entity)) : null,
298
+ message: result.message,
299
+ });
300
+ }
301
+
263
302
  // Expire when stopAfter > 0 and trigger count reaches the limit.
264
303
  // Absolute conditions (merged, build-pass, etc.) auto-expire on first trigger
265
304
  // when stopAfter=0 — fire-once semantics. Change-based conditions run forever.
@@ -296,6 +335,45 @@ function checkWatches(config, state) {
296
335
  log('warn', `Watch notification error: ${err.message}`);
297
336
  }
298
337
  }
338
+
339
+ // Fire follow-up actions outside the lock. Each action runs independently —
340
+ // failures in one don't prevent others from firing. Results are best-effort
341
+ // logged and persisted onto the watch as _lastActionResult / _lastActionAt.
342
+ for (const task of actionsToRun) {
343
+ _runActionTask(task).catch(err => log('warn', `Watch action task ${task.watchId} crashed: ${err.message}`));
344
+ }
345
+ }
346
+
347
+ /** Internal: invoke a watch's action and persist the result back onto the watch. */
348
+ async function _runActionTask(task) {
349
+ const ctx = watchActions.buildTriggerContext(task.snapshot, {
350
+ previousState: task.previousState,
351
+ newState: task.newState,
352
+ entity: task.entity,
353
+ message: task.message,
354
+ });
355
+ const result = await watchActions.runWatchAction(task.snapshot, ctx);
356
+ log(result.ok ? 'info' : 'warn',
357
+ `Watch ${task.watchId} action ${task.snapshot.action?.type || '?'}: ${result.summary || (result.ok ? 'ok' : 'failed')}`);
358
+ // Persist back onto the watch (best-effort).
359
+ try {
360
+ mutateJsonFileLocked(_watchesPath(), (watches) => {
361
+ if (!Array.isArray(watches)) return watches;
362
+ const w = watches.find(x => x.id === task.watchId);
363
+ if (w) {
364
+ w._lastActionResult = {
365
+ type: task.snapshot.action?.type || null,
366
+ ok: !!result.ok,
367
+ summary: result.summary || '',
368
+ dispatchedItemId: result.dispatchedItemId || null,
369
+ at: ts(),
370
+ };
371
+ }
372
+ return watches;
373
+ }, { defaultValue: [] });
374
+ } catch (err) {
375
+ log('warn', `Watch ${task.watchId} action result persist failed: ${err.message}`);
376
+ }
299
377
  }
300
378
 
301
379
  /**
@@ -680,7 +758,16 @@ module.exports = {
680
758
  registerTargetType,
681
759
  getTargetType,
682
760
  listTargetTypes,
761
+ // Re-exported from engine/watch-actions for callers that prefer a single
762
+ // import surface (dashboard.js handlers, tests). See engine/watch-actions.js
763
+ // for the canonical implementations and the action registry.
764
+ registerActionType: watchActions.registerActionType,
765
+ getActionType: watchActions.getActionType,
766
+ listActionTypes: watchActions.listActionTypes,
767
+ runWatchAction: watchActions.runWatchAction,
768
+ validateAction: watchActions.validateAction,
683
769
  _captureState, // exported for testing
684
770
  _watchesPath, // exported for testing — dynamic, respects MINIONS_TEST_DIR
771
+ _runActionTask, // exported for testing — invoked by checkWatches per fired action
685
772
  _TARGET_TYPES: TARGET_TYPES, // exported for testing — direct registry access
686
773
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1857",
3
+ "version": "0.1.1859",
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"