@yemi33/minions 0.1.1901 → 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
@@ -88,6 +88,85 @@ function _isNonActionableComment(c, config = {}) {
88
88
  return false;
89
89
  }
90
90
 
91
+ // W-mp2h696g000a7bc0 — Detect agent self-review-declined no-op comments.
92
+ //
93
+ // Per the documented contract (memory `subject: self-review`, docs/completion-reports.md:83-105):
94
+ // when an agent is dispatched to review a PR they implemented, they post a `gh pr comment`
95
+ // (no `VERDICT:` line) explaining the recusal and complete with `noop:true, verdict:null,
96
+ // needs_rerun:true`. Because `gh` authenticates as the shared PAT user (`yemi33`), the
97
+ // classifier can't tell the noop comment from a real human comment by author alone.
98
+ //
99
+ // Detection requires a stronger signal than author identity. Returns true only when BOTH:
100
+ // 1. Body has NO `VERDICT:` line (we never want to swallow a real verdict comment), AND
101
+ // 2. EITHER the body contains the canonical "Self-review declined" phrase AND references
102
+ // a dispatch id matching `<agent>-<type>-<uid>` that resolves to a same-agent
103
+ // review/implement dispatch in the completed history,
104
+ // OR the most recent review dispatch on this PR was assigned to the same agent as the
105
+ // PR author and completed with the noop:true / verdict:null / needs_rerun:true contract.
106
+ //
107
+ // This is a narrow allowlist for the noop pattern — generic PAT-user comments still flow
108
+ // through `_isNonActionableComment` and trigger `humanFeedback.pendingFix=true` as before.
109
+ function _isAgentSelfReviewDeclinedComment(c, { pr, dispatch } = {}) {
110
+ if (!c || !pr) return false;
111
+ const body = String(c.body || '');
112
+ if (!body) return false;
113
+ if (_hasMinionsReviewVerdict(body)) return false;
114
+
115
+ const prAuthorAgent = String(pr.agent || '').toLowerCase();
116
+ if (!prAuthorAgent) return false;
117
+
118
+ const completed = (dispatch && Array.isArray(dispatch.completed)) ? dispatch.completed : [];
119
+ const isNoopContract = (sc) => {
120
+ if (!sc || typeof sc !== 'object') return false;
121
+ if (sc.noop !== true && sc.noop !== 'true') return false;
122
+ if (sc.verdict !== null && sc.verdict !== undefined && sc.verdict !== '') return false;
123
+ if (sc.needs_rerun !== true && sc.needsRerun !== true) return false;
124
+ return true;
125
+ };
126
+
127
+ // Signal A — explicit "Self-review declined" phrase + verifiable dispatch id pointing
128
+ // at a same-agent review/implement/fix dispatch in the completed history.
129
+ const phraseMatch = /self[\s\-_]+review\s+declined/i.test(body);
130
+ if (phraseMatch) {
131
+ const dispatchIdMatches = body.match(/\b[a-z][a-z0-9]*-[a-z][a-z0-9]*-[a-z0-9]{8,}\b/g) || [];
132
+ for (const id of dispatchIdMatches) {
133
+ const entry = completed.find(d => d && d.id === id);
134
+ if (!entry) continue;
135
+ const agent = String(entry.agent || '').toLowerCase();
136
+ const t = String(entry.type || '').toLowerCase();
137
+ if (agent !== prAuthorAgent) continue;
138
+ if (t === 'review' || t === 'implement' || t === 'implement-large' || t === 'fix') {
139
+ return true;
140
+ }
141
+ }
142
+ }
143
+
144
+ // Signal B — most recent review dispatch on this PR was assigned to the PR author
145
+ // and completed with the documented noop contract. Catches the case where the agent
146
+ // forgot the canonical phrase but the completion-report contract still flags a noop.
147
+ const prId = String(pr.id || '');
148
+ const prNumberRaw = pr.prNumber;
149
+ const prNumber = prNumberRaw == null ? null : Number(prNumberRaw);
150
+ const reviewDispatches = completed.filter(d => {
151
+ if (!d || String(d.type || '').toLowerCase() !== 'review') return false;
152
+ const dpr = d.meta && d.meta.pr;
153
+ if (!dpr) return false;
154
+ if (prId && String(dpr.id || '') === prId) return true;
155
+ if (prNumber != null && Number(dpr.prNumber) === prNumber) return true;
156
+ return false;
157
+ });
158
+ if (reviewDispatches.length === 0) return false;
159
+ reviewDispatches.sort((a, b) => {
160
+ const ta = String(a.completed_at || a.created_at || '');
161
+ const tb = String(b.completed_at || b.created_at || '');
162
+ return tb.localeCompare(ta);
163
+ });
164
+ const latest = reviewDispatches[0];
165
+ if (!latest) return false;
166
+ if (String(latest.agent || '').toLowerCase() !== prAuthorAgent) return false;
167
+ return isNoopContract(latest.structuredCompletion);
168
+ }
169
+
91
170
  // ─── Per-Repo Poll Backoff ──────────────────────────────────────────────────
92
171
  // Tracks consecutive poll failures per repo slug to avoid spamming logs when
93
172
  // a repo is inaccessible. Backoff doubles each failure: 2min, 4min, 8min, 16min, max 30min.
@@ -417,6 +496,74 @@ async function pollPrStatus(config) {
417
496
  updated = true;
418
497
  }
419
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
+
420
567
  if (pr.status !== newStatus) {
421
568
  log('info', `PR ${pr.id} status: ${pr.status} → ${newStatus}`);
422
569
  pr.status = newStatus;
@@ -628,6 +775,14 @@ async function pollPrStatus(config) {
628
775
  // ─── Poll Human Comments on PRs ─────────────────────────────────────────────
629
776
 
630
777
  async function pollPrHumanComments(config) {
778
+ // Load dispatch state once per poll cycle (cached for ~2s by queries.getDispatch).
779
+ // Used by `_isAgentSelfReviewDeclinedComment` to verify dispatch ids referenced in
780
+ // candidate noop comments belong to the PR author.
781
+ const queries = require('./queries');
782
+ const dispatch = (() => {
783
+ try { return queries.getDispatch(); }
784
+ catch { return { pending: [], active: [], completed: [] }; }
785
+ })();
631
786
  const totalUpdated = await forEachActiveGhPr(config, async (project, pr, prNum, slug) => {
632
787
  // Get issue comments (general PR comments)
633
788
  const comments = await ghApi(`/issues/${prNum}/comments`, slug);
@@ -653,7 +808,12 @@ async function pollPrHumanComments(config) {
653
808
  for (const c of allComments) {
654
809
  const date = c.created_at || c.updated_at || '';
655
810
  const dateMs = date ? new Date(date).getTime() : 0;
656
- const isNonActionable = _isNonActionableComment(c, config);
811
+ // W-mp2h696g000a7bc0 agent self-review-declined no-op comments are posted via
812
+ // `gh pr comment` and authenticate as the shared PAT user, so author identity alone
813
+ // can't distinguish them from real human feedback. The narrow allowlist check uses
814
+ // the comment body + dispatch history to identify the documented noop pattern.
815
+ const isNonActionable = _isNonActionableComment(c, config)
816
+ || _isAgentSelfReviewDeclinedComment(c, { pr, dispatch });
657
817
  if (dateMs) allCommentDates.push(date);
658
818
  if (isNonActionable) continue;
659
819
  const entry = {
@@ -975,5 +1135,6 @@ module.exports = {
975
1135
  _hasMinionsReviewVerdict, // exported for testing
976
1136
  _isAgentComment, // exported for testing
977
1137
  _isNonActionableComment, // exported for testing
1138
+ _isAgentSelfReviewDeclinedComment, // exported for testing
978
1139
  _isPreviewStatusComment, // exported for testing
979
1140
  };
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,