@yemi33/minions 0.1.1852 → 0.1.1853

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,8 +1,16 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1852 (2026-05-10)
3
+ ## 0.1.1853 (2026-05-10)
4
+
5
+ ### Features
6
+ - generalize watches infra to support arbitrary target types (#2322)
7
+ - use safeJsonArr in engine/watches.js loadWatches (footgun #2) (#2320)
4
8
 
5
9
  ### Fixes
10
+ - use safeJsonObj in engine/teams.js (footgun #2) (#2318)
11
+ - use safeJsonArr in engine/github.js PR poller (#2317)
12
+ - guard empty projects[] in cli.js dispatch helpers (#2316)
13
+ - use safeJsonArr in engine/ado.js PR poller (#2315)
6
14
  - use DONE_STATUSES constant in getAgents lastAction (#2314)
7
15
 
8
16
  ## 0.1.1851 (2026-05-10)
@@ -9,9 +9,23 @@ const _WATCH_STATUS_BADGES = {
9
9
  expired: '<span class="pr-badge" style="background:rgba(139,148,158,0.15);color:var(--muted);border-color:var(--muted)">expired</span>',
10
10
  };
11
11
 
12
+ // Cache of target types fetched from /api/watches/target-types. Populated on
13
+ // first modal open and refreshed per modal show. Each entry:
14
+ // { value, label, conditions: [...], description }
15
+ let _watchTargetTypesCache = null;
16
+
17
+ // Built-in fallback labels — used when the API response hasn't loaded yet, or
18
+ // for legacy targetTypes/conditions that may appear on stored watches but not
19
+ // in the live registry. The registry from the API is authoritative when present.
12
20
  const _WATCH_TARGET_LABELS = {
13
- pr: 'PR',
21
+ pr: 'Pull Request',
14
22
  'work-item': 'Work Item',
23
+ meeting: 'Meeting',
24
+ plan: 'Plan / PRD',
25
+ schedule: 'Schedule',
26
+ pipeline: 'Pipeline',
27
+ dispatch: 'Dispatch',
28
+ agent: 'Agent',
15
29
  };
16
30
 
17
31
  const _WATCH_CONDITION_LABELS = {
@@ -24,8 +38,30 @@ const _WATCH_CONDITION_LABELS = {
24
38
  any: 'Any Change',
25
39
  'new-comments': 'New Comments',
26
40
  'vote-change': 'Vote Change',
41
+ concluded: 'Concluded',
42
+ approved: 'Approved',
43
+ rejected: 'Rejected',
44
+ 'stage-complete': 'Stage Complete',
45
+ ran: 'Ran',
46
+ enabled: 'Enabled',
47
+ disabled: 'Disabled',
48
+ 'activity-change': 'Activity Change',
27
49
  };
28
50
 
51
+ function _conditionLabel(cond) {
52
+ if (_WATCH_CONDITION_LABELS[cond]) return _WATCH_CONDITION_LABELS[cond];
53
+ // Title-case unknown conditions for graceful fallback
54
+ return String(cond || '').replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
55
+ }
56
+
57
+ function _targetTypeLabel(type) {
58
+ if (_watchTargetTypesCache) {
59
+ const hit = _watchTargetTypesCache.find(t => t.value === type);
60
+ if (hit) return hit.label;
61
+ }
62
+ return _WATCH_TARGET_LABELS[type] || (type || '');
63
+ }
64
+
29
65
  function _intervalToHuman(ms) {
30
66
  if (!ms) return 'default';
31
67
  const sec = Math.floor(ms / 1000);
@@ -83,8 +119,8 @@ function renderWatches(watchesData) {
83
119
  for (var i = 0; i < pageItems.length; i++) {
84
120
  var w = pageItems[i];
85
121
  var statusBadge = _WATCH_STATUS_BADGES[w.status] || escHtml(w.status || 'unknown');
86
- var targetLabel = _WATCH_TARGET_LABELS[w.targetType] || escHtml(w.targetType || '');
87
- var condLabel = _WATCH_CONDITION_LABELS[w.condition] || escHtml(w.condition || '');
122
+ var targetLabel = escHtml(_targetTypeLabel(w.targetType));
123
+ var condLabel = escHtml(_conditionLabel(w.condition));
88
124
  var lastChecked = w.last_checked ? timeAgo(w.last_checked) : 'never';
89
125
  var lastTriggered = w.last_triggered ? timeAgo(w.last_triggered) : 'never';
90
126
  var triggerInfo = (w.triggerCount || 0) + (w.stopAfter > 0 ? '/' + w.stopAfter : '');
@@ -140,8 +176,8 @@ function openWatchDetail(id) {
140
176
  var lastChecked = w.last_checked ? formatLocalDateTime(w.last_checked) : 'never';
141
177
  var lastTriggered = w.last_triggered ? formatLocalDateTime(w.last_triggered) : 'never';
142
178
  var createdAt = w.created_at ? formatLocalDateTime(w.created_at) : 'unknown';
143
- var targetLabel = _WATCH_TARGET_LABELS[w.targetType] || w.targetType;
144
- var condLabel = _WATCH_CONDITION_LABELS[w.condition] || w.condition;
179
+ var targetLabel = _targetTypeLabel(w.targetType);
180
+ var condLabel = _conditionLabel(w.condition);
145
181
 
146
182
  document.getElementById('modal-title').innerHTML = escHtml(w.description || w.target) +
147
183
  ' <div style="display:flex;gap:4px;margin-top:4px">' +
@@ -220,33 +256,33 @@ function deleteWatch(id) {
220
256
 
221
257
  // ─── Create Watch Modal ─────────────────────────────────────────────────────
222
258
 
259
+ // Build the form HTML using the (cached) target-type registry. Target type
260
+ // and condition options come from the live registry — no hardcoded list — so
261
+ // new types registered server-side appear here without UI changes.
223
262
  function _watchFormHtml() {
224
263
  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';
225
264
 
226
- var targetTypes = [
227
- { value: 'pr', label: 'Pull Request' },
228
- { value: 'work-item', label: 'Work Item' },
229
- ];
230
- var conditions = [
231
- { value: 'merged', label: 'Merged' },
232
- { value: 'build-fail', label: 'Build Fail' },
233
- { value: 'build-pass', label: 'Build Pass' },
234
- { value: 'completed', label: 'Completed' },
235
- { value: 'failed', label: 'Failed' },
236
- { value: 'status-change', label: 'Status Change' },
237
- { value: 'any', label: 'Any Change' },
238
- { value: 'new-comments', label: 'New Comments' },
239
- { value: 'vote-change', label: 'Vote Change' },
240
- ];
241
- var ttOpts = targetTypes.map(function(t) { return '<option value="' + t.value + '">' + t.label + '</option>'; }).join('');
242
- var condOpts = conditions.map(function(c) { return '<option value="' + c.value + '">' + c.label + '</option>'; }).join('');
265
+ var targetTypes = _watchTargetTypesCache && _watchTargetTypesCache.length
266
+ ? _watchTargetTypesCache
267
+ : [{ value: 'pr', label: 'Pull Request', conditions: ['merged','build-fail','build-pass','status-change','any','new-comments','vote-change'] }];
268
+
269
+ var ttOpts = targetTypes.map(function(t) {
270
+ return '<option value="' + escHtml(t.value) + '">' + escHtml(t.label) + '</option>';
271
+ }).join('');
272
+
273
+ // Condition options for the initially-selected target type (first entry).
274
+ var initialConds = (targetTypes[0] && targetTypes[0].conditions) || [];
275
+ var condOpts = initialConds.map(function(c) {
276
+ return '<option value="' + escHtml(c) + '">' + escHtml(_conditionLabel(c)) + '</option>';
277
+ }).join('');
278
+
243
279
  var agentOpts = '<option value="">human</option>' + (cmdAgents || []).map(function(a) { return '<option value="' + escHtml(a.id) + '">' + escHtml(a.name) + '</option>'; }).join('');
244
280
  var projOpts = '<option value="">Any</option>' + (cmdProjects || []).map(function(p) { return '<option value="' + escHtml(p.name) + '">' + escHtml(p.name) + '</option>'; }).join('');
245
281
 
246
282
  return '<div style="display:flex;flex-direction:column;gap:12px;font-family:inherit">' +
247
283
  '<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>' +
248
- '<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>' +
249
- '<label style="color:var(--text);font-size:var(--text-md)">Target Type<select id="watch-edit-target-type" style="' + inputStyle + '">' + ttOpts + '</select></label>' +
284
+ '<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>' +
285
+ '<label style="color:var(--text);font-size:var(--text-md)">Target Type<select id="watch-edit-target-type" style="' + inputStyle + '" onchange="_updateWatchConditionOptions()">' + ttOpts + '</select></label>' +
250
286
  '<label style="color:var(--text);font-size:var(--text-md)">Condition<select id="watch-edit-condition" style="' + inputStyle + '">' + condOpts + '</select></label>' +
251
287
  '<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>' +
252
288
  '<label style="color:var(--text);font-size:var(--text-md)">Owner (who gets notified)<select id="watch-edit-owner" style="' + inputStyle + '">' + agentOpts + '</select></label>' +
@@ -257,7 +293,19 @@ function _watchFormHtml() {
257
293
  '</div>';
258
294
  }
259
295
 
260
- function openCreateWatchModal() {
296
+ // Refresh the condition <select> based on the currently selected target type.
297
+ function _updateWatchConditionOptions() {
298
+ var ttSel = document.getElementById('watch-edit-target-type');
299
+ var condSel = document.getElementById('watch-edit-condition');
300
+ if (!ttSel || !condSel || !_watchTargetTypesCache) return;
301
+ var entry = _watchTargetTypesCache.find(function(t) { return t.value === ttSel.value; });
302
+ var conditions = (entry && entry.conditions) || [];
303
+ condSel.innerHTML = conditions.map(function(c) {
304
+ return '<option value="' + escHtml(c) + '">' + escHtml(_conditionLabel(c)) + '</option>';
305
+ }).join('');
306
+ }
307
+
308
+ function _renderCreateWatchModal() {
261
309
  document.getElementById('modal-title').innerHTML = 'Create Watch' +
262
310
  ' <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>';
263
311
  document.getElementById('modal-body').innerHTML = _watchFormHtml();
@@ -266,6 +314,23 @@ function openCreateWatchModal() {
266
314
  document.getElementById('modal').classList.add('open');
267
315
  }
268
316
 
317
+ function openCreateWatchModal() {
318
+ // Render an initial form immediately (uses cached target types if present).
319
+ _renderCreateWatchModal();
320
+ // Fetch the live registry and re-render so newly registered target types
321
+ // 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
326
+ var titleEl = document.getElementById('modal-title');
327
+ if (titleEl && titleEl.textContent.indexOf('Create Watch') === 0) {
328
+ _renderCreateWatchModal();
329
+ }
330
+ }
331
+ }).catch(function() { /* fall back to baked-in defaults */ });
332
+ }
333
+
269
334
  function submitWatch() {
270
335
  var target = (document.getElementById('watch-edit-target') || {}).value || '';
271
336
  var targetType = (document.getElementById('watch-edit-target-type') || {}).value || 'pr';
@@ -324,4 +389,5 @@ window.MinionsWatches = {
324
389
  deleteWatch: deleteWatch,
325
390
  _watchPrev: _watchPrev,
326
391
  _watchNext: _watchNext,
392
+ _updateWatchConditionOptions: _updateWatchConditionOptions,
327
393
  };
package/dashboard.js CHANGED
@@ -6301,6 +6301,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6301
6301
  return jsonReply(res, 200, { watches: watchesMod.getWatches() });
6302
6302
  }
6303
6303
 
6304
+ async function handleWatchesTargetTypes(req, res) {
6305
+ return jsonReply(res, 200, { targetTypes: watchesMod.listTargetTypes() });
6306
+ }
6307
+
6304
6308
  async function handleWatchesCreate(req, res) {
6305
6309
  const body = await readBody(req);
6306
6310
  const { target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet } = body;
@@ -7313,6 +7317,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7313
7317
 
7314
7318
  // Watches
7315
7319
  { method: 'GET', path: '/api/watches', desc: 'List all watches', handler: handleWatchesList },
7320
+ { method: 'GET', path: '/api/watches/target-types', desc: 'List registered watch target types and their valid conditions', handler: handleWatchesTargetTypes },
7316
7321
  { method: 'POST', path: '/api/watches', desc: 'Create a new watch', params: 'target, targetType, condition, interval?, owner?, description?, project?, notify?, stopAfter?, onNotMet?', handler: handleWatchesCreate },
7317
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 },
7318
7323
  { method: 'POST', path: '/api/watches/delete', desc: 'Delete a watch', params: 'id', handler: handleWatchesDelete },
package/engine/ado.js CHANGED
@@ -1128,7 +1128,7 @@ async function reconcilePrs(config) {
1128
1128
  if (adoPrs.length === 0) continue;
1129
1129
 
1130
1130
  const prPath = shared.projectPrPath(project);
1131
- const existingPrs = shared.safeJson(prPath) || [];
1131
+ const existingPrs = shared.safeJsonArr(prPath);
1132
1132
  shared.normalizePrRecords(existingPrs, project);
1133
1133
  const existingIds = new Set(existingPrs.map(p => p.id));
1134
1134
  let projectAdded = 0;
@@ -1136,9 +1136,9 @@ async function reconcilePrs(config) {
1136
1136
 
1137
1137
  // Load work items to match branches to work item IDs
1138
1138
  const wiPath = shared.projectWorkItemsPath(project);
1139
- const workItems = shared.safeJson(wiPath) || [];
1139
+ const workItems = shared.safeJsonArr(wiPath);
1140
1140
  const centralWiPath = path.join(shared.MINIONS_DIR, 'work-items.json');
1141
- const centralItems = shared.safeJson(centralWiPath) || [];
1141
+ const centralItems = shared.safeJsonArr(centralWiPath);
1142
1142
  const allItems = [...workItems, ...centralItems];
1143
1143
 
1144
1144
  let projectUpdated = 0;
package/engine/cli.js CHANGED
@@ -1164,7 +1164,7 @@ const commands = {
1164
1164
  console.log(target.error);
1165
1165
  return;
1166
1166
  }
1167
- const targetProject = target?.project || projects[0];
1167
+ const targetProject = target?.project || (projects.length > 0 ? projects[0] : null);
1168
1168
  const wiPath = projectWorkItemsPath(targetProject);
1169
1169
  let item;
1170
1170
  mutateWorkItems(wiPath, items => {
@@ -1212,7 +1212,7 @@ const commands = {
1212
1212
  console.log(target.error);
1213
1213
  return;
1214
1214
  }
1215
- const targetProject = target?.project || projects[0];
1215
+ const targetProject = target?.project || (projects.length > 0 ? projects[0] : null);
1216
1216
 
1217
1217
  if (!targetProject) {
1218
1218
  console.log('No projects configured. Run: minions add <dir>');
package/engine/github.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  const shared = require('./shared');
8
- const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeWrite, mutateJsonFileLocked, MINIONS_DIR, getPrLinks, backfillPrPrdItems, log, ts, dateStamp, PR_STATUS, PR_POLLABLE_STATUSES, createThrottleTracker, getProjectOrg } = shared;
8
+ const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeJsonArr, safeWrite, mutateJsonFileLocked, MINIONS_DIR, getPrLinks, backfillPrPrdItems, log, ts, dateStamp, PR_STATUS, PR_POLLABLE_STATUSES, createThrottleTracker, getProjectOrg } = shared;
9
9
  const { getPrs } = require('./queries');
10
10
  const path = require('path');
11
11
 
@@ -320,7 +320,7 @@ async function forEachActiveGhPr(config, callback) {
320
320
 
321
321
  // Also poll manually-linked PRs from central pull-requests.json (extract slug from URL)
322
322
  const centralPath = path.join(MINIONS_DIR, 'pull-requests.json');
323
- const centralPrs = safeJson(centralPath) || [];
323
+ const centralPrs = safeJsonArr(centralPath);
324
324
  const activeCentral = centralPrs.filter(pr => PR_POLLABLE_STATUSES.has(pr.status) && pr.url);
325
325
  let centralUpdated = 0;
326
326
  const updatedCentralRecords = [];
@@ -727,7 +727,7 @@ async function reconcilePrs(config) {
727
727
  if (existingPrs.length === 0) {
728
728
  try {
729
729
  const wiPath = projectWorkItemsPath(project);
730
- const wis = safeJson(wiPath) || [];
730
+ const wis = safeJsonArr(wiPath);
731
731
  if (wis.length === 0) continue;
732
732
  } catch { continue; }
733
733
  }
@@ -748,7 +748,7 @@ async function reconcilePrs(config) {
748
748
  if (ghPrs.length === 0) continue;
749
749
 
750
750
  const prPath = projectPrPath(project);
751
- const currentPrs = safeJson(prPath) || [];
751
+ const currentPrs = safeJsonArr(prPath);
752
752
  shared.normalizePrRecords(currentPrs, project);
753
753
  const existingIds = new Set(currentPrs.map(p => p.id));
754
754
  let metadataUpdated = 0;
@@ -756,9 +756,9 @@ async function reconcilePrs(config) {
756
756
 
757
757
  // Load work items to match branches
758
758
  const wiPath = projectWorkItemsPath(project);
759
- const workItems = safeJson(wiPath) || [];
759
+ const workItems = safeJsonArr(wiPath);
760
760
  const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
761
- const centralItems = safeJson(centralWiPath) || [];
761
+ const centralItems = safeJsonArr(centralWiPath);
762
762
  const allItems = [...workItems, ...centralItems];
763
763
 
764
764
  for (const ghPr of ghPrs) {
package/engine/shared.js CHANGED
@@ -1553,13 +1553,47 @@ const PR_PENDING_REASON = {
1553
1553
 
1554
1554
  // Watch statuses — engine-level persistent watches that survive restarts
1555
1555
  const WATCH_STATUS = { ACTIVE: 'active', PAUSED: 'paused', TRIGGERED: 'triggered', EXPIRED: 'expired' };
1556
- const WATCH_TARGET_TYPE = { PR: 'pr', WORK_ITEM: 'work-item' };
1557
- const WATCH_CONDITION = { MERGED: 'merged', BUILD_FAIL: 'build-fail', BUILD_PASS: 'build-pass', COMPLETED: 'completed', FAILED: 'failed', STATUS_CHANGE: 'status-change', ANY: 'any', NEW_COMMENTS: 'new-comments', VOTE_CHANGE: 'vote-change' };
1556
+ // Built-in target types. Additional types may be registered at runtime via
1557
+ // engine/watches.registerTargetType this object lists the types shipped in
1558
+ // the engine for use in switch statements and constants comparisons. The
1559
+ // actual allowlist for createWatch/dashboard validation is the registry in
1560
+ // engine/watches.js (see getTargetTypes()).
1561
+ const WATCH_TARGET_TYPE = {
1562
+ PR: 'pr',
1563
+ WORK_ITEM: 'work-item',
1564
+ MEETING: 'meeting',
1565
+ PLAN: 'plan',
1566
+ SCHEDULE: 'schedule',
1567
+ PIPELINE: 'pipeline',
1568
+ DISPATCH: 'dispatch',
1569
+ AGENT: 'agent',
1570
+ };
1571
+ const WATCH_CONDITION = {
1572
+ MERGED: 'merged',
1573
+ BUILD_FAIL: 'build-fail',
1574
+ BUILD_PASS: 'build-pass',
1575
+ COMPLETED: 'completed',
1576
+ FAILED: 'failed',
1577
+ STATUS_CHANGE: 'status-change',
1578
+ ANY: 'any',
1579
+ NEW_COMMENTS: 'new-comments',
1580
+ VOTE_CHANGE: 'vote-change',
1581
+ // ── Generalized target-type conditions ────────────────────────────────────
1582
+ CONCLUDED: 'concluded', // meeting reached terminal status (completed/archived)
1583
+ APPROVED: 'approved', // plan/PRD approved
1584
+ REJECTED: 'rejected', // plan/PRD rejected
1585
+ STAGE_COMPLETE: 'stage-complete', // pipeline run finished a stage (any new stage flipped to terminal)
1586
+ RAN: 'ran', // schedule lastRun changed
1587
+ ENABLED: 'enabled', // schedule enabled
1588
+ DISABLED: 'disabled', // schedule disabled
1589
+ ACTIVITY_CHANGE: 'activity-change', // agent transitioned status (e.g. idle → working)
1590
+ };
1558
1591
  // Absolute conditions auto-expire on first trigger when stopAfter=0 (fire-once semantics).
1559
- // Change-based conditions (status-change, any) run forever when stopAfter=0.
1592
+ // Change-based conditions (status-change, any, *-change) run forever when stopAfter=0.
1560
1593
  const WATCH_ABSOLUTE_CONDITIONS = new Set([
1561
1594
  WATCH_CONDITION.MERGED, WATCH_CONDITION.BUILD_FAIL, WATCH_CONDITION.BUILD_PASS,
1562
1595
  WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED,
1596
+ WATCH_CONDITION.CONCLUDED, WATCH_CONDITION.APPROVED, WATCH_CONDITION.REJECTED,
1563
1597
  ]);
1564
1598
 
1565
1599
  /** Update per-agent review metrics (prsApproved/prsRejected). Only writes for configured agents. */
package/engine/teams.js CHANGED
@@ -8,7 +8,7 @@ const path = require('path');
8
8
  const shared = require('./shared');
9
9
  const queries = require('./queries');
10
10
 
11
- const { log, safeRead, safeJson, mutateJsonFileLocked, ENGINE_DEFAULTS } = shared;
11
+ const { log, safeRead, safeJson, safeJsonObj, mutateJsonFileLocked, ENGINE_DEFAULTS } = shared;
12
12
  const { ENGINE_DIR, getConfig } = queries;
13
13
  const cards = require('./teams-cards');
14
14
 
@@ -150,7 +150,7 @@ function saveConversationRef(key, ref) {
150
150
  */
151
151
  function getConversationRef(key) {
152
152
  if (!key) return null;
153
- const state = safeJson(TEAMS_STATE_PATH) || {};
153
+ const state = safeJsonObj(TEAMS_STATE_PATH);
154
154
  return state.conversations?.[key]?.ref || null;
155
155
  }
156
156
 
@@ -466,7 +466,7 @@ async function teamsPostCCResponse(userMessage, ccResponse) {
466
466
  const card = cards.buildCCResponseCard(userMessage, ccResponse);
467
467
 
468
468
  // Find first available conversation ref
469
- const state = safeJson(TEAMS_STATE_PATH) || {};
469
+ const state = safeJsonObj(TEAMS_STATE_PATH);
470
470
  const convKeys = Object.keys(state.conversations || {});
471
471
  if (convKeys.length === 0) {
472
472
  log('info', 'Teams CC mirror skipped — no conversation refs stored');
@@ -498,7 +498,7 @@ async function teamsNotifyCompletion(dispatchItem, result, agentId) {
498
498
  const card = cards.buildCompletionCard(agentId, item, result, prUrl || undefined);
499
499
 
500
500
  // Find first available conversation ref
501
- const state = safeJson(TEAMS_STATE_PATH) || {};
501
+ const state = safeJsonObj(TEAMS_STATE_PATH);
502
502
  const convKeys = Object.keys(state.conversations || {});
503
503
  if (convKeys.length === 0) return;
504
504
 
@@ -531,7 +531,7 @@ async function teamsNotifyPrEvent(pr, event, project, prFilePath) {
531
531
  const card = cards.buildPrCard(pr, event, project);
532
532
 
533
533
  // Find first available conversation ref
534
- const state = safeJson(TEAMS_STATE_PATH) || {};
534
+ const state = safeJsonObj(TEAMS_STATE_PATH);
535
535
  const convKeys = Object.keys(state.conversations || {});
536
536
  if (convKeys.length === 0) return;
537
537
 
@@ -593,7 +593,7 @@ async function teamsNotifyPlanEvent(planInfo, event) {
593
593
  const planName = planInfo.name || planInfo.file || 'Unknown plan';
594
594
  const card = cards.buildPlanCard(planInfo, event);
595
595
 
596
- const state = safeJson(TEAMS_STATE_PATH) || {};
596
+ const state = safeJsonObj(TEAMS_STATE_PATH);
597
597
  const convKeys = Object.keys(state.conversations || {});
598
598
  if (convKeys.length === 0) return;
599
599
 
package/engine/watches.js CHANGED
@@ -2,15 +2,28 @@
2
2
  * engine/watches.js -- Persistent watch jobs that survive engine restarts.
3
3
  * Zero dependencies -- uses only Node.js built-ins + engine/shared.js.
4
4
  *
5
- * Watches monitor targets (PRs, work items, branches) for conditions (merged,
6
- * build-fail, completed, etc.) and fire inbox notifications when triggered.
5
+ * Watches monitor targets (PRs, work items, meetings, plans, schedules,
6
+ * pipelines, dispatches, agents, ...) for conditions (merged, build-fail,
7
+ * completed, concluded, approved, etc.) and fire inbox notifications when
8
+ * triggered.
9
+ *
10
+ * Target-type behavior is data-driven via a registry — see TARGET_TYPES below.
11
+ * Each registered target type provides:
12
+ * - label: human-friendly name shown in dashboard pickers
13
+ * - conditions: array of condition keys this target type accepts
14
+ * - fetchEntity: (target, state) => entity-or-null lookup
15
+ * - captureState: (entity) => snapshot for change-detection diffing
16
+ * - evaluate: (condition, entity, prevState, target) => { triggered, message }
17
+ *
18
+ * Built-in types: pr, work-item, meeting, plan, schedule, pipeline, dispatch,
19
+ * agent. Additional types can be added at runtime via registerTargetType().
7
20
  *
8
21
  * State stored in engine/watches.json — concurrency-safe via mutateJsonFileLocked.
9
22
  */
10
23
 
11
24
  const path = require('path');
12
25
  const shared = require('./shared');
13
- const { safeJson, mutateJsonFileLocked, ts, uid, log, writeToInbox,
26
+ const { safeJsonArr, mutateJsonFileLocked, ts, uid, log, writeToInbox,
14
27
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS } = shared;
15
28
 
16
29
  // Dynamic path — respects MINIONS_TEST_DIR for test isolation
@@ -28,12 +41,57 @@ function findPrByTarget(pullRequests, target) {
28
41
  );
29
42
  }
30
43
 
44
+ // ── Target type registry ─────────────────────────────────────────────────────
45
+ // Built-in target types are registered at module load (see bottom of file).
46
+ // Additional types can be added at runtime via registerTargetType(). The
47
+ // registry IS the allowlist for createWatch / dashboard validation — the old
48
+ // hardcoded "pr or work-item" check is gone.
49
+ const TARGET_TYPES = {};
50
+
51
+ /**
52
+ * Register a watch target type.
53
+ * @param {string} type - Unique key (e.g. 'pr', 'meeting', 'custom-thing')
54
+ * @param {object} spec - { label, conditions, fetchEntity, captureState, evaluate }
55
+ * - label: string shown in dashboard pickers
56
+ * - conditions: array of condition strings the type accepts
57
+ * - fetchEntity: (target, state) => entity-or-null
58
+ * - captureState: (entity) => snapshot object
59
+ * - evaluate: (condition, entity, prevState, target) => { triggered, message }
60
+ */
61
+ function registerTargetType(type, spec) {
62
+ if (!type || typeof type !== 'string') throw new Error('registerTargetType: type must be a non-empty string');
63
+ if (!spec || typeof spec !== 'object') throw new Error('registerTargetType: spec must be an object');
64
+ for (const k of ['label', 'conditions', 'fetchEntity', 'captureState', 'evaluate']) {
65
+ if (spec[k] === undefined) throw new Error(`registerTargetType(${type}): spec.${k} is required`);
66
+ }
67
+ if (!Array.isArray(spec.conditions) || spec.conditions.length === 0) {
68
+ throw new Error(`registerTargetType(${type}): spec.conditions must be a non-empty array`);
69
+ }
70
+ TARGET_TYPES[type] = spec;
71
+ }
72
+
73
+ /** Returns the registered spec for a target type, or null. */
74
+ function getTargetType(type) { return TARGET_TYPES[type] || null; }
75
+
76
+ /**
77
+ * Returns the list of registered target types as a serializable array
78
+ * suitable for `GET /api/watches/target-types` and dashboard pickers.
79
+ */
80
+ function listTargetTypes() {
81
+ return Object.keys(TARGET_TYPES).sort().map(key => ({
82
+ value: key,
83
+ label: TARGET_TYPES[key].label,
84
+ conditions: [...TARGET_TYPES[key].conditions],
85
+ description: TARGET_TYPES[key].description || '',
86
+ }));
87
+ }
88
+
31
89
  /**
32
90
  * Read all watches from disk.
33
91
  * @returns {Array<object>}
34
92
  */
35
93
  function getWatches() {
36
- return safeJson(_watchesPath()) || [];
94
+ return safeJsonArr(_watchesPath());
37
95
  }
38
96
 
39
97
  /**
@@ -43,11 +101,12 @@ function getWatches() {
43
101
  */
44
102
  function createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet }) {
45
103
  if (!target) throw new Error('target is required');
46
- if (!targetType || !Object.values(WATCH_TARGET_TYPE).includes(targetType)) {
47
- throw new Error(`targetType must be one of: ${Object.values(WATCH_TARGET_TYPE).join(', ')}`);
104
+ if (!targetType || !TARGET_TYPES[targetType]) {
105
+ throw new Error(`targetType must be one of: ${Object.keys(TARGET_TYPES).sort().join(', ')}`);
48
106
  }
49
- if (!condition || !Object.values(WATCH_CONDITION).includes(condition)) {
50
- throw new Error(`condition must be one of: ${Object.values(WATCH_CONDITION).join(', ')}`);
107
+ const tt = TARGET_TYPES[targetType];
108
+ if (!condition || !tt.conditions.includes(condition)) {
109
+ throw new Error(`condition must be one of: ${tt.conditions.join(', ')} (for targetType ${targetType})`);
51
110
  }
52
111
 
53
112
  const watch = {
@@ -131,79 +190,27 @@ function deleteWatch(id) {
131
190
 
132
191
  /**
133
192
  * Evaluate whether a watch condition is met given current state.
193
+ * Dispatches to the registered target type's evaluator.
134
194
  * @param {object} watch - The watch object
135
- * @param {object} state - { pullRequests, workItems } current state
195
+ * @param {object} state - { pullRequests, workItems, meetings?, plans?, schedules?, scheduleRuns?, pipelineRuns?, dispatch?, agents?, config? }
136
196
  * @returns {{ triggered: boolean, message: string }}
137
197
  */
138
198
  function evaluateWatch(watch, state) {
139
199
  const { target, targetType, condition } = watch;
140
-
141
- if (targetType === WATCH_TARGET_TYPE.PR) {
142
- const pr = findPrByTarget(state.pullRequests, target);
143
- if (!pr) return { triggered: false, message: `PR ${target} not found` };
144
-
145
- // Store previous state for status-change detection
146
- const prevState = watch._lastState || {};
147
-
148
- switch (condition) {
149
- case WATCH_CONDITION.MERGED:
150
- return { triggered: pr.status === 'merged', message: pr.status === 'merged' ? `PR ${target} was merged` : '' };
151
- case WATCH_CONDITION.BUILD_FAIL:
152
- return { triggered: pr.buildStatus === 'failing', message: pr.buildStatus === 'failing' ? `PR ${target} build is failing` : '' };
153
- case WATCH_CONDITION.BUILD_PASS:
154
- return { triggered: pr.buildStatus === 'passing', message: pr.buildStatus === 'passing' ? `PR ${target} build is passing` : '' };
155
- case WATCH_CONDITION.STATUS_CHANGE: {
156
- const changed = prevState.status !== undefined && prevState.status !== pr.status;
157
- return { triggered: changed, message: changed ? `PR ${target} status changed: ${prevState.status} → ${pr.status}` : '' };
158
- }
159
- case WATCH_CONDITION.ANY: {
160
- const anyChanged = prevState.status !== undefined && (
161
- prevState.status !== pr.status ||
162
- prevState.buildStatus !== pr.buildStatus ||
163
- prevState.reviewStatus !== pr.reviewStatus
164
- );
165
- return { triggered: anyChanged, message: anyChanged ? `PR ${target} changed` : '' };
166
- }
167
- case WATCH_CONDITION.NEW_COMMENTS: {
168
- const lastCommentDate = pr.humanFeedback?.lastProcessedCommentDate || null;
169
- const prevCommentDate = prevState.lastCommentDate || null;
170
- const hasNew = lastCommentDate && lastCommentDate !== prevCommentDate;
171
- return { triggered: !!hasNew, message: hasNew ? `PR ${target} has a new comment (${lastCommentDate})` : '' };
172
- }
173
- case WATCH_CONDITION.VOTE_CHANGE: {
174
- const changed = prevState.reviewStatus !== undefined && prevState.reviewStatus !== pr.reviewStatus;
175
- return { triggered: changed, message: changed ? `PR ${target} vote changed: ${prevState.reviewStatus} → ${pr.reviewStatus}` : '' };
176
- }
177
- default:
178
- return { triggered: false, message: `Unknown condition: ${condition}` };
179
- }
200
+ const tt = TARGET_TYPES[targetType];
201
+ if (!tt) return { triggered: false, message: `Unknown target type: ${targetType}` };
202
+ if (!tt.conditions.includes(condition)) return { triggered: false, message: `Unknown condition: ${condition}` };
203
+
204
+ const entity = tt.fetchEntity(target, state || {});
205
+ if (!entity) return { triggered: false, message: `${tt.label} ${target} not found` };
206
+
207
+ const prevState = watch._lastState || {};
208
+ try {
209
+ return tt.evaluate(condition, entity, prevState, target) || { triggered: false, message: '' };
210
+ } catch (err) {
211
+ log('warn', `evaluateWatch ${targetType}/${condition}: ${err.message}`);
212
+ return { triggered: false, message: `evaluator error: ${err.message}` };
180
213
  }
181
-
182
- if (targetType === WATCH_TARGET_TYPE.WORK_ITEM) {
183
- const wi = (state.workItems || []).find(w => w.id === target);
184
- if (!wi) return { triggered: false, message: `Work item ${target} not found` };
185
-
186
- const prevState = watch._lastState || {};
187
-
188
- switch (condition) {
189
- case WATCH_CONDITION.COMPLETED:
190
- return { triggered: shared.DONE_STATUSES.has(wi.status), message: shared.DONE_STATUSES.has(wi.status) ? `Work item ${target} completed (${wi.status})` : '' };
191
- case WATCH_CONDITION.FAILED:
192
- return { triggered: wi.status === shared.WI_STATUS.FAILED, message: wi.status === shared.WI_STATUS.FAILED ? `Work item ${target} failed` : '' };
193
- case WATCH_CONDITION.STATUS_CHANGE: {
194
- const changed = prevState.status !== undefined && prevState.status !== wi.status;
195
- return { triggered: changed, message: changed ? `Work item ${target} status: ${prevState.status} → ${wi.status}` : '' };
196
- }
197
- case WATCH_CONDITION.ANY: {
198
- const anyChanged = prevState.status !== undefined && prevState.status !== wi.status;
199
- return { triggered: anyChanged, message: anyChanged ? `Work item ${target} changed (${wi.status})` : '' };
200
- }
201
- default:
202
- return { triggered: false, message: `Unknown condition: ${condition}` };
203
- }
204
- }
205
-
206
- return { triggered: false, message: `Unknown target type: ${targetType}` };
207
214
  }
208
215
 
209
216
  /**
@@ -293,18 +300,374 @@ function checkWatches(config, state) {
293
300
 
294
301
  /**
295
302
  * Internal: capture state snapshot for a watch target.
303
+ * Dispatches to the registered target type's captureState.
296
304
  */
297
305
  function _captureState(watch, state) {
298
- if (watch.targetType === WATCH_TARGET_TYPE.PR) {
299
- const pr = findPrByTarget(state.pullRequests, watch.target);
300
- if (pr) return { status: pr.status, buildStatus: pr.buildStatus, reviewStatus: pr.reviewStatus, lastCommentDate: pr.humanFeedback?.lastProcessedCommentDate || null };
306
+ const tt = TARGET_TYPES[watch.targetType];
307
+ if (!tt) return {};
308
+ const entity = tt.fetchEntity(watch.target, state || {});
309
+ if (!entity) return {};
310
+ try {
311
+ return tt.captureState(entity) || {};
312
+ } catch (err) {
313
+ log('warn', `_captureState ${watch.targetType}: ${err.message}`);
314
+ return {};
301
315
  }
302
- if (watch.targetType === WATCH_TARGET_TYPE.WORK_ITEM) {
303
- const wi = (state.workItems || []).find(w => w.id === watch.target);
304
- if (wi) return { status: wi.status };
316
+ }
317
+
318
+ // ── Built-in target type registrations ───────────────────────────────────────
319
+
320
+ // PR — pull request status / build / comments / votes
321
+ registerTargetType(WATCH_TARGET_TYPE.PR, {
322
+ label: 'Pull Request',
323
+ description: 'Watch a pull request for merge, build, comment, or vote changes',
324
+ conditions: [
325
+ WATCH_CONDITION.MERGED, WATCH_CONDITION.BUILD_FAIL, WATCH_CONDITION.BUILD_PASS,
326
+ WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
327
+ WATCH_CONDITION.NEW_COMMENTS, WATCH_CONDITION.VOTE_CHANGE,
328
+ ],
329
+ fetchEntity: (target, state) => findPrByTarget(state.pullRequests, target),
330
+ captureState: (pr) => ({
331
+ status: pr.status, buildStatus: pr.buildStatus, reviewStatus: pr.reviewStatus,
332
+ lastCommentDate: pr.humanFeedback?.lastProcessedCommentDate || null,
333
+ }),
334
+ evaluate: (condition, pr, prevState, target) => {
335
+ switch (condition) {
336
+ case WATCH_CONDITION.MERGED:
337
+ return { triggered: pr.status === 'merged', message: pr.status === 'merged' ? `PR ${target} was merged` : '' };
338
+ case WATCH_CONDITION.BUILD_FAIL:
339
+ return { triggered: pr.buildStatus === 'failing', message: pr.buildStatus === 'failing' ? `PR ${target} build is failing` : '' };
340
+ case WATCH_CONDITION.BUILD_PASS:
341
+ return { triggered: pr.buildStatus === 'passing', message: pr.buildStatus === 'passing' ? `PR ${target} build is passing` : '' };
342
+ case WATCH_CONDITION.STATUS_CHANGE: {
343
+ const changed = prevState.status !== undefined && prevState.status !== pr.status;
344
+ return { triggered: changed, message: changed ? `PR ${target} status changed: ${prevState.status} → ${pr.status}` : '' };
345
+ }
346
+ case WATCH_CONDITION.ANY: {
347
+ const anyChanged = prevState.status !== undefined && (
348
+ prevState.status !== pr.status ||
349
+ prevState.buildStatus !== pr.buildStatus ||
350
+ prevState.reviewStatus !== pr.reviewStatus
351
+ );
352
+ return { triggered: anyChanged, message: anyChanged ? `PR ${target} changed` : '' };
353
+ }
354
+ case WATCH_CONDITION.NEW_COMMENTS: {
355
+ const lastCommentDate = pr.humanFeedback?.lastProcessedCommentDate || null;
356
+ const prevCommentDate = prevState.lastCommentDate || null;
357
+ const hasNew = lastCommentDate && lastCommentDate !== prevCommentDate;
358
+ return { triggered: !!hasNew, message: hasNew ? `PR ${target} has a new comment (${lastCommentDate})` : '' };
359
+ }
360
+ case WATCH_CONDITION.VOTE_CHANGE: {
361
+ const changed = prevState.reviewStatus !== undefined && prevState.reviewStatus !== pr.reviewStatus;
362
+ return { triggered: changed, message: changed ? `PR ${target} vote changed: ${prevState.reviewStatus} → ${pr.reviewStatus}` : '' };
363
+ }
364
+ default:
365
+ return { triggered: false, message: `Unknown condition: ${condition}` };
366
+ }
367
+ },
368
+ });
369
+
370
+ // Work item — completed/failed/status-change
371
+ registerTargetType(WATCH_TARGET_TYPE.WORK_ITEM, {
372
+ label: 'Work Item',
373
+ description: 'Watch a work item for completion, failure, or status changes',
374
+ conditions: [
375
+ WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED,
376
+ WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
377
+ ],
378
+ fetchEntity: (target, state) => (state.workItems || []).find(w => w.id === target) || null,
379
+ captureState: (wi) => ({ status: wi.status }),
380
+ evaluate: (condition, wi, prevState, target) => {
381
+ switch (condition) {
382
+ case WATCH_CONDITION.COMPLETED: {
383
+ const done = shared.DONE_STATUSES.has(wi.status);
384
+ return { triggered: done, message: done ? `Work item ${target} completed (${wi.status})` : '' };
385
+ }
386
+ case WATCH_CONDITION.FAILED: {
387
+ const failed = wi.status === shared.WI_STATUS.FAILED;
388
+ return { triggered: failed, message: failed ? `Work item ${target} failed` : '' };
389
+ }
390
+ case WATCH_CONDITION.STATUS_CHANGE: {
391
+ const changed = prevState.status !== undefined && prevState.status !== wi.status;
392
+ return { triggered: changed, message: changed ? `Work item ${target} status: ${prevState.status} → ${wi.status}` : '' };
393
+ }
394
+ case WATCH_CONDITION.ANY: {
395
+ const anyChanged = prevState.status !== undefined && prevState.status !== wi.status;
396
+ return { triggered: anyChanged, message: anyChanged ? `Work item ${target} changed (${wi.status})` : '' };
397
+ }
398
+ default:
399
+ return { triggered: false, message: `Unknown condition: ${condition}` };
400
+ }
401
+ },
402
+ });
403
+
404
+ // Meeting — concluded (terminal status) / status-change / any
405
+ const MEETING_TERMINAL = new Set(['completed', 'archived']);
406
+ registerTargetType(WATCH_TARGET_TYPE.MEETING, {
407
+ label: 'Meeting',
408
+ description: 'Watch a meeting for conclusion or status changes',
409
+ conditions: [WATCH_CONDITION.CONCLUDED, WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY],
410
+ fetchEntity: (target, state) => (state.meetings || []).find(m => m && m.id === target) || null,
411
+ captureState: (m) => ({ status: m.status }),
412
+ evaluate: (condition, m, prevState, target) => {
413
+ switch (condition) {
414
+ case WATCH_CONDITION.CONCLUDED: {
415
+ const concluded = MEETING_TERMINAL.has(String(m.status || '').toLowerCase());
416
+ return { triggered: concluded, message: concluded ? `Meeting ${target} concluded (${m.status})` : '' };
417
+ }
418
+ case WATCH_CONDITION.STATUS_CHANGE: {
419
+ const changed = prevState.status !== undefined && prevState.status !== m.status;
420
+ return { triggered: changed, message: changed ? `Meeting ${target} status: ${prevState.status} → ${m.status}` : '' };
421
+ }
422
+ case WATCH_CONDITION.ANY: {
423
+ const anyChanged = prevState.status !== undefined && prevState.status !== m.status;
424
+ return { triggered: anyChanged, message: anyChanged ? `Meeting ${target} changed (${m.status})` : '' };
425
+ }
426
+ default:
427
+ return { triggered: false, message: `Unknown condition: ${condition}` };
428
+ }
429
+ },
430
+ });
431
+
432
+ // Plan / PRD — approved/rejected/completed/status-change/any
433
+ function _findPlan(target, state) {
434
+ const t = String(target);
435
+ return (state.plans || []).find(p => {
436
+ if (!p) return false;
437
+ if (p._source === t || p._source === t + '.json') return true;
438
+ if (p._sourcePlan === t || p._sourcePlan === t + '.md') return true;
439
+ if (p.id === t) return true;
440
+ return false;
441
+ }) || null;
442
+ }
443
+ registerTargetType(WATCH_TARGET_TYPE.PLAN, {
444
+ label: 'Plan / PRD',
445
+ description: 'Watch a plan/PRD for approval, rejection, completion, or status changes',
446
+ conditions: [
447
+ WATCH_CONDITION.APPROVED, WATCH_CONDITION.REJECTED, WATCH_CONDITION.COMPLETED,
448
+ WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
449
+ ],
450
+ fetchEntity: _findPlan,
451
+ captureState: (p) => ({ status: p.status, planStale: !!p.planStale }),
452
+ evaluate: (condition, p, prevState, target) => {
453
+ const status = String(p.status || '').toLowerCase();
454
+ switch (condition) {
455
+ case WATCH_CONDITION.APPROVED:
456
+ return { triggered: status === shared.PLAN_STATUS.APPROVED, message: status === shared.PLAN_STATUS.APPROVED ? `Plan ${target} approved` : '' };
457
+ case WATCH_CONDITION.REJECTED:
458
+ return { triggered: status === shared.PLAN_STATUS.REJECTED, message: status === shared.PLAN_STATUS.REJECTED ? `Plan ${target} rejected` : '' };
459
+ case WATCH_CONDITION.COMPLETED:
460
+ return { triggered: status === shared.PLAN_STATUS.COMPLETED, message: status === shared.PLAN_STATUS.COMPLETED ? `Plan ${target} completed` : '' };
461
+ case WATCH_CONDITION.STATUS_CHANGE: {
462
+ const changed = prevState.status !== undefined && prevState.status !== p.status;
463
+ return { triggered: changed, message: changed ? `Plan ${target} status: ${prevState.status} → ${p.status}` : '' };
464
+ }
465
+ case WATCH_CONDITION.ANY: {
466
+ const anyChanged = prevState.status !== undefined &&
467
+ (prevState.status !== p.status || prevState.planStale !== !!p.planStale);
468
+ return { triggered: anyChanged, message: anyChanged ? `Plan ${target} changed (${p.status})` : '' };
469
+ }
470
+ default:
471
+ return { triggered: false, message: `Unknown condition: ${condition}` };
472
+ }
473
+ },
474
+ });
475
+
476
+ // Schedule — ran (lastRun changed) / enabled / disabled / status-change / any
477
+ function _findSchedule(target, state) {
478
+ const schedules = (state.config?.schedules) || state.schedules || [];
479
+ const sched = schedules.find(s => s && s.id === target) || null;
480
+ if (!sched) return null;
481
+ const runEntry = (state.scheduleRuns || {})[target];
482
+ const lastRun = runEntry && typeof runEntry === 'object' ? runEntry.lastRun : (typeof runEntry === 'string' ? runEntry : null);
483
+ // Synthesize an entity that combines schedule config + run state
484
+ return { ...sched, _lastRun: lastRun || null };
485
+ }
486
+ registerTargetType(WATCH_TARGET_TYPE.SCHEDULE, {
487
+ label: 'Schedule',
488
+ description: 'Watch a scheduled task for runs or enable/disable changes',
489
+ conditions: [
490
+ WATCH_CONDITION.RAN, WATCH_CONDITION.ENABLED, WATCH_CONDITION.DISABLED,
491
+ WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
492
+ ],
493
+ fetchEntity: _findSchedule,
494
+ captureState: (s) => ({ lastRun: s._lastRun || null, enabled: s.enabled !== false }),
495
+ evaluate: (condition, s, prevState, target) => {
496
+ const enabled = s.enabled !== false;
497
+ const lastRun = s._lastRun || null;
498
+ switch (condition) {
499
+ case WATCH_CONDITION.RAN: {
500
+ const ran = !!lastRun && lastRun !== (prevState.lastRun || null);
501
+ return { triggered: ran, message: ran ? `Schedule ${target} ran (${lastRun})` : '' };
502
+ }
503
+ case WATCH_CONDITION.ENABLED: {
504
+ const flipped = prevState.enabled === false && enabled === true;
505
+ return { triggered: flipped, message: flipped ? `Schedule ${target} was enabled` : '' };
506
+ }
507
+ case WATCH_CONDITION.DISABLED: {
508
+ const flipped = prevState.enabled === true && enabled === false;
509
+ return { triggered: flipped, message: flipped ? `Schedule ${target} was disabled` : '' };
510
+ }
511
+ case WATCH_CONDITION.STATUS_CHANGE: {
512
+ const changed = prevState.enabled !== undefined && prevState.enabled !== enabled;
513
+ return { triggered: changed, message: changed ? `Schedule ${target} enabled changed: ${prevState.enabled} → ${enabled}` : '' };
514
+ }
515
+ case WATCH_CONDITION.ANY: {
516
+ const anyChanged = prevState.enabled !== undefined &&
517
+ (prevState.enabled !== enabled || prevState.lastRun !== lastRun);
518
+ return { triggered: anyChanged, message: anyChanged ? `Schedule ${target} changed` : '' };
519
+ }
520
+ default:
521
+ return { triggered: false, message: `Unknown condition: ${condition}` };
522
+ }
523
+ },
524
+ });
525
+
526
+ // Pipeline — completed/failed/stage-complete/status-change/any. Target is
527
+ // the pipeline ID; we track the latest run.
528
+ function _findPipelineLatestRun(target, state) {
529
+ const runsByPipeline = state.pipelineRuns || {};
530
+ const list = runsByPipeline[target];
531
+ if (!Array.isArray(list) || list.length === 0) return null;
532
+ // The latest run is the last entry (startRun pushes; cleanup keeps last 10).
533
+ return { ...list[list.length - 1], _pipelineId: target };
534
+ }
535
+ function _completedStageCount(run) {
536
+ if (!run || !run.stages) return 0;
537
+ let n = 0;
538
+ for (const st of Object.values(run.stages)) {
539
+ const s = String((st && st.status) || '').toLowerCase();
540
+ if (s === 'completed' || s === 'failed' || s === 'stopped') n += 1;
305
541
  }
306
- return {};
542
+ return n;
307
543
  }
544
+ registerTargetType(WATCH_TARGET_TYPE.PIPELINE, {
545
+ label: 'Pipeline',
546
+ description: 'Watch the latest run of a pipeline for completion, failure, or stage progress',
547
+ conditions: [
548
+ WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED, WATCH_CONDITION.STAGE_COMPLETE,
549
+ WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
550
+ ],
551
+ fetchEntity: _findPipelineLatestRun,
552
+ captureState: (run) => ({
553
+ runId: run.runId || null, status: run.status || null,
554
+ completedStages: _completedStageCount(run),
555
+ }),
556
+ evaluate: (condition, run, prevState, target) => {
557
+ switch (condition) {
558
+ case WATCH_CONDITION.COMPLETED: {
559
+ const done = run.status === shared.PIPELINE_STATUS.COMPLETED;
560
+ return { triggered: done, message: done ? `Pipeline ${target} completed (run ${run.runId})` : '' };
561
+ }
562
+ case WATCH_CONDITION.FAILED: {
563
+ const failed = run.status === shared.PIPELINE_STATUS.FAILED || run.status === shared.PIPELINE_STATUS.STOPPED;
564
+ return { triggered: failed, message: failed ? `Pipeline ${target} failed (${run.status}, run ${run.runId})` : '' };
565
+ }
566
+ case WATCH_CONDITION.STAGE_COMPLETE: {
567
+ const cur = _completedStageCount(run);
568
+ const prev = prevState.completedStages || 0;
569
+ const advanced = prevState.completedStages !== undefined && cur > prev && (prevState.runId === run.runId);
570
+ return { triggered: advanced, message: advanced ? `Pipeline ${target} stage progress: ${prev} → ${cur}` : '' };
571
+ }
572
+ case WATCH_CONDITION.STATUS_CHANGE: {
573
+ const changed = prevState.status !== undefined && prevState.status !== run.status;
574
+ return { triggered: changed, message: changed ? `Pipeline ${target} status: ${prevState.status} → ${run.status}` : '' };
575
+ }
576
+ case WATCH_CONDITION.ANY: {
577
+ const anyChanged = prevState.status !== undefined &&
578
+ (prevState.status !== run.status || prevState.runId !== run.runId ||
579
+ (prevState.completedStages || 0) !== _completedStageCount(run));
580
+ return { triggered: anyChanged, message: anyChanged ? `Pipeline ${target} changed (${run.status})` : '' };
581
+ }
582
+ default:
583
+ return { triggered: false, message: `Unknown condition: ${condition}` };
584
+ }
585
+ },
586
+ });
587
+
588
+ // Dispatch — completed/failed/status-change/any. Target is dispatch entry id.
589
+ function _findDispatchEntry(target, state) {
590
+ const d = state.dispatch || {};
591
+ for (const list of ['active', 'pending', 'completed']) {
592
+ const entry = (d[list] || []).find(e => e && e.id === target);
593
+ if (entry) return { ...entry, _list: list };
594
+ }
595
+ return null;
596
+ }
597
+ const DISPATCH_TERMINAL = new Set(['done', 'completed', 'failed', 'cancelled']);
598
+ registerTargetType(WATCH_TARGET_TYPE.DISPATCH, {
599
+ label: 'Dispatch',
600
+ description: 'Watch a dispatch entry for terminal status or status changes',
601
+ conditions: [
602
+ WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED,
603
+ WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
604
+ ],
605
+ fetchEntity: _findDispatchEntry,
606
+ captureState: (e) => ({ status: e.status || e._list || null, list: e._list || null }),
607
+ evaluate: (condition, e, prevState, target) => {
608
+ const status = String(e.status || '').toLowerCase();
609
+ const list = e._list || null;
610
+ switch (condition) {
611
+ case WATCH_CONDITION.COMPLETED: {
612
+ const done = list === 'completed' && (status === '' || (DISPATCH_TERMINAL.has(status) && status !== 'failed'));
613
+ return { triggered: done, message: done ? `Dispatch ${target} completed` : '' };
614
+ }
615
+ case WATCH_CONDITION.FAILED: {
616
+ const failed = status === 'failed' || status === 'error';
617
+ return { triggered: failed, message: failed ? `Dispatch ${target} failed` : '' };
618
+ }
619
+ case WATCH_CONDITION.STATUS_CHANGE: {
620
+ const changed = prevState.status !== undefined && prevState.status !== (e.status || list);
621
+ return { triggered: changed, message: changed ? `Dispatch ${target} status: ${prevState.status} → ${e.status || list}` : '' };
622
+ }
623
+ case WATCH_CONDITION.ANY: {
624
+ const cur = e.status || list;
625
+ const anyChanged = prevState.status !== undefined &&
626
+ (prevState.status !== cur || prevState.list !== list);
627
+ return { triggered: anyChanged, message: anyChanged ? `Dispatch ${target} changed (${cur})` : '' };
628
+ }
629
+ default:
630
+ return { triggered: false, message: `Unknown condition: ${condition}` };
631
+ }
632
+ },
633
+ });
634
+
635
+ // Agent — activity-change / status-change / any. State.agents must be an
636
+ // array of agent objects (id + status), as produced by queries.getAgents.
637
+ function _findAgent(target, state) {
638
+ return (state.agents || []).find(a => a && a.id === target) || null;
639
+ }
640
+ registerTargetType(WATCH_TARGET_TYPE.AGENT, {
641
+ label: 'Agent',
642
+ description: 'Watch an agent for activity transitions (idle ↔ working) or status changes',
643
+ conditions: [
644
+ WATCH_CONDITION.ACTIVITY_CHANGE, WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
645
+ ],
646
+ fetchEntity: _findAgent,
647
+ captureState: (a) => ({ status: a.status || null }),
648
+ evaluate: (condition, a, prevState, target) => {
649
+ const cur = a.status || null;
650
+ switch (condition) {
651
+ case WATCH_CONDITION.ACTIVITY_CHANGE: {
652
+ // Triggers on transitions involving the 'working' status
653
+ const wasWorking = prevState.status === 'working';
654
+ const nowWorking = cur === 'working';
655
+ const changed = prevState.status !== undefined && wasWorking !== nowWorking;
656
+ return { triggered: changed, message: changed ? `Agent ${target} activity: ${prevState.status} → ${cur}` : '' };
657
+ }
658
+ case WATCH_CONDITION.STATUS_CHANGE: {
659
+ const changed = prevState.status !== undefined && prevState.status !== cur;
660
+ return { triggered: changed, message: changed ? `Agent ${target} status: ${prevState.status} → ${cur}` : '' };
661
+ }
662
+ case WATCH_CONDITION.ANY: {
663
+ const anyChanged = prevState.status !== undefined && prevState.status !== cur;
664
+ return { triggered: anyChanged, message: anyChanged ? `Agent ${target} changed (${cur})` : '' };
665
+ }
666
+ default:
667
+ return { triggered: false, message: `Unknown condition: ${condition}` };
668
+ }
669
+ },
670
+ });
308
671
 
309
672
  module.exports = {
310
673
  DEFAULT_WATCH_INTERVAL,
@@ -314,6 +677,10 @@ module.exports = {
314
677
  deleteWatch,
315
678
  evaluateWatch,
316
679
  checkWatches,
680
+ registerTargetType,
681
+ getTargetType,
682
+ listTargetTypes,
317
683
  _captureState, // exported for testing
318
684
  _watchesPath, // exported for testing — dynamic, respects MINIONS_TEST_DIR
685
+ _TARGET_TYPES: TARGET_TYPES, // exported for testing — direct registry access
319
686
  };
package/engine.js CHANGED
@@ -4419,7 +4419,46 @@ async function tickInner() {
4419
4419
  });
4420
4420
  // Also include central work items
4421
4421
  const centralWi = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
4422
- checkWatches(config, { pullRequests, workItems: [...workItems, ...centralWi] });
4422
+
4423
+ // Gather state for the new generalized target types. Each block is
4424
+ // best-effort — if a module/file is missing the watch evaluator will
4425
+ // simply report "not found" rather than crashing the tick.
4426
+ let meetings = [];
4427
+ try { meetings = require('./engine/meeting').getMeetings(); } catch { /* optional */ }
4428
+
4429
+ // Plans/PRDs — read PRD JSON files directly so plan-level watches see
4430
+ // status transitions (approved/rejected/completed/etc.). Each entry
4431
+ // carries its filename in _source for target lookup.
4432
+ const plans = [];
4433
+ try {
4434
+ const prdDir = path.join(MINIONS_DIR, 'prd');
4435
+ for (const dir of [prdDir, path.join(prdDir, 'archive')]) {
4436
+ if (!fs.existsSync(dir)) continue;
4437
+ for (const f of fs.readdirSync(dir).filter(x => x.endsWith('.json'))) {
4438
+ const plan = safeJson(path.join(dir, f));
4439
+ if (plan) plans.push({ ...plan, _source: f, _sourcePlan: plan.source_plan || '' });
4440
+ }
4441
+ }
4442
+ } catch { /* optional */ }
4443
+
4444
+ let scheduleRuns = {};
4445
+ try { scheduleRuns = safeJson(path.join(MINIONS_DIR, 'engine', 'schedule-runs.json')) || {}; } catch { /* optional */ }
4446
+
4447
+ let pipelineRuns = {};
4448
+ try { pipelineRuns = require('./engine/pipeline').getPipelineRuns(); } catch { /* optional */ }
4449
+
4450
+ let dispatch = { pending: [], active: [], completed: [] };
4451
+ try { dispatch = queries.getDispatch() || dispatch; } catch { /* optional */ }
4452
+
4453
+ let agents = [];
4454
+ try { agents = queries.getAgents(config) || []; } catch { /* optional */ }
4455
+
4456
+ checkWatches(config, {
4457
+ pullRequests,
4458
+ workItems: [...workItems, ...centralWi],
4459
+ meetings, plans, scheduleRuns, pipelineRuns, dispatch, agents,
4460
+ config,
4461
+ });
4423
4462
  });
4424
4463
  }
4425
4464
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1852",
3
+ "version": "0.1.1853",
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"