@yemi33/minions 0.1.1902 → 0.1.1903

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/engine/watches.js CHANGED
@@ -34,6 +34,13 @@ function _watchesPath() { return path.join(shared.MINIONS_DIR, 'engine', 'watche
34
34
  // so watches are checked every N ticks where N = ceil(interval / tickInterval).
35
35
  const DEFAULT_WATCH_INTERVAL = 300000;
36
36
 
37
+ // P-w12d8f3a — Phase 6.2: per-watch evaluation history is capped at 25
38
+ // entries (oldest-first). Bounded to keep watches.json compact:
39
+ // 25 entries × ~200 bytes × 100 watches ≈ 500 KB ceiling. History entries
40
+ // never contain full state snapshots — only the trigger reason — per the
41
+ // "history is small and bounded" design principle.
42
+ const WATCH_HISTORY_MAX = 25;
43
+
37
44
  /** Find a PR by target (prNumber, canonical ID, or display ID). */
38
45
  function findPrByTarget(pullRequests, target) {
39
46
  const t = String(target);
@@ -100,7 +107,7 @@ function getWatches() {
100
107
  * @param {object} opts - Watch definition
101
108
  * @returns {object} - Created watch
102
109
  */
103
- function createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet, action }) {
110
+ function createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet, action, requires }) {
104
111
  if (!target) throw new Error('target is required');
105
112
  if (!targetType || !TARGET_TYPES[targetType]) {
106
113
  throw new Error(`targetType must be one of: ${Object.keys(TARGET_TYPES).sort().join(', ')}`);
@@ -113,6 +120,11 @@ function createWatch({ target, targetType, condition, interval, owner, descripti
113
120
  const actionErr = watchActions.validateAction(action);
114
121
  if (actionErr) throw new Error(actionErr);
115
122
  }
123
+ // P-w10b6e2d — Phase 5: validate optional requires[] cross-target join.
124
+ if (requires !== undefined && requires !== null) {
125
+ const reqErr = _validateRequires(requires);
126
+ if (reqErr) throw new Error(reqErr);
127
+ }
116
128
 
117
129
  const watch = {
118
130
  id: 'watch-' + uid(),
@@ -128,10 +140,20 @@ function createWatch({ target, targetType, condition, interval, owner, descripti
128
140
  stopAfter: Number(stopAfter) || 0, // 0 = run forever; N = expire after N triggers
129
141
  onNotMet: onNotMet || null, // null | 'notify' — action per poll when condition not met
130
142
  action: action || null, // optional follow-up action — see engine/watch-actions.js
143
+ // P-w10b6e2d — Phase 5: optional requires[] — when present every entry
144
+ // must also evaluate truthy for the watch to fire. See evaluateWatch.
145
+ requires: Array.isArray(requires) ? requires.map(r => ({
146
+ target: r.target, targetType: r.targetType, condition: r.condition,
147
+ })) : null,
131
148
  triggerCount: 0,
132
149
  created_at: ts(),
133
150
  last_checked: null,
134
151
  last_triggered: null,
152
+ // P-w12d8f3a — Phase 6.2: bounded evaluation history (last
153
+ // WATCH_HISTORY_MAX=25 checks, oldest-first). Appended in checkWatches
154
+ // on every poll (fired AND not-fired). Powers GET /api/watches/:id/history
155
+ // (P-w13c2b6f) without needing a separate file.
156
+ _history: [],
135
157
  };
136
158
 
137
159
  mutateJsonFileLocked(_watchesPath(), (watches) => {
@@ -161,13 +183,19 @@ function updateWatch(id, updates) {
161
183
  const actionErr = watchActions.validateAction(updates.action);
162
184
  if (actionErr) throw new Error(actionErr);
163
185
  }
186
+ // P-w10b6e2d — Phase 5: validate requires[] before entering the lock so a
187
+ // bad shape never lands on disk. `[]` is allowed (clears the join).
188
+ if (updates.requires !== undefined && updates.requires !== null) {
189
+ const reqErr = _validateRequires(updates.requires);
190
+ if (reqErr) throw new Error(reqErr);
191
+ }
164
192
  let found = null;
165
193
  mutateJsonFileLocked(_watchesPath(), (watches) => {
166
194
  if (!Array.isArray(watches)) return watches;
167
195
  const watch = watches.find(w => w.id === id);
168
196
  if (!watch) return watches;
169
197
  // Only allow safe field updates
170
- const allowed = ['status', 'interval', 'description', 'notify', 'stopAfter', 'onNotMet', 'condition', 'action'];
198
+ const allowed = ['status', 'interval', 'description', 'notify', 'stopAfter', 'onNotMet', 'condition', 'action', 'requires'];
171
199
  for (const key of allowed) {
172
200
  if (updates[key] !== undefined) watch[key] = updates[key];
173
201
  }
@@ -204,6 +232,14 @@ function deleteWatch(id) {
204
232
  * @param {object} watch - The watch object
205
233
  * @param {object} state - { pullRequests, workItems, meetings?, plans?, schedules?, scheduleRuns?, pipelineRuns?, dispatch?, agents?, config? }
206
234
  * @returns {{ triggered: boolean, message: string }}
235
+ *
236
+ * P-w10b6e2d — Phase 5: when `watch.requires[]` is present, every entry
237
+ * must also evaluate truthy or the watch does not fire. Requirements are
238
+ * evaluated against the same `state` object via a recursive call into
239
+ * evaluateWatch with a synthetic mini-watch (no _lastState). The depth
240
+ * cap is 1 — `_validateRequires` rejects requirement entries that
241
+ * themselves declare a `requires` field at create/update time, so the
242
+ * recursion can never go past one level here.
207
243
  */
208
244
  function evaluateWatch(watch, state) {
209
245
  const { target, targetType, condition } = watch;
@@ -215,12 +251,71 @@ function evaluateWatch(watch, state) {
215
251
  if (!entity) return { triggered: false, message: `${tt.label} ${target} not found` };
216
252
 
217
253
  const prevState = watch._lastState || {};
254
+ let primary;
218
255
  try {
219
- return tt.evaluate(condition, entity, prevState, target) || { triggered: false, message: '' };
256
+ primary = tt.evaluate(condition, entity, prevState, target) || { triggered: false, message: '' };
220
257
  } catch (err) {
221
258
  log('warn', `evaluateWatch ${targetType}/${condition}: ${err.message}`);
222
259
  return { triggered: false, message: `evaluator error: ${err.message}` };
223
260
  }
261
+ // Short-circuit when primary is not truthy — requires only constrain
262
+ // an already-firing primary; an un-fired primary stays un-fired.
263
+ if (!primary.triggered) return primary;
264
+
265
+ // P-w10b6e2d — Phase 5: AND-join with each requires[] entry.
266
+ if (Array.isArray(watch.requires) && watch.requires.length > 0) {
267
+ for (let i = 0; i < watch.requires.length; i++) {
268
+ const req = watch.requires[i];
269
+ // Synthetic mini-watch — no _lastState, no nested requires (depth cap
270
+ // enforced at create/update time). evaluateWatch handles missing
271
+ // entity / unknown type / unknown condition the same way it does
272
+ // for a top-level watch.
273
+ const reqResult = evaluateWatch({
274
+ target: req.target, targetType: req.targetType, condition: req.condition,
275
+ }, state);
276
+ if (!reqResult.triggered) {
277
+ return {
278
+ triggered: false,
279
+ message: `${primary.message || 'primary truthy'} BUT requires[${i}] (${req.targetType}/${req.target}/${req.condition}) not met: ${reqResult.message || 'condition not met'}`,
280
+ };
281
+ }
282
+ }
283
+ }
284
+
285
+ return primary;
286
+ }
287
+
288
+ /**
289
+ * Validate a `watch.requires` array. Returns an error message string when
290
+ * invalid, or null when valid. Enforces the Phase 5 contract:
291
+ * - must be an array (use `[]` to clear; null/undefined to omit entirely)
292
+ * - each entry must be a plain object with a non-empty target, a
293
+ * targetType registered in TARGET_TYPES, and a condition listed in
294
+ * that target type's `conditions` array
295
+ * - depth cap of 1: requirement entries MUST NOT themselves declare a
296
+ * `requires` field (otherwise we'd need cycle detection)
297
+ */
298
+ function _validateRequires(requires) {
299
+ if (!Array.isArray(requires)) return 'requires must be an array of {target, targetType, condition}';
300
+ for (let i = 0; i < requires.length; i++) {
301
+ const r = requires[i];
302
+ if (!r || typeof r !== 'object' || Array.isArray(r)) {
303
+ return `requires[${i}] must be a {target, targetType, condition} object`;
304
+ }
305
+ if (!r.target || typeof r.target !== 'string') {
306
+ return `requires[${i}].target is required (non-empty string)`;
307
+ }
308
+ if (!r.targetType || !TARGET_TYPES[r.targetType]) {
309
+ return `requires[${i}].targetType must be one of: ${Object.keys(TARGET_TYPES).sort().join(', ')}`;
310
+ }
311
+ if (!r.condition || !TARGET_TYPES[r.targetType].conditions.includes(r.condition)) {
312
+ return `requires[${i}].condition must be one of: ${TARGET_TYPES[r.targetType].conditions.join(', ')} (for targetType ${r.targetType})`;
313
+ }
314
+ if (r.requires !== undefined) {
315
+ return `requires[${i}] must not contain a nested 'requires' field (depth cap is 1)`;
316
+ }
317
+ }
318
+ return null;
224
319
  }
225
320
 
226
321
  /**
@@ -273,25 +368,48 @@ function checkWatches(config, state) {
273
368
  watch._lastTriggerMessage = result.message;
274
369
  const newState = _captureState(watch, state);
275
370
 
276
- // Queue trigger notification — unique key per trigger to avoid overwriting previous messages
277
- if (watch.notify === 'inbox' && watch.owner) {
278
- notifications.push({
279
- type: 'trigger', owner: watch.owner,
280
- slug: `watch-${watch.id}-${watch.triggerCount}`,
281
- body: `## Watch Triggered: ${watch.description}\n\n${result.message}\n\nWatch ID: ${watch.id} | Target: ${watch.target} | Condition: ${watch.condition}`,
282
- });
283
- }
284
371
  log('info', `Watch triggered: ${watch.id} — ${result.message}`);
285
372
 
286
373
  // Queue follow-up action for after-lock invocation. We snapshot the
287
374
  // watch + entity here so the async handler can run with the same
288
375
  // state that triggered the watch even if the live state changes.
289
- if (watch.action && typeof watch.action === 'object' && watch.action.type) {
376
+ //
377
+ // P-w8e9b1f4 — Phase 4: action may be either a single object
378
+ // (`{type, ...}`) or an array-form chain (`[{type,...}, ...]`).
379
+ //
380
+ // P-w11a4c9e — Phase 6.1: NOTIFY is no longer a special case.
381
+ // When `watch.notify === 'inbox'` (and the watch has an owner),
382
+ // an implicit `{type:'notify'}` step is prepended to the chain
383
+ // here so the legacy inbox alert and any explicit follow-up
384
+ // action both run through the registered action surface.
385
+ //
386
+ // When there is NO implicit notify to prepend, the original
387
+ // action shape is preserved (single-object stays single-object,
388
+ // array stays array) so back-compat _lastActionResult shape
389
+ // (`type: 'webhook'`, `dispatchedItemId`, …) is unchanged.
390
+ const _explicitAction = watch.action;
391
+ const _wantImplicitNotify = (watch.notify === 'inbox' && watch.owner);
392
+ const _hasExplicitArray = Array.isArray(_explicitAction) && _explicitAction.length > 0;
393
+ const _hasExplicitObject = _explicitAction && typeof _explicitAction === 'object'
394
+ && !Array.isArray(_explicitAction) && _explicitAction.type;
395
+ let _effectiveAction = null;
396
+ if (_wantImplicitNotify && _hasExplicitArray) {
397
+ _effectiveAction = [{ type: 'notify' }].concat(_explicitAction);
398
+ } else if (_wantImplicitNotify && _hasExplicitObject) {
399
+ _effectiveAction = [{ type: 'notify' }, _explicitAction];
400
+ } else if (_wantImplicitNotify) {
401
+ _effectiveAction = [{ type: 'notify' }];
402
+ } else if (_hasExplicitArray || _hasExplicitObject) {
403
+ _effectiveAction = _explicitAction;
404
+ }
405
+ if (_effectiveAction) {
290
406
  const tt = TARGET_TYPES[watch.targetType];
291
407
  const entity = tt ? tt.fetchEntity(watch.target, state || {}) : null;
408
+ const snapshot = JSON.parse(JSON.stringify(watch));
409
+ snapshot.action = _effectiveAction;
292
410
  actionsToRun.push({
293
411
  watchId: watch.id,
294
- snapshot: JSON.parse(JSON.stringify(watch)),
412
+ snapshot,
295
413
  previousState,
296
414
  newState,
297
415
  entity: entity ? JSON.parse(JSON.stringify(entity)) : null,
@@ -319,6 +437,21 @@ function checkWatches(config, state) {
319
437
 
320
438
  // Capture state for change detection on next check
321
439
  watch._lastState = _captureState(watch, state);
440
+
441
+ // P-w12d8f3a — Phase 6.2: append a bounded evaluation history
442
+ // entry on EVERY check (fired AND not-fired). Trim oldest first
443
+ // when the buffer exceeds WATCH_HISTORY_MAX. action_results is
444
+ // populated post-lock by _runActionTask once the action runs.
445
+ if (!Array.isArray(watch._history)) watch._history = [];
446
+ watch._history.push({
447
+ checked_at: watch.last_checked,
448
+ fired: !!result.triggered,
449
+ reason: result.message || '',
450
+ condition: watch.condition,
451
+ });
452
+ if (watch._history.length > WATCH_HISTORY_MAX) {
453
+ watch._history.splice(0, watch._history.length - WATCH_HISTORY_MAX);
454
+ }
322
455
  } catch (err) {
323
456
  log('warn', `Watch check error (${watch.id}): ${err.message}`);
324
457
  }
@@ -353,21 +486,64 @@ async function _runActionTask(task) {
353
486
  message: task.message,
354
487
  });
355
488
  const result = await watchActions.runWatchAction(task.snapshot, ctx);
489
+ const isChain = Array.isArray(task.snapshot.action);
490
+ const actionLabel = isChain
491
+ ? `chain[${task.snapshot.action.length}]`
492
+ : (task.snapshot.action?.type || '?');
356
493
  log(result.ok ? 'info' : 'warn',
357
- `Watch ${task.watchId} action ${task.snapshot.action?.type || '?'}: ${result.summary || (result.ok ? 'ok' : 'failed')}`);
494
+ `Watch ${task.watchId} action ${actionLabel}: ${result.summary || (result.ok ? 'ok' : 'failed')}`);
358
495
  // Persist back onto the watch (best-effort).
496
+ // P-w8e9b1f4 — Phase 4: array-form actions persist a `steps[]` array
497
+ // alongside the aggregate {ok, summary} so dashboards / downstream readers
498
+ // can inspect each step's result. Single-action watches keep the legacy
499
+ // `dispatchedItemId` shortcut for back-compat.
359
500
  try {
360
501
  mutateJsonFileLocked(_watchesPath(), (watches) => {
361
502
  if (!Array.isArray(watches)) return watches;
362
503
  const w = watches.find(x => x.id === task.watchId);
363
504
  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
- };
505
+ if (isChain) {
506
+ w._lastActionResult = {
507
+ type: 'chain',
508
+ ok: !!result.ok,
509
+ summary: result.summary || '',
510
+ aborted: !!result.aborted,
511
+ steps: Array.isArray(result.steps) ? result.steps.map(s => ({
512
+ type: s.type || null,
513
+ ok: !!s.ok,
514
+ skipped: !!s.skipped,
515
+ summary: s.summary || '',
516
+ dispatchedItemId: (s.result && s.result.dispatchedItemId) || null,
517
+ })) : [],
518
+ at: ts(),
519
+ };
520
+ } else {
521
+ w._lastActionResult = {
522
+ type: task.snapshot.action?.type || null,
523
+ ok: !!result.ok,
524
+ summary: result.summary || '',
525
+ dispatchedItemId: result.dispatchedItemId || null,
526
+ at: ts(),
527
+ };
528
+ }
529
+
530
+ // P-w12d8f3a — Phase 6.2: backfill action_results onto the most
531
+ // recent (fired) history entry. The history append already
532
+ // happened inside the checkWatches lock; here we attach a small
533
+ // bounded summary so the GET /api/watches/:id/history surface
534
+ // (P-w13c2b6f) can show whether the action succeeded. Step-level
535
+ // detail stays on _lastActionResult — history keeps only the
536
+ // aggregate to honor the "small + bounded" contract.
537
+ if (Array.isArray(w._history) && w._history.length > 0) {
538
+ const last = w._history[w._history.length - 1];
539
+ if (last && last.fired) {
540
+ last.action_results = {
541
+ type: w._lastActionResult.type,
542
+ ok: w._lastActionResult.ok,
543
+ summary: w._lastActionResult.summary || '',
544
+ };
545
+ }
546
+ }
371
547
  }
372
548
  return watches;
373
549
  }, { defaultValue: [] });
@@ -386,7 +562,11 @@ function _captureState(watch, state) {
386
562
  const entity = tt.fetchEntity(watch.target, state || {});
387
563
  if (!entity) return {};
388
564
  try {
389
- return tt.captureState(entity) || {};
565
+ // P-w5b8d2c9 — Phase 2.2: pass prevState so captureState can carry
566
+ // forward unchanged-tick counters (e.g. _unchangedTicks for work-item
567
+ // stalled, _stuckStageTicks for pipeline stuck-in-stage). Existing
568
+ // captureState fns that take only 1 arg ignore this — backward-compat.
569
+ return tt.captureState(entity, watch._lastState || {}) || {};
390
570
  } catch (err) {
391
571
  log('warn', `_captureState ${watch.targetType}: ${err.message}`);
392
572
  return {};
@@ -403,11 +583,28 @@ registerTargetType(WATCH_TARGET_TYPE.PR, {
403
583
  WATCH_CONDITION.MERGED, WATCH_CONDITION.BUILD_FAIL, WATCH_CONDITION.BUILD_PASS,
404
584
  WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
405
585
  WATCH_CONDITION.NEW_COMMENTS, WATCH_CONDITION.VOTE_CHANGE,
586
+ // P-w4e2f6a1 — Phase 2.1: head/mergeability/draft/behind predicates.
587
+ // Backed by Phase 1.1 captureState plumbing (headRefOid, mergeable,
588
+ // isDraft, mergeStateStatus) and Phase 1.3 behindBy (read at fire-time
589
+ // so this list is safe to advertise even before behindBy lands).
590
+ WATCH_CONDITION.HEAD_COMMIT_CHANGE, WATCH_CONDITION.MERGEABLE_FLIPPED,
591
+ WATCH_CONDITION.READY_FOR_MERGE, WATCH_CONDITION.BEHIND_MASTER,
592
+ WATCH_CONDITION.DRAFT_FLIPPED,
406
593
  ],
407
594
  fetchEntity: (target, state) => findPrByTarget(state.pullRequests, target),
408
595
  captureState: (pr) => ({
409
596
  status: pr.status, buildStatus: pr.buildStatus, reviewStatus: pr.reviewStatus,
410
597
  lastCommentDate: pr.humanFeedback?.lastProcessedCommentDate || null,
598
+ // P-w1a3f9b2 — Phase 1.1: plumb head-commit / mergeability / draft / merge-state
599
+ // so future predicates (Phase 2.1: head-commit-change, mergeable-flipped,
600
+ // ready-for-merge, draft-flipped) can read them. Values are passed through
601
+ // verbatim — including `undefined` for legacy PR objects that haven't been
602
+ // re-polled yet — so symmetric prevState/newState comparisons stay false on
603
+ // the first post-upgrade tick instead of synthesizing a phantom change.
604
+ headRefOid: pr.headRefOid,
605
+ mergeable: pr.mergeable,
606
+ isDraft: pr.isDraft,
607
+ mergeStateStatus: pr.mergeStateStatus,
411
608
  }),
412
609
  evaluate: (condition, pr, prevState, target) => {
413
610
  switch (condition) {
@@ -439,6 +636,59 @@ registerTargetType(WATCH_TARGET_TYPE.PR, {
439
636
  const changed = prevState.reviewStatus !== undefined && prevState.reviewStatus !== pr.reviewStatus;
440
637
  return { triggered: changed, message: changed ? `PR ${target} vote changed: ${prevState.reviewStatus} → ${pr.reviewStatus}` : '' };
441
638
  }
639
+ // ── P-w4e2f6a1 — Phase 2.1 PR predicates ──────────────────────────
640
+ case WATCH_CONDITION.HEAD_COMMIT_CHANGE: {
641
+ // Fire only when both sides are defined, non-null SHAs that differ.
642
+ // Suppresses post-upgrade phantom triggers when prevState is missing
643
+ // headRefOid (legacy `_lastState`) or the poller hasn't populated it
644
+ // yet on this tick.
645
+ const prev = prevState.headRefOid;
646
+ const cur = pr.headRefOid;
647
+ const changed = !!(prev && cur && prev !== cur);
648
+ return { triggered: changed, message: changed ? `PR ${target} head commit changed: ${prev} → ${cur}` : '' };
649
+ }
650
+ case WATCH_CONDITION.MERGEABLE_FLIPPED: {
651
+ // engine/github.js:902-923 documents that mergeable=null means
652
+ // "GitHub still computing" — NOT a positive signal. Only fire on
653
+ // transitions between defined boolean values (true↔false). Any
654
+ // transition involving null or undefined is ignored.
655
+ const prev = prevState.mergeable;
656
+ const cur = pr.mergeable;
657
+ const isBool = (v) => v === true || v === false;
658
+ const flipped = isBool(prev) && isBool(cur) && prev !== cur;
659
+ return { triggered: flipped, message: flipped ? `PR ${target} mergeable flipped: ${prev} → ${cur}` : '' };
660
+ }
661
+ case WATCH_CONDITION.READY_FOR_MERGE: {
662
+ // Compound predicate — fires when ALL gating signals line up.
663
+ // Registered as an absolute condition (engine/shared.js
664
+ // WATCH_ABSOLUTE_CONDITIONS) so it auto-expires on first trigger
665
+ // when stopAfter=0. Treat isDraft !== true (catches null/undefined
666
+ // legacy PRs that don't expose the field).
667
+ const ready = pr.status === 'active'
668
+ && pr.reviewStatus === 'approved'
669
+ && pr.buildStatus === 'passing'
670
+ && pr.mergeable === true
671
+ && pr.isDraft !== true;
672
+ return { triggered: ready, message: ready ? `PR ${target} is ready for merge` : '' };
673
+ }
674
+ case WATCH_CONDITION.BEHIND_MASTER: {
675
+ // Phase 1.3 will plumb pr.behindBy. Until then, this stays dormant
676
+ // (typeof check rejects null/undefined). Treat null as not-behind
677
+ // per the PRD spec — only a numeric > 0 fires.
678
+ const b = pr.behindBy;
679
+ const behind = typeof b === 'number' && b > 0;
680
+ return { triggered: behind, message: behind ? `PR ${target} is behind master by ${b} commit${b === 1 ? '' : 's'}` : '' };
681
+ }
682
+ case WATCH_CONDITION.DRAFT_FLIPPED: {
683
+ // Symmetric to mergeable-flipped: only fire on true↔false transitions
684
+ // between defined boolean values. Suppresses post-upgrade phantom
685
+ // triggers when isDraft was previously undefined.
686
+ const prev = prevState.isDraft;
687
+ const cur = pr.isDraft;
688
+ const isBool = (v) => v === true || v === false;
689
+ const flipped = isBool(prev) && isBool(cur) && prev !== cur;
690
+ return { triggered: flipped, message: flipped ? `PR ${target} draft flipped: ${prev} → ${cur}` : '' };
691
+ }
442
692
  default:
443
693
  return { triggered: false, message: `Unknown condition: ${condition}` };
444
694
  }
@@ -452,9 +702,36 @@ registerTargetType(WATCH_TARGET_TYPE.WORK_ITEM, {
452
702
  conditions: [
453
703
  WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED,
454
704
  WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
705
+ // P-w5b8d2c9 — Phase 2.2: stalled / retry-limit / dependency predicates.
706
+ // Backed by Phase 1.2 captureState plumbing (retries, _pendingReason)
707
+ // plus a Phase 2.2 _unchangedTicks counter computed inside captureState.
708
+ WATCH_CONDITION.STALLED, WATCH_CONDITION.RETRY_LIMIT_REACHED,
709
+ WATCH_CONDITION.DEPENDENCY_MET,
455
710
  ],
456
711
  fetchEntity: (target, state) => (state.workItems || []).find(w => w.id === target) || null,
457
- captureState: (wi) => ({ status: wi.status }),
712
+ // P-w2c8d1e7 Phase 1.2: surface retry count, pending-reason, and branch
713
+ // for upcoming stalled / branch-aware predicates. `retries` aliases the
714
+ // private `_retryCount` so guards can read a stable field name.
715
+ // P-w5b8d2c9 — Phase 2.2: also carry forward `_unchangedTicks`, an
716
+ // unbounded counter incremented each tick that the observable fields
717
+ // (status / retries / _pendingReason / branch) all match prevState. Reset
718
+ // to 0 on any change. Drives the `stalled` predicate.
719
+ captureState: (wi, prev) => {
720
+ const status = wi.status;
721
+ const retries = wi._retryCount || 0;
722
+ const pendingReason = wi._pendingReason || null;
723
+ const branch = wi.branch || null;
724
+ const unchanged = !!prev
725
+ && prev.status === status
726
+ && (prev.retries || 0) === retries
727
+ && (prev._pendingReason || null) === pendingReason
728
+ && (prev.branch || null) === branch;
729
+ const unchangedTicks = unchanged ? ((prev._unchangedTicks || 0) + 1) : 0;
730
+ return {
731
+ status, retries, _pendingReason: pendingReason, branch,
732
+ _unchangedTicks: unchangedTicks,
733
+ };
734
+ },
458
735
  evaluate: (condition, wi, prevState, target) => {
459
736
  switch (condition) {
460
737
  case WATCH_CONDITION.COMPLETED: {
@@ -473,6 +750,41 @@ registerTargetType(WATCH_TARGET_TYPE.WORK_ITEM, {
473
750
  const anyChanged = prevState.status !== undefined && prevState.status !== wi.status;
474
751
  return { triggered: anyChanged, message: anyChanged ? `Work item ${target} changed (${wi.status})` : '' };
475
752
  }
753
+ // ── P-w5b8d2c9 — Phase 2.2 work-item predicates ──────────────────
754
+ case WATCH_CONDITION.STALLED: {
755
+ // Suppress on terminal items — a done/failed work item is not
756
+ // "stalled", it's finished. Otherwise fire when the carried-forward
757
+ // _unchangedTicks counter has crossed the threshold.
758
+ const isTerminal = shared.DONE_STATUSES.has(wi.status)
759
+ || wi.status === shared.WI_STATUS.FAILED
760
+ || wi.status === shared.WI_STATUS.CANCELLED;
761
+ if (isTerminal) return { triggered: false, message: '' };
762
+ const ticks = prevState._unchangedTicks || 0;
763
+ const threshold = shared.WATCH_STALLED_DEFAULT_TICKS;
764
+ const stalled = ticks >= threshold;
765
+ return { triggered: stalled, message: stalled ? `Work item ${target} stalled (${wi.status}, no change for ${ticks} checks)` : '' };
766
+ }
767
+ case WATCH_CONDITION.RETRY_LIMIT_REACHED: {
768
+ // Compound state assertion — registered as absolute so it auto-expires
769
+ // after the first fire when stopAfter=0 (otherwise it would re-fire
770
+ // every tick while the work item sits at maxRetries).
771
+ const retries = wi._retryCount || 0;
772
+ const limit = shared.ENGINE_DEFAULTS.maxRetries;
773
+ const reached = retries >= limit;
774
+ return { triggered: reached, message: reached ? `Work item ${target} hit retry limit (${retries} >= ${limit})` : '' };
775
+ }
776
+ case WATCH_CONDITION.DEPENDENCY_MET: {
777
+ // Transition predicate: prevState had _pendingReason='dependency_unmet'
778
+ // and current does not. Restrict to pending/dispatched statuses so
779
+ // we don't confuse "dependency met" with "item terminated for other
780
+ // reasons" (e.g. cancelled while waiting on a dep).
781
+ const wasUnmet = prevState._pendingReason === 'dependency_unmet';
782
+ const nowMet = (wi._pendingReason || null) !== 'dependency_unmet';
783
+ const validStatus = wi.status === shared.WI_STATUS.PENDING
784
+ || wi.status === shared.WI_STATUS.DISPATCHED;
785
+ const fired = wasUnmet && nowMet && validStatus;
786
+ return { triggered: fired, message: fired ? `Work item ${target} dependency met (status=${wi.status}, reason=${wi._pendingReason || 'none'})` : '' };
787
+ }
476
788
  default:
477
789
  return { triggered: false, message: `Unknown condition: ${condition}` };
478
790
  }
@@ -518,15 +830,56 @@ function _findPlan(target, state) {
518
830
  return false;
519
831
  }) || null;
520
832
  }
833
+ // P-w2c8d1e7 — Phase 1.2: derive item progress from the PRD's
834
+ // missing_features array. Returns 0 when the array is missing/empty so
835
+ // guards can compare numerically without null-checks.
836
+ function _planItemsTotal(p) {
837
+ return Array.isArray(p?.missing_features) ? p.missing_features.length : 0;
838
+ }
839
+ function _planItemsDone(p) {
840
+ if (!Array.isArray(p?.missing_features)) return 0;
841
+ let n = 0;
842
+ for (const item of p.missing_features) {
843
+ if (item && shared.DONE_STATUSES.has(item.status)) n += 1;
844
+ }
845
+ return n;
846
+ }
847
+ // P-w5b8d2c9 — Phase 2.2: max retry count across plan items, drives the
848
+ // `item-failed-n-times` predicate. Treats null/undefined retries as 0 so
849
+ // the predicate stays dormant for plans that have no retry data.
850
+ function _planMaxItemRetries(p) {
851
+ if (!Array.isArray(p?.missing_features)) return 0;
852
+ let max = 0;
853
+ for (const item of p.missing_features) {
854
+ const rc = (item && item._retryCount) || 0;
855
+ if (rc > max) max = rc;
856
+ }
857
+ return max;
858
+ }
521
859
  registerTargetType(WATCH_TARGET_TYPE.PLAN, {
522
860
  label: 'Plan / PRD',
523
861
  description: 'Watch a plan/PRD for approval, rejection, completion, or status changes',
524
862
  conditions: [
525
863
  WATCH_CONDITION.APPROVED, WATCH_CONDITION.REJECTED, WATCH_CONDITION.COMPLETED,
526
864
  WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
865
+ // P-w5b8d2c9 — Phase 2.2: progress / failure-rate predicates derived
866
+ // from the PRD's missing_features array.
867
+ WATCH_CONDITION.ALL_ITEMS_DONE, WATCH_CONDITION.ITEM_FAILED_N_TIMES,
527
868
  ],
528
869
  fetchEntity: _findPlan,
529
- captureState: (p) => ({ status: p.status, planStale: !!p.planStale }),
870
+ // P-w2c8d1e7 Phase 1.2: surface item progress derived from the PRD's
871
+ // missing_features array so plan-level predicates (e.g. all-items-done)
872
+ // can read it directly from _lastState.
873
+ // P-w5b8d2c9 — Phase 2.2: also surface max_item_retries for the
874
+ // item-failed-n-times predicate. All values default to 0 so legacy
875
+ // `_lastState` shapes don't break.
876
+ captureState: (p) => ({
877
+ status: p.status,
878
+ planStale: !!p.planStale,
879
+ items_done: _planItemsDone(p),
880
+ items_total: _planItemsTotal(p),
881
+ max_item_retries: _planMaxItemRetries(p),
882
+ }),
530
883
  evaluate: (condition, p, prevState, target) => {
531
884
  const status = String(p.status || '').toLowerCase();
532
885
  switch (condition) {
@@ -545,6 +898,26 @@ registerTargetType(WATCH_TARGET_TYPE.PLAN, {
545
898
  (prevState.status !== p.status || prevState.planStale !== !!p.planStale);
546
899
  return { triggered: anyChanged, message: anyChanged ? `Plan ${target} changed (${p.status})` : '' };
547
900
  }
901
+ // ── P-w5b8d2c9 — Phase 2.2 plan predicates ───────────────────────
902
+ case WATCH_CONDITION.ALL_ITEMS_DONE: {
903
+ // Compound state assertion — registered as absolute so it auto-expires
904
+ // after first fire when stopAfter=0 (otherwise a fully-complete
905
+ // plan would re-fire every tick). Requires items_total > 0 so an
906
+ // empty PRD doesn't trigger the predicate vacuously.
907
+ const total = _planItemsTotal(p);
908
+ const done = _planItemsDone(p);
909
+ const allDone = total > 0 && done === total;
910
+ return { triggered: allDone, message: allDone ? `Plan ${target} all items done (${done}/${total})` : '' };
911
+ }
912
+ case WATCH_CONDITION.ITEM_FAILED_N_TIMES: {
913
+ // Compound state assertion — fires when ANY missing_feature has a
914
+ // _retryCount at or beyond ENGINE_DEFAULTS.maxRetries. Absolute so
915
+ // the alert fires once when the threshold is reached.
916
+ const maxRc = _planMaxItemRetries(p);
917
+ const limit = shared.ENGINE_DEFAULTS.maxRetries;
918
+ const fired = maxRc >= limit;
919
+ return { triggered: fired, message: fired ? `Plan ${target} has item retried ${maxRc} times (>= ${limit})` : '' };
920
+ }
548
921
  default:
549
922
  return { triggered: false, message: `Unknown condition: ${condition}` };
550
923
  }
@@ -569,7 +942,15 @@ registerTargetType(WATCH_TARGET_TYPE.SCHEDULE, {
569
942
  WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
570
943
  ],
571
944
  fetchEntity: _findSchedule,
572
- captureState: (s) => ({ lastRun: s._lastRun || null, enabled: s.enabled !== false }),
945
+ // P-w2c8d1e7 Phase 1.2: surface next_run as null. Cron parsing in
946
+ // engine/scheduler.js exposes parseCronExpr/.matches() but not a
947
+ // next-fire calculator, so the snapshot field is reserved (always null
948
+ // today) for forward-compat with future predicates that gain a helper.
949
+ captureState: (s) => ({
950
+ lastRun: s._lastRun || null,
951
+ enabled: s.enabled !== false,
952
+ next_run: null,
953
+ }),
573
954
  evaluate: (condition, s, prevState, target) => {
574
955
  const enabled = s.enabled !== false;
575
956
  const lastRun = s._lastRun || null;
@@ -619,18 +1000,53 @@ function _completedStageCount(run) {
619
1000
  }
620
1001
  return n;
621
1002
  }
1003
+ // P-w2c8d1e7 — Phase 1.2: report the id of the first non-terminal stage
1004
+ // in run.stages iteration order. run.stages is built from pipeline.stages
1005
+ // in declaration order (engine/pipeline.js startRun ~line 84) so the first
1006
+ // non-terminal entry is the currently-executing or next-pending stage.
1007
+ const _PIPELINE_TERMINAL = new Set(['completed', 'failed', 'stopped']);
1008
+ function _currentPipelineStageId(run) {
1009
+ if (!run || !run.stages || typeof run.stages !== 'object') return null;
1010
+ for (const [stageId, st] of Object.entries(run.stages)) {
1011
+ const s = String((st && st.status) || '').toLowerCase();
1012
+ if (!_PIPELINE_TERMINAL.has(s)) return stageId;
1013
+ }
1014
+ return null;
1015
+ }
622
1016
  registerTargetType(WATCH_TARGET_TYPE.PIPELINE, {
623
1017
  label: 'Pipeline',
624
1018
  description: 'Watch the latest run of a pipeline for completion, failure, or stage progress',
625
1019
  conditions: [
626
1020
  WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED, WATCH_CONDITION.STAGE_COMPLETE,
627
1021
  WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
1022
+ // P-w5b8d2c9 — Phase 2.2: stage-targeted predicates derived from the
1023
+ // current_stage_id snapshot field plus a Phase 2.2 _stuckStageTicks
1024
+ // counter computed inside captureState.
1025
+ WATCH_CONDITION.STAGE_ADVANCED, WATCH_CONDITION.STUCK_IN_STAGE,
628
1026
  ],
629
1027
  fetchEntity: _findPipelineLatestRun,
630
- captureState: (run) => ({
631
- runId: run.runId || null, status: run.status || null,
632
- completedStages: _completedStageCount(run),
633
- }),
1028
+ // P-w2c8d1e7 — Phase 1.2: surface current_stage_id (first non-terminal
1029
+ // stage in declaration order) for stage-targeted predicates.
1030
+ // P-w5b8d2c9 — Phase 2.2: also carry forward `_stuckStageTicks`, an
1031
+ // unbounded counter that increments each tick the same non-null
1032
+ // current_stage_id persists. Reset to 0 when the stage advances or when
1033
+ // current_stage_id is null (run has no live stage). Drives the
1034
+ // `stuck-in-stage` predicate.
1035
+ captureState: (run, prev) => {
1036
+ const runId = run.runId || null;
1037
+ const status = run.status || null;
1038
+ const completedStages = _completedStageCount(run);
1039
+ const currentStageId = _currentPipelineStageId(run);
1040
+ const sameStage = !!prev
1041
+ && currentStageId !== null
1042
+ && prev.current_stage_id === currentStageId
1043
+ && prev.runId === runId;
1044
+ const stuckStageTicks = sameStage ? ((prev._stuckStageTicks || 0) + 1) : 0;
1045
+ return {
1046
+ runId, status, completedStages, current_stage_id: currentStageId,
1047
+ _stuckStageTicks: stuckStageTicks,
1048
+ };
1049
+ },
634
1050
  evaluate: (condition, run, prevState, target) => {
635
1051
  switch (condition) {
636
1052
  case WATCH_CONDITION.COMPLETED: {
@@ -657,6 +1073,26 @@ registerTargetType(WATCH_TARGET_TYPE.PIPELINE, {
657
1073
  (prevState.completedStages || 0) !== _completedStageCount(run));
658
1074
  return { triggered: anyChanged, message: anyChanged ? `Pipeline ${target} changed (${run.status})` : '' };
659
1075
  }
1076
+ // ── P-w5b8d2c9 — Phase 2.2 pipeline predicates ───────────────────
1077
+ case WATCH_CONDITION.STAGE_ADVANCED: {
1078
+ // Triggers when current_stage_id changes within the same run.
1079
+ // Different runId means a new run started — handled by status-change
1080
+ // semantics, not by stage-advanced. Suppresses the
1081
+ // undefined-prev → defined-cur transition (post-upgrade phantom).
1082
+ const cur = _currentPipelineStageId(run);
1083
+ const prev = prevState.current_stage_id;
1084
+ const advanced = prev !== undefined && prev !== cur && prevState.runId === run.runId;
1085
+ return { triggered: advanced, message: advanced ? `Pipeline ${target} advanced stage: ${prev} → ${cur}` : '' };
1086
+ }
1087
+ case WATCH_CONDITION.STUCK_IN_STAGE: {
1088
+ // Fires when the carried-forward _stuckStageTicks counter has
1089
+ // crossed the threshold. Implicitly requires current_stage_id !==
1090
+ // null (counter resets to 0 when null per captureState).
1091
+ const ticks = prevState._stuckStageTicks || 0;
1092
+ const threshold = shared.WATCH_STUCK_STAGE_DEFAULT_TICKS;
1093
+ const stuck = ticks >= threshold;
1094
+ return { triggered: stuck, message: stuck ? `Pipeline ${target} stuck in stage ${prevState.current_stage_id} for ${ticks} checks` : '' };
1095
+ }
660
1096
  default:
661
1097
  return { triggered: false, message: `Unknown condition: ${condition}` };
662
1098
  }
@@ -722,7 +1158,14 @@ registerTargetType(WATCH_TARGET_TYPE.AGENT, {
722
1158
  WATCH_CONDITION.ACTIVITY_CHANGE, WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
723
1159
  ],
724
1160
  fetchEntity: _findAgent,
725
- captureState: (a) => ({ status: a.status || null }),
1161
+ // P-w2c8d1e7 Phase 1.2: surface currentDispatchId so predicates can
1162
+ // distinguish "agent now working on a different dispatch" from a plain
1163
+ // status flip. Sourced from queries.getAgents() which derives it from
1164
+ // dispatch.json (active list) via getAgentStatus.dispatch_id.
1165
+ captureState: (a) => ({
1166
+ status: a.status || null,
1167
+ currentDispatchId: a.currentDispatchId || null,
1168
+ }),
726
1169
  evaluate: (condition, a, prevState, target) => {
727
1170
  const cur = a.status || null;
728
1171
  switch (condition) {
@@ -765,6 +1208,7 @@ module.exports = {
765
1208
  getActionType: watchActions.getActionType,
766
1209
  listActionTypes: watchActions.listActionTypes,
767
1210
  runWatchAction: watchActions.runWatchAction,
1211
+ runWatchActionChain: watchActions.runWatchActionChain,
768
1212
  validateAction: watchActions.validateAction,
769
1213
  _captureState, // exported for testing
770
1214
  _watchesPath, // exported for testing — dynamic, respects MINIONS_TEST_DIR