@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/github.js CHANGED
@@ -496,6 +496,74 @@ async function pollPrStatus(config) {
496
496
  updated = true;
497
497
  }
498
498
 
499
+ // P-w1a3f9b2 — Phase 1.1: plumb mergeable / isDraft / mergeStateStatus /
500
+ // headRefOid onto the PR object so watches captureState (engine/watches.js)
501
+ // and future predicates (Phase 2.1: head-commit-change, mergeable-flipped,
502
+ // ready-for-merge, draft-flipped) can read them. We mirror GitHub's
503
+ // tri-state contract for `mergeable` (true|false|null — see
504
+ // checkLiveBuildAndConflict comments above):
505
+ // - true → GitHub computed: mergeable
506
+ // - false → GitHub computed: conflicts
507
+ // - null → GitHub still computing — preserve null verbatim, do NOT
508
+ // substitute the cached value (predicates treat null as "no
509
+ // signal" and won't fire transitions involving null).
510
+ // `headRefOid` is an alias of `headSha` for spec parity (GitHub's GraphQL
511
+ // schema and many tools name it `headRefOid`); both stay in sync.
512
+ if (prData.head?.sha && pr.headRefOid !== prData.head.sha) {
513
+ pr.headRefOid = prData.head.sha;
514
+ updated = true;
515
+ }
516
+ const newMergeable = prData.mergeable === true ? true
517
+ : prData.mergeable === false ? false
518
+ : null;
519
+ if (pr.mergeable !== newMergeable) {
520
+ pr.mergeable = newMergeable;
521
+ updated = true;
522
+ }
523
+ const newIsDraft = prData.draft === true;
524
+ if (pr.isDraft !== newIsDraft) {
525
+ pr.isDraft = newIsDraft;
526
+ updated = true;
527
+ }
528
+ const newMergeStateStatus = typeof prData.mergeable_state === 'string' && prData.mergeable_state
529
+ ? prData.mergeable_state
530
+ : null;
531
+ if ((pr.mergeStateStatus || null) !== newMergeStateStatus) {
532
+ pr.mergeStateStatus = newMergeStateStatus;
533
+ updated = true;
534
+ }
535
+
536
+ // P-w3a7b9c4 — Phase 1.3: optional gated `behindBy` field via GitHub
537
+ // /compare/{base}...{head} API. Each call costs one extra GitHub
538
+ // request, so it's gated behind the opt-in
539
+ // `config.engine.watchesIncludeBehindBy` flag. The natural per-tick
540
+ // budget IS the prPollStatusEvery cadence (default ~12 ticks) — by
541
+ // living inside pollPrStatus this fetch inherits that gate without
542
+ // needing a separate counter. When skipped (flag off, PR closed,
543
+ // missing refs, cross-repo fork, or compare-API error), behindBy is
544
+ // explicitly set to null so the `behind-master` predicate
545
+ // (engine/watches.js BEHIND_MASTER) treats it as "no signal" and does
546
+ // not over-fire on legacy / stale state. ADO PRs are handled in
547
+ // engine/ado.js with the same null contract.
548
+ const includeBehindBy = config.engine?.watchesIncludeBehindBy === true;
549
+ let newBehindBy = null;
550
+ if (includeBehindBy && prData.state === 'open' && prData.base?.ref && prData.head?.sha) {
551
+ // Same-repo PRs only — cross-repo (forked) compare needs `owner:branch`
552
+ // syntax which we don't normalize here. Treat fork PRs as "no signal".
553
+ const baseRepo = prData.base?.repo?.full_name || '';
554
+ const headRepo = prData.head?.repo?.full_name || '';
555
+ if (baseRepo && headRepo && baseRepo === headRepo) {
556
+ const cmp = await ghApi(`/compare/${encodeURIComponent(prData.base.ref)}...${encodeURIComponent(prData.head.sha)}`, slug);
557
+ if (cmp && cmp !== GH_NOT_FOUND && typeof cmp.behind_by === 'number') {
558
+ newBehindBy = cmp.behind_by;
559
+ }
560
+ }
561
+ }
562
+ if ((pr.behindBy ?? null) !== newBehindBy) {
563
+ pr.behindBy = newBehindBy;
564
+ updated = true;
565
+ }
566
+
499
567
  if (pr.status !== newStatus) {
500
568
  log('info', `PR ${pr.id} status: ${pr.status} → ${newStatus}`);
501
569
  pr.status = newStatus;
package/engine/queries.js CHANGED
@@ -553,6 +553,9 @@ function getAgents(config) {
553
553
  return {
554
554
  ...a, runtime, status: s.status, lastAction,
555
555
  currentTask: (s.task || '').slice(0, 200),
556
+ // P-w2c8d1e7 — Phase 1.2: surface the active dispatch id so watch
557
+ // captureState can snapshot it for currentDispatchId-aware predicates.
558
+ currentDispatchId: s.dispatch_id || null,
556
559
  resultSummary: (s.resultSummary || '').slice(0, 500),
557
560
  started_at: s.started_at || null,
558
561
  completed_at: s.completed_at || null,
@@ -0,0 +1,350 @@
1
+ /**
2
+ * engine/safe-expr.js — Hand-rolled recursive-descent expression evaluator.
3
+ *
4
+ * Phase 3.1 of feat/plan-resilient-watches (P-w6f4a3e8). Used by watch action
5
+ * guards (P-w7c5d8b3) and predicate-based watches (Phase 2.x) to evaluate
6
+ * user-supplied boolean expressions against a runtime context such as
7
+ * `{ target, newState, previousState, condition, ... }`.
8
+ *
9
+ * Hard rules — DO NOT REGRESS:
10
+ * 1. Never use `eval()` or `new Function()`. Both are RCE vectors. The unit
11
+ * test suite greps the source of this module to enforce this.
12
+ * 2. Never throw out of `evaluate()`. Watches must keep ticking even if a
13
+ * user types a malformed predicate. On any parse or evaluation error,
14
+ * log a warn tagged `[safe-expr]` and return `false`.
15
+ * 3. Identifier resolution does an own-property walk only. Reaching
16
+ * `Object.prototype` (`toString`, `constructor`, `__proto__`, ...) is a
17
+ * sandbox-escape vector for an attacker who controls a watch expression.
18
+ *
19
+ * Grammar (lowest to highest precedence):
20
+ * expression -> or
21
+ * or -> and ('||' and)*
22
+ * and -> equality ('&&' equality)*
23
+ * equality -> comparison (('==='|'=='|'!=='|'!=') comparison)*
24
+ * comparison -> unary (('<='|'<'|'>='|'>') unary)*
25
+ * unary -> '!' unary | primary
26
+ * primary -> NUMBER | STRING | true | false | null | undefined
27
+ * | IDENT ('.' IDENT)* | '(' expression ')'
28
+ *
29
+ * Top-level result is coerced via `Boolean(...)` so callers always see a
30
+ * boolean (the canonical guard contract: truthy = run, falsy = skip).
31
+ */
32
+
33
+ 'use strict';
34
+
35
+ // ── Lexer ────────────────────────────────────────────────────────────────────
36
+
37
+ const TOK = {
38
+ NUMBER: 'NUMBER',
39
+ STRING: 'STRING',
40
+ IDENT: 'IDENT',
41
+ BOOLEAN: 'BOOLEAN',
42
+ NULL: 'NULL',
43
+ UNDEFINED: 'UNDEFINED',
44
+ DOT: 'DOT',
45
+ LPAREN: 'LPAREN',
46
+ RPAREN: 'RPAREN',
47
+ EQ_STRICT: 'EQ_STRICT',
48
+ NEQ_STRICT: 'NEQ_STRICT',
49
+ EQ_LOOSE: 'EQ_LOOSE',
50
+ NEQ_LOOSE: 'NEQ_LOOSE',
51
+ LT: 'LT',
52
+ LTE: 'LTE',
53
+ GT: 'GT',
54
+ GTE: 'GTE',
55
+ AND: 'AND',
56
+ OR: 'OR',
57
+ NOT: 'NOT',
58
+ MINUS: 'MINUS',
59
+ EOF: 'EOF',
60
+ };
61
+
62
+ function _isDigit(c) { return c >= '0' && c <= '9'; }
63
+ function _isIdentStart(c) {
64
+ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_' || c === '$';
65
+ }
66
+ function _isIdentBody(c) { return _isIdentStart(c) || _isDigit(c); }
67
+
68
+ function tokenize(src) {
69
+ const tokens = [];
70
+ const len = src.length;
71
+ let i = 0;
72
+
73
+ while (i < len) {
74
+ const c = src[i];
75
+
76
+ // Whitespace
77
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; }
78
+
79
+ // String literals (single or double quoted, with basic backslash escapes)
80
+ if (c === '"' || c === "'") {
81
+ const quote = c;
82
+ let j = i + 1;
83
+ let val = '';
84
+ while (j < len && src[j] !== quote) {
85
+ if (src[j] === '\\' && j + 1 < len) {
86
+ const next = src[j + 1];
87
+ if (next === 'n') val += '\n';
88
+ else if (next === 't') val += '\t';
89
+ else if (next === 'r') val += '\r';
90
+ else if (next === '\\') val += '\\';
91
+ else if (next === quote) val += quote;
92
+ else val += next;
93
+ j += 2;
94
+ } else {
95
+ val += src[j];
96
+ j++;
97
+ }
98
+ }
99
+ if (j >= len) throw new Error(`Unterminated string literal starting at ${i}`);
100
+ tokens.push({ type: TOK.STRING, value: val });
101
+ i = j + 1;
102
+ continue;
103
+ }
104
+
105
+ // Number literals (positive only — unary minus handled in parsePrimary)
106
+ if (_isDigit(c)) {
107
+ let j = i;
108
+ let sawDot = false;
109
+ while (j < len) {
110
+ const ch = src[j];
111
+ if (_isDigit(ch)) { j++; continue; }
112
+ if (ch === '.' && !sawDot) { sawDot = true; j++; continue; }
113
+ break;
114
+ }
115
+ const numStr = src.slice(i, j);
116
+ const num = Number(numStr);
117
+ if (Number.isNaN(num)) throw new Error(`Invalid number "${numStr}" at ${i}`);
118
+ tokens.push({ type: TOK.NUMBER, value: num });
119
+ i = j;
120
+ continue;
121
+ }
122
+
123
+ // Identifiers and keywords
124
+ if (_isIdentStart(c)) {
125
+ let j = i;
126
+ while (j < len && _isIdentBody(src[j])) j++;
127
+ const word = src.slice(i, j);
128
+ if (word === 'true') tokens.push({ type: TOK.BOOLEAN, value: true });
129
+ else if (word === 'false') tokens.push({ type: TOK.BOOLEAN, value: false });
130
+ else if (word === 'null') tokens.push({ type: TOK.NULL, value: null });
131
+ else if (word === 'undefined') tokens.push({ type: TOK.UNDEFINED, value: undefined });
132
+ else tokens.push({ type: TOK.IDENT, value: word });
133
+ i = j;
134
+ continue;
135
+ }
136
+
137
+ // Multi-char operators (longest match first)
138
+ if (c === '=' && src[i + 1] === '=' && src[i + 2] === '=') { tokens.push({ type: TOK.EQ_STRICT }); i += 3; continue; }
139
+ if (c === '!' && src[i + 1] === '=' && src[i + 2] === '=') { tokens.push({ type: TOK.NEQ_STRICT }); i += 3; continue; }
140
+ if (c === '=' && src[i + 1] === '=') { tokens.push({ type: TOK.EQ_LOOSE }); i += 2; continue; }
141
+ if (c === '!' && src[i + 1] === '=') { tokens.push({ type: TOK.NEQ_LOOSE }); i += 2; continue; }
142
+ if (c === '<' && src[i + 1] === '=') { tokens.push({ type: TOK.LTE }); i += 2; continue; }
143
+ if (c === '>' && src[i + 1] === '=') { tokens.push({ type: TOK.GTE }); i += 2; continue; }
144
+ if (c === '&' && src[i + 1] === '&') { tokens.push({ type: TOK.AND }); i += 2; continue; }
145
+ if (c === '|' && src[i + 1] === '|') { tokens.push({ type: TOK.OR }); i += 2; continue; }
146
+
147
+ // Single-char operators
148
+ if (c === '<') { tokens.push({ type: TOK.LT }); i++; continue; }
149
+ if (c === '>') { tokens.push({ type: TOK.GT }); i++; continue; }
150
+ if (c === '!') { tokens.push({ type: TOK.NOT }); i++; continue; }
151
+ if (c === '(') { tokens.push({ type: TOK.LPAREN }); i++; continue; }
152
+ if (c === ')') { tokens.push({ type: TOK.RPAREN }); i++; continue; }
153
+ if (c === '.') { tokens.push({ type: TOK.DOT }); i++; continue; }
154
+ if (c === '-') { tokens.push({ type: TOK.MINUS }); i++; continue; }
155
+
156
+ throw new Error(`Unexpected character '${c}' at ${i}`);
157
+ }
158
+
159
+ tokens.push({ type: TOK.EOF });
160
+ return tokens;
161
+ }
162
+
163
+ // ── Parser (recursive descent) ───────────────────────────────────────────────
164
+
165
+ function parse(tokens) {
166
+ let pos = 0;
167
+ const peek = () => tokens[pos];
168
+ const match = (...types) => types.includes(tokens[pos].type);
169
+
170
+ function parseOr() {
171
+ let left = parseAnd();
172
+ while (match(TOK.OR)) {
173
+ pos++;
174
+ const right = parseAnd();
175
+ left = { kind: 'logical', op: '||', left, right };
176
+ }
177
+ return left;
178
+ }
179
+
180
+ function parseAnd() {
181
+ let left = parseEquality();
182
+ while (match(TOK.AND)) {
183
+ pos++;
184
+ const right = parseEquality();
185
+ left = { kind: 'logical', op: '&&', left, right };
186
+ }
187
+ return left;
188
+ }
189
+
190
+ function parseEquality() {
191
+ let left = parseComparison();
192
+ while (match(TOK.EQ_STRICT, TOK.NEQ_STRICT, TOK.EQ_LOOSE, TOK.NEQ_LOOSE)) {
193
+ const op = tokens[pos++].type;
194
+ const right = parseComparison();
195
+ left = { kind: 'binary', op, left, right };
196
+ }
197
+ return left;
198
+ }
199
+
200
+ function parseComparison() {
201
+ let left = parseUnary();
202
+ while (match(TOK.LT, TOK.LTE, TOK.GT, TOK.GTE)) {
203
+ const op = tokens[pos++].type;
204
+ const right = parseUnary();
205
+ left = { kind: 'binary', op, left, right };
206
+ }
207
+ return left;
208
+ }
209
+
210
+ function parseUnary() {
211
+ if (match(TOK.NOT)) {
212
+ pos++;
213
+ return { kind: 'not', expr: parseUnary() };
214
+ }
215
+ return parsePrimary();
216
+ }
217
+
218
+ function parsePrimary() {
219
+ const tok = peek();
220
+
221
+ // Unary minus is only supported as a prefix on a numeric literal — we have
222
+ // no arithmetic operators, so '-' anywhere else is a parse error.
223
+ if (tok.type === TOK.MINUS) {
224
+ pos++;
225
+ const next = peek();
226
+ if (next.type !== TOK.NUMBER) throw new Error(`Expected number after '-'`);
227
+ pos++;
228
+ return { kind: 'literal', value: -next.value };
229
+ }
230
+
231
+ if (
232
+ tok.type === TOK.NUMBER ||
233
+ tok.type === TOK.STRING ||
234
+ tok.type === TOK.BOOLEAN ||
235
+ tok.type === TOK.NULL ||
236
+ tok.type === TOK.UNDEFINED
237
+ ) {
238
+ pos++;
239
+ return { kind: 'literal', value: tok.value };
240
+ }
241
+
242
+ if (tok.type === TOK.IDENT) {
243
+ pos++;
244
+ const segments = [tok.value];
245
+ while (match(TOK.DOT)) {
246
+ pos++;
247
+ const next = peek();
248
+ if (next.type !== TOK.IDENT) throw new Error(`Expected identifier after '.'`);
249
+ pos++;
250
+ segments.push(next.value);
251
+ }
252
+ return { kind: 'path', segments };
253
+ }
254
+
255
+ if (tok.type === TOK.LPAREN) {
256
+ pos++;
257
+ const expr = parseOr();
258
+ if (peek().type !== TOK.RPAREN) throw new Error(`Expected ')'`);
259
+ pos++;
260
+ return expr;
261
+ }
262
+
263
+ throw new Error(`Unexpected token ${tok.type}`);
264
+ }
265
+
266
+ const ast = parseOr();
267
+ if (peek().type !== TOK.EOF) {
268
+ throw new Error(`Unexpected trailing token ${peek().type}`);
269
+ }
270
+ return ast;
271
+ }
272
+
273
+ // ── Evaluator ────────────────────────────────────────────────────────────────
274
+
275
+ function _resolvePath(segments, ctx) {
276
+ let cur = ctx;
277
+ for (const seg of segments) {
278
+ if (cur == null) return undefined;
279
+ // Own-property walk only. Reaching Object.prototype (toString,
280
+ // constructor, __proto__, ...) is a sandbox-escape vector for any
281
+ // attacker who controls the expression string.
282
+ if (!Object.prototype.hasOwnProperty.call(cur, seg)) return undefined;
283
+ cur = cur[seg];
284
+ }
285
+ return cur;
286
+ }
287
+
288
+ function _evalNode(node, ctx) {
289
+ switch (node.kind) {
290
+ case 'literal':
291
+ return node.value;
292
+ case 'path':
293
+ return _resolvePath(node.segments, ctx);
294
+ case 'not':
295
+ return !_evalNode(node.expr, ctx);
296
+ case 'logical': {
297
+ const left = _evalNode(node.left, ctx);
298
+ if (node.op === '&&') return left ? _evalNode(node.right, ctx) : left;
299
+ if (node.op === '||') return left ? left : _evalNode(node.right, ctx);
300
+ return false;
301
+ }
302
+ case 'binary': {
303
+ const l = _evalNode(node.left, ctx);
304
+ const r = _evalNode(node.right, ctx);
305
+ switch (node.op) {
306
+ case TOK.EQ_STRICT: return l === r;
307
+ case TOK.NEQ_STRICT: return l !== r;
308
+ case TOK.EQ_LOOSE: return l == r; // intentional loose equality
309
+ case TOK.NEQ_LOOSE: return l != r; // intentional loose equality
310
+ case TOK.LT: return l < r;
311
+ case TOK.LTE: return l <= r;
312
+ case TOK.GT: return l > r;
313
+ case TOK.GTE: return l >= r;
314
+ default: return false;
315
+ }
316
+ }
317
+ default:
318
+ return false;
319
+ }
320
+ }
321
+
322
+ // ── Public API ───────────────────────────────────────────────────────────────
323
+
324
+ function evaluate(exprString, context) {
325
+ if (typeof exprString !== 'string' || exprString.trim() === '') {
326
+ console.warn(`[safe-expr] invalid expression (expected non-empty string): ${JSON.stringify(exprString)}`);
327
+ return false;
328
+ }
329
+
330
+ let ast;
331
+ try {
332
+ const tokens = tokenize(exprString);
333
+ ast = parse(tokens);
334
+ } catch (err) {
335
+ console.warn(`[safe-expr] parse error in "${exprString}": ${err.message}`);
336
+ return false;
337
+ }
338
+
339
+ let result;
340
+ try {
341
+ result = _evalNode(ast, context == null ? {} : context);
342
+ } catch (err) {
343
+ console.warn(`[safe-expr] evaluation error in "${exprString}": ${err.message}`);
344
+ return false;
345
+ }
346
+
347
+ return Boolean(result);
348
+ }
349
+
350
+ module.exports = { evaluate };
package/engine/shared.js CHANGED
@@ -1095,6 +1095,7 @@ const ENGINE_DEFAULTS = {
1095
1095
  ghPollEnabled: true, // poll GitHub PR status, comments, and reconciliation on each tick cycle
1096
1096
  prPollStatusEvery: 12, // poll PR build/review/merge status every N ticks for both ADO and GitHub (~12 min at default interval)
1097
1097
  prPollCommentsEvery: 12, // poll PR human comments every N ticks for both ADO and GitHub (~12 min at default interval)
1098
+ watchesIncludeBehindBy: false, // opt-in: when true, GitHub PR poll calls /compare/{base}...{head} once per pr per pollPrStatusEvery cadence to populate pr.behindBy (powers the `behind-master` watch predicate). Off by default to avoid the extra API call. ADO PRs always get null (no commit-graph walk yet).
1098
1099
  autoCompletePrs: false, // auto-merge PRs when builds green + review approved (opt-in)
1099
1100
  prMergeMethod: 'squash', // merge method: squash, merge, rebase
1100
1101
  ignoredCommentAuthors: [], // comments from these authors are auto-closed and never trigger fixes
@@ -1589,13 +1590,50 @@ const WATCH_CONDITION = {
1589
1590
  ENABLED: 'enabled', // schedule enabled
1590
1591
  DISABLED: 'disabled', // schedule disabled
1591
1592
  ACTIVITY_CHANGE: 'activity-change', // agent transitioned status (e.g. idle → working)
1593
+ // ── P-w4e2f6a1 — Phase 2.1: PR predicate conditions ──────────────────────
1594
+ // See engine/watches.js PR target type for trigger semantics.
1595
+ HEAD_COMMIT_CHANGE: 'head-commit-change', // PR headRefOid advanced (new push)
1596
+ MERGEABLE_FLIPPED: 'mergeable-flipped', // mergeable transitioned between true↔false (NOT involving null)
1597
+ READY_FOR_MERGE: 'ready-for-merge', // canonical compound: active+approved+passing+mergeable+!draft
1598
+ BEHIND_MASTER: 'behind-master', // pr.behindBy > 0 (treats null/undefined as not-behind)
1599
+ DRAFT_FLIPPED: 'draft-flipped', // isDraft transitioned between true↔false
1600
+ // ── P-w5b8d2c9 — Phase 2.2: work-item / plan / pipeline predicates ───────
1601
+ // See engine/watches.js for trigger semantics. Counters (_unchangedTicks,
1602
+ // _stuckStageTicks) are computed inside captureState by comparing the
1603
+ // freshly-captured snapshot against prevState — see _captureState which
1604
+ // now passes prevState as the 2nd arg.
1605
+ STALLED: 'stalled', // work-item: no captureState change for N ticks (default WATCH_STALLED_DEFAULT_TICKS)
1606
+ RETRY_LIMIT_REACHED: 'retry-limit-reached', // work-item: _retryCount >= ENGINE_DEFAULTS.maxRetries
1607
+ DEPENDENCY_MET: 'dependency-met', // work-item: _pendingReason transitioned away from 'dependency_unmet'
1608
+ ALL_ITEMS_DONE: 'all-items-done', // plan: items_done === items_total && items_total > 0
1609
+ ITEM_FAILED_N_TIMES: 'item-failed-n-times', // plan: any missing_features[*]._retryCount >= ENGINE_DEFAULTS.maxRetries
1610
+ STAGE_ADVANCED: 'stage-advanced', // pipeline: current_stage_id changed within same runId
1611
+ STUCK_IN_STAGE: 'stuck-in-stage', // pipeline: current_stage_id unchanged for N ticks (default WATCH_STUCK_STAGE_DEFAULT_TICKS)
1592
1612
  };
1613
+ // ── P-w5b8d2c9 — Phase 2.2: tick thresholds ────────────────────────────────
1614
+ // Default check interval is 5min (DEFAULT_WATCH_INTERVAL); 12 unchanged checks
1615
+ // ≈ 60 minutes. Spec: Math.ceil(60 / 5) = 12 tick-equivalents. Hard-coded
1616
+ // today; per-watch override may follow in a later phase.
1617
+ const WATCH_STALLED_DEFAULT_TICKS = 12;
1618
+ const WATCH_STUCK_STAGE_DEFAULT_TICKS = 12;
1593
1619
  // Absolute conditions auto-expire on first trigger when stopAfter=0 (fire-once semantics).
1594
1620
  // Change-based conditions (status-change, any, *-change) run forever when stopAfter=0.
1595
1621
  const WATCH_ABSOLUTE_CONDITIONS = new Set([
1596
1622
  WATCH_CONDITION.MERGED, WATCH_CONDITION.BUILD_FAIL, WATCH_CONDITION.BUILD_PASS,
1597
1623
  WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED,
1598
1624
  WATCH_CONDITION.CONCLUDED, WATCH_CONDITION.APPROVED, WATCH_CONDITION.REJECTED,
1625
+ // ready-for-merge is a compound state assertion (true at moment of check) —
1626
+ // fire-once when stopAfter=0 so the watch doesn't re-fire each tick while
1627
+ // the PR sits idle in the ready state.
1628
+ WATCH_CONDITION.READY_FOR_MERGE,
1629
+ // ── P-w5b8d2c9 — Phase 2.2: state-assertion conditions that should
1630
+ // fire-once when stopAfter=0. retry-limit-reached / all-items-done /
1631
+ // item-failed-n-times are compound state assertions ("this is true right
1632
+ // now"); without auto-expiry they would re-fire every tick while the
1633
+ // condition holds.
1634
+ WATCH_CONDITION.RETRY_LIMIT_REACHED,
1635
+ WATCH_CONDITION.ALL_ITEMS_DONE,
1636
+ WATCH_CONDITION.ITEM_FAILED_N_TIMES,
1599
1637
  ]);
1600
1638
  // Built-in follow-up action types invoked by the engine when a watch fires.
1601
1639
  // The action registry in engine/watch-actions.js is the source of truth; these
@@ -1607,6 +1645,7 @@ const WATCH_ACTION_TYPE = {
1607
1645
  DISPATCH_WORK_ITEM: 'dispatch-work-item',
1608
1646
  RUN_SKILL: 'run-skill',
1609
1647
  WEBHOOK: 'webhook',
1648
+ MINIONS_API: 'minions-api',
1610
1649
  CANCEL_WORK_ITEM: 'cancel-work-item',
1611
1650
  TRIGGER_PIPELINE: 'trigger-pipeline',
1612
1651
  ARCHIVE_PLAN: 'archive-plan',
@@ -3561,6 +3600,7 @@ module.exports = {
3561
3600
  backfillProjectWorkSourceDefaults,
3562
3601
  WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, DISPATCH_RESULT, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
3563
3602
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
3603
+ WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
3564
3604
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
3565
3605
  FAILURE_CLASS, ESCALATION_POLICY, COMPLETION_FIELDS,
3566
3606
  DEFAULT_AGENT_METRICS,