@yemi33/minions 0.1.2017 → 0.1.2018

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.
@@ -32,7 +32,7 @@ How the engine manages the lifecycle of a PR from creation through review, fix,
32
32
  5. Build failure (`buildStatus === 'failing'`)
33
33
  6. Merge conflict (`_mergeConflict`)
34
34
 
35
- When multiple problems coexist, earlier triggers get the first chance to enqueue work. The local `fixDispatched` flag is declared before the fix triggers and set after human-feedback, review-feedback, and build-failure dispatches. Conflict fixes run last and explicitly require `!fixDispatched`, so any earlier successful fix dispatch suppresses the conflict fix for that PR in the same discovery pass. Build fixes are evaluated after human and minion review feedback, but the build-fix condition itself is not gated by `!fixDispatched`.
35
+ When multiple problems coexist, earlier triggers get the first chance to enqueue work. The local `fixDispatched` flag is declared before the initial review trigger and set after first-review, human-feedback, review-feedback, and build-failure dispatches. Conflict fixes run last and explicitly require `!fixDispatched`, so any earlier successful review/fix dispatch suppresses the conflict fix for that PR in the same discovery pass. Build fixes are evaluated after human and minion review feedback, but the build-fix condition itself is not gated by `!fixDispatched`.
36
36
 
37
37
  The engine does not cap review→fix cycles or build-fix attempts. Each trigger evaluates its own gates on every discovery pass; loops stop only when the underlying condition clears (reviewer approves, build passes, conflict resolves, human feedback handled). Operators who need to halt automation on a runaway PR should disable the relevant feature flag (`evalLoop`, `autoFixBuilds`, `autoFixConflicts`) or close the PR.
38
38
 
package/engine/ado.js CHANGED
@@ -10,6 +10,7 @@ const { exec, execAsync, getAdoOrgBase, log, ts, dateStamp, PR_STATUS, createThr
10
10
  const { getPrs } = require('./queries');
11
11
  const { mutateJsonFileLocked } = shared;
12
12
  const { acquireAdoToken } = require('./ado-token');
13
+ const { isPreviewStatusBody, hasMinionsMarker, hasVerdictPrefix } = require('./comment-classifier');
13
14
  const { wrapUntrusted, buildSource } = require('./untrusted-fence');
14
15
 
15
16
  // Lazy require to avoid circular dependency — only needed for engine().handlePostMerge
@@ -1490,6 +1491,32 @@ async function pollPrHumanComments(config) {
1490
1491
  const allCommentDates = [];
1491
1492
  const ignoredAuthors = (config.engine?.ignoredCommentAuthors || []).map(a => a.toLowerCase());
1492
1493
 
1494
+ // P-f23classifier (F3, security note): ADO has no `viewerDidAuthor`
1495
+ // equivalent. A human pasting our `<!-- minions:agent=... -->` marker
1496
+ // (e.g. by copy-paste from a fix-summary) would otherwise be
1497
+ // misclassified as agent-authored. Cross-check marker-presence against
1498
+ // the configured Minions identity (uniqueName / displayName / id /
1499
+ // descriptor) so marker-presence alone NEVER suffices on ADO. When
1500
+ // `minionsAdoIdentities` is empty (default), marker-presence is ignored
1501
+ // entirely on ADO — the body still has to match one of the legacy
1502
+ // regexes (Minions(...), by Minions, [minions], VERDICT:) to classify
1503
+ // as agent-authored.
1504
+ const minionsAdoIdentities = new Set(
1505
+ (Array.isArray(config.engine?.minionsAdoIdentities) ? config.engine.minionsAdoIdentities : [])
1506
+ .map(s => String(s || '').toLowerCase())
1507
+ .filter(Boolean)
1508
+ );
1509
+ const isAdoMinionsAuthor = (comment) => {
1510
+ if (minionsAdoIdentities.size === 0) return false;
1511
+ const author = comment?.author || {};
1512
+ const candidates = [author.uniqueName, author.displayName, author.id, author.descriptor];
1513
+ for (const cand of candidates) {
1514
+ if (typeof cand !== 'string' || !cand) continue;
1515
+ if (minionsAdoIdentities.has(cand.toLowerCase())) return true;
1516
+ }
1517
+ return false;
1518
+ };
1519
+
1493
1520
  for (const thread of threads) {
1494
1521
  // P-8c6a4f2d: collect comments from ALL threads regardless of status.
1495
1522
  // Previously we skipped resolved/closed threads here, which dropped
@@ -1504,25 +1531,31 @@ async function pollPrHumanComments(config) {
1504
1531
  // Track date for cutoff BEFORE author/body filters so bot/CI/ignored
1505
1532
  // comments still advance the cutoff.
1506
1533
  if (comment.publishedDate) allCommentDates.push(comment.publishedDate);
1507
- // Skip explicitly ignored authors and CI-report bodies, but do not ignore bot authors by default.
1534
+ // Skip explicitly ignored authors.
1508
1535
  const authorName = (comment.author?.displayName || '').toLowerCase();
1509
1536
  if (ignoredAuthors.some(a => authorName.includes(a))) continue;
1510
- if (/^#{1,3}\s*(Coverage|Build|Test|Deploy|Pipeline)\s*(Report|Status|Result|Summary)/i.test(content)) continue;
1511
- // Detect agent comments (included in context, but don't trigger fix).
1512
- // W-mpbhnaim000q9ed3: also match the VERDICT-prefixed review body. The
1513
- // `Review by Minions (...)` sign-off can be skipped by a review agent
1514
- // (Ripley on PR ado:office/iss/constellation#5056697, 2026-05-18: posted
1515
- // a 1100-char APPROVE body with no sign-off engine treated it as
1516
- // human feedback fix-dispatch loop spammed "Skipping human-feedback
1517
- // fix ... noop on the same head" once a minute for 9+ hours). The
1518
- // VERDICT prefix is itself an engine-owned structured-output contract
1519
- // (parseReviewVerdict, playbooks/review.md:69-72) humans don't write
1520
- // it as the first line of a comment. The `^` + /m anchor keeps quoted
1521
- // verdict text inside a human reply from matching.
1522
- const isAgent = /\bMinions\s*\(/i.test(content)
1537
+ // P-f23classifier (F2): filter preview-status / CI-report bot bodies
1538
+ // (Firebase / Appetize / Deploy / Preview / Coverage-Build-Test
1539
+ // headings / shields.io badges) for parity with engine/github.js
1540
+ // _isPreviewStatusComment. ADO has no `user.type === 'bot'` flag,
1541
+ // so the filter is body-only — same behavior the inline ADO CI
1542
+ // filter had before extraction, now broadened to include the GH
1543
+ // preview signatures so a Firebase/Appetize bot comment on an ADO
1544
+ // PR no longer advances pendingFix.
1545
+ if (isPreviewStatusBody(content)) continue;
1546
+ // P-f23classifier (F3): detect agent comments (included in context
1547
+ // but don't trigger fix). Structurally symmetric to
1548
+ // engine/github.js _isAgentComment + _isMinionsAuthoredComment:
1549
+ // - marker-presence + ADO identity cross-check (spoof guard), OR
1550
+ // - legacy body patterns (Minions(...), by Minions, [minions]), OR
1551
+ // - hasVerdictPrefix(content) — engine-owned structured-output
1552
+ // contract emitted by playbooks/review.md.
1553
+ const markerAuthored = hasMinionsMarker(content) && isAdoMinionsAuthor(comment);
1554
+ const isAgent = markerAuthored
1555
+ || /\bMinions\s*\(/i.test(content)
1523
1556
  || /\bby\s+Minions\b/i.test(content)
1524
1557
  || /\[minions\]/i.test(content)
1525
- || /^\s*VERDICT:\s*(APPROVE|REQUEST_CHANGES)\b/im.test(content);
1558
+ || hasVerdictPrefix(content);
1526
1559
 
1527
1560
  const entry = {
1528
1561
  threadId: thread.id,
@@ -0,0 +1,138 @@
1
+ /**
2
+ * engine/comment-classifier.js — P-f23classifier (F2 + F3)
3
+ *
4
+ * Host-agnostic PR-comment body classifiers shared by engine/github.js and
5
+ * engine/ado.js pollers. Three pure body-only predicates:
6
+ *
7
+ * isPreviewStatusBody(body) — Firebase / Appetize / Deploy / Preview bot
8
+ * signatures + Coverage/Build/Test/Deploy/Pipeline report headings +
9
+ * shields.io-style badge images. Ported from engine/github.js
10
+ * `_isPreviewStatusComment` body checks. ADO has no `user.type === 'bot'`
11
+ * equivalent, so the GH-side bot-author gate stays in github.js — this
12
+ * module's body match is what both pollers consume.
13
+ *
14
+ * hasMinionsMarker(body) — structural `<!-- minions:agent=… kind=… -->`
15
+ * check using gh-comment.MINIONS_COMMENT_MARKER_RE. Strict: marker must
16
+ * occupy the body's first line entirely (no leading content, no trailing
17
+ * content on the same line). Same semantics as the GH-side
18
+ * `_isMinionsAuthoredComment` body check.
19
+ *
20
+ * hasVerdictPrefix(body) — `VERDICT: APPROVE | REQUEST_CHANGES` prefix
21
+ * check. Tightened in P-f4verdictregex (F4, Wave 3): requires a line
22
+ * that starts (under /m) with optional whitespace, an optional bold
23
+ * (`**` / `__`) or heading (`#+ `) marker, the literal `VERDICT:` token,
24
+ * whitespace, and `APPROVE` or `REQUEST_CHANGES` ending on a word
25
+ * boundary. Case-sensitive; legacy lowercase / `REQUEST-CHANGES` /
26
+ * `REQUEST CHANGES` variants no longer match. Strict body-position-0 is
27
+ * NOT required — real verdict bodies have preambles ~1/3 of the time,
28
+ * so any line-start position counts. Quoted (`> VERDICT:`) and inline
29
+ * mid-paragraph mentions are rejected (line-start anchor + only `**`,
30
+ * `__`, `#+` allowed before `VERDICT:`).
31
+ *
32
+ * IMPORTANT — author identity is intentionally NOT consulted here. These are
33
+ * body-only predicates. Callers MUST combine them with their own identity
34
+ * gates before treating a comment as agent-authored:
35
+ *
36
+ * GitHub: `comment.viewerDidAuthor === true`, backfilled via `gh api user`
37
+ * by engine/github.js `_backfillViewerDidAuthor`. The combined
38
+ * gate (`viewerDidAuthor && hasMinionsMarker`) is what makes the
39
+ * marker non-spoofable on GH.
40
+ *
41
+ * ADO: `comment.author.uniqueName` (or displayName / id / descriptor)
42
+ * matched case-insensitively against
43
+ * `config.engine.minionsAdoIdentities`. ADO has no
44
+ * `viewerDidAuthor` equivalent, so a human pasting our marker
45
+ * would otherwise be misclassified as agent-authored. The combined
46
+ * gate (`isMinionsAdoAuthor && hasMinionsMarker`) is the
47
+ * structural complement that prevents marker spoofing.
48
+ *
49
+ * Marker-presence ALONE never classifies a comment as agent on either
50
+ * platform — that contract is enforced at the caller, not here.
51
+ */
52
+
53
+ const { MINIONS_COMMENT_MARKER_RE } = require('./gh-comment');
54
+
55
+ // ── Preview / status / CI-report body patterns ──────────────────────────────
56
+ // Coverage / Build / Test / Deploy / Pipeline report headings (level-1-to-3).
57
+ const _CI_REPORT_HEADING_RE =
58
+ /^#{1,3}\s*(Coverage|Build|Test|Deploy|Pipeline)\s*(Report|Status|Result|Summary)/i;
59
+
60
+ // shields.io / similar badge images.
61
+ const _BADGE_RE = /!\[.*\]\(https?:\/\/.*badge/i;
62
+
63
+ // Firebase App Distribution / Appetize / Preview / Deploy headings.
64
+ const _PREVIEW_HEADING_RE =
65
+ /^#{1,3}\s*(Firebase(?:\s+App\s+Distribution)?|Appetize|Preview|Deploy(?:ment)?|Status)\b/i;
66
+
67
+ const _FIREBASE_BRAND_RE = /\bFirebase\s+App\s+Distribution\b/i;
68
+ const _FIREBASE_URL_RE = /\bappdistribution\.firebase\b/i;
69
+ const _APPETIZE_URL_RE = /\bappetize\.io\b/i;
70
+
71
+ // "deploy ready", "deployment available", "preview succeeded", etc.
72
+ const _DEPLOY_READY_RE =
73
+ /\b(?:deploy|deployment|preview)\s+(?:ready|available|succeeded|complete|completed)\b/i;
74
+
75
+ function isPreviewStatusBody(body) {
76
+ const text = String(body || '');
77
+ if (!text) return false;
78
+ if (_CI_REPORT_HEADING_RE.test(text)) return true;
79
+ if (_BADGE_RE.test(text)) return true;
80
+ if (_PREVIEW_HEADING_RE.test(text)) return true;
81
+ if (_FIREBASE_BRAND_RE.test(text)) return true;
82
+ if (_FIREBASE_URL_RE.test(text)) return true;
83
+ if (_APPETIZE_URL_RE.test(text)) return true;
84
+ if (_DEPLOY_READY_RE.test(text)) return true;
85
+ return false;
86
+ }
87
+
88
+ // ── Structural Minions marker ──────────────────────────────────────────────
89
+ //
90
+ // MINIONS_COMMENT_MARKER_RE uses the /m flag so its `^` anchor matches the
91
+ // start of ANY line. For body classification we additionally require the
92
+ // marker to occupy the FIRST line of the body entirely — no leading text, no
93
+ // trailing content on the same line. That matches the body-only contract
94
+ // gh-comment.buildMinionsCommentBody produces (marker, \n\n, body) and
95
+ // prevents quoted/blockquoted markers in human replies from triggering the
96
+ // classifier.
97
+ function hasMinionsMarker(body) {
98
+ const text = String(body || '');
99
+ if (!text) return false;
100
+ const m = MINIONS_COMMENT_MARKER_RE.exec(text);
101
+ if (!m) return false;
102
+ if (m.index !== 0) return false;
103
+ const lineEnd = text.indexOf('\n');
104
+ const firstLine = lineEnd === -1 ? text : text.slice(0, lineEnd);
105
+ return firstLine.trim() === m[0].trim();
106
+ }
107
+
108
+ // ── VERDICT prefix ─────────────────────────────────────────────────────────
109
+ //
110
+ // P-f4verdictregex (F4, Wave 3): tightened from the GH-flavored permissive
111
+ // regex to a strict line-start form. The body must contain a line that
112
+ // starts with optional whitespace, an optional bold (`**` / `__`) or
113
+ // heading (`#+ `) marker, the literal `VERDICT:` token, whitespace, and
114
+ // `APPROVE` or `REQUEST_CHANGES` ending on a word boundary. Case-sensitive
115
+ // (no /i flag) — playbooks/review.md emits the canonical uppercase form.
116
+ // /m flag lets a verdict appear after a preamble (~1/3 of real bodies have
117
+ // a preamble before the verdict line). Quoted (`> VERDICT:`) and inline
118
+ // mid-paragraph mentions are rejected because nothing other than
119
+ // whitespace + `**` / `__` / `#+ ` is allowed before `VERDICT:`.
120
+ //
121
+ // The optional trailing `(?:\*\*|__)?` group lets `__bold__` markdown close
122
+ // cleanly — `_` is a word character, so without consuming the closing
123
+ // `__` the trailing `\b` would not match `__VERDICT: APPROVE__`. The
124
+ // alternation backtracks for `**...**` bodies (trailing `**` consumed then
125
+ // `\b` fails, engine retries with 0-width trailing match and `\b` succeeds
126
+ // at the `E` → `*` transition).
127
+ const _VERDICT_PREFIX_RE =
128
+ /^\s*(?:\*\*|__|#+\s*)?VERDICT:\s+(APPROVE|REQUEST_CHANGES)(?:\*\*|__)?\b/m;
129
+
130
+ function hasVerdictPrefix(body) {
131
+ return _VERDICT_PREFIX_RE.test(String(body || ''));
132
+ }
133
+
134
+ module.exports = {
135
+ isPreviewStatusBody,
136
+ hasMinionsMarker,
137
+ hasVerdictPrefix,
138
+ };
package/engine/github.js CHANGED
@@ -7,7 +7,7 @@
7
7
  const shared = require('./shared');
8
8
  const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeJsonArr, safeWrite, mutateJsonFileLocked, mutatePullRequests, MINIONS_DIR, getPrLinks, backfillPrPrdItems, log, ts, dateStamp, PR_STATUS, PR_POLLABLE_STATUSES, ENGINE_DEFAULTS, createThrottleTracker, getProjectOrg } = shared;
9
9
  const { getPrs } = require('./queries');
10
- const { MINIONS_COMMENT_MARKER_RE } = require('./gh-comment');
10
+ const { isPreviewStatusBody, hasMinionsMarker, hasVerdictPrefix } = require('./comment-classifier');
11
11
  const { wrapUntrusted, buildSource } = require('./untrusted-fence');
12
12
  const ghToken = require('./gh-token');
13
13
  const path = require('path');
@@ -59,6 +59,21 @@ function _setShellSafeGhForTest(fn) {
59
59
  _shellSafeGhOverride = (typeof fn === 'function') ? fn : null;
60
60
  }
61
61
 
62
+ // P-f1ghstale: test seam to mock ghApi at the call-site level. ghApi itself
63
+ // always catches (returns null on failure, GH_NOT_FOUND on 404), so the inner
64
+ // `catch` in checkLiveBuildAndConflict cannot be reached by mocking the shell
65
+ // layer alone. This seam lets tests inject a throwing/resolving override that
66
+ // exercises the buildStatusStale branch (parity with ADO's adoFetch testability
67
+ // via globalThis.fetch).
68
+ let _ghApiOverrideForTest = null;
69
+ function _setGhApiForTest(fn) {
70
+ _ghApiOverrideForTest = (typeof fn === 'function') ? fn : null;
71
+ }
72
+ async function _ghApiViaTestSeam(endpoint, slug, opts) {
73
+ if (_ghApiOverrideForTest) return _ghApiOverrideForTest(endpoint, slug, opts);
74
+ return ghApi(endpoint, slug, opts);
75
+ }
76
+
62
77
  // ─── Constants ──────────────────────────────────────────────────────────────
63
78
 
64
79
  // 10 MB — prevents maxBuffer exceeded errors on repos with many open PRs.
@@ -78,8 +93,14 @@ function getRepoSlug(project) {
78
93
  return `${org}/${repo}`;
79
94
  }
80
95
 
96
+ // P-f23classifier (F4): delegates to the host-agnostic VERDICT prefix check
97
+ // in engine/comment-classifier.js. The literal regex used to live here;
98
+ // extraction allowed F4 (Wave 3) to tighten the prefix in a single place.
99
+ // The tightened form requires `VERDICT: APPROVE|REQUEST_CHANGES` at line
100
+ // start (under /m), optionally wrapped in `**`, `__`, or `#+ ` markers —
101
+ // see engine/comment-classifier.js for the full contract.
81
102
  function _hasMinionsReviewVerdict(body) {
82
- return /(?:^|\n)\s*\*{0,2}VERDICT[:\s]+\*{0,2}(?:APPROVE|REQUEST[_\s-]?CHANGES)\*{0,2}\b/i.test(String(body || ''));
103
+ return hasVerdictPrefix(body);
83
104
  }
84
105
 
85
106
  function _isAgentComment(c) {
@@ -92,29 +113,21 @@ function _isAgentComment(c) {
92
113
  return false;
93
114
  }
94
115
 
95
- function _isCiReportCommentBody(body) {
96
- const text = String(body || '');
97
- if (/^#{1,3}\s*(Coverage|Build|Test|Deploy|Pipeline)\s*(Report|Status|Result|Summary)/i.test(text)) return true;
98
- if (/!\[.*\]\(https?:\/\/.*badge/i.test(text)) return true;
99
- return false;
100
- }
101
-
102
116
  function _isGitHubBotComment(c) {
103
117
  const login = String(c?.user?.login || '');
104
118
  const type = String(c?.user?.type || '');
105
119
  return type.toLowerCase() === 'bot' || /\[bot\]$/i.test(login);
106
120
  }
107
121
 
122
+ // P-f23classifier (F2): retains the GH-side bot-author gate (ADO has no
123
+ // `user.type === 'bot'` equivalent) but delegates the body check to the
124
+ // host-agnostic isPreviewStatusBody helper in engine/comment-classifier.js.
125
+ // The body matcher covers Coverage/Build/Test/Deploy/Pipeline report
126
+ // headings + Firebase App Distribution + Appetize + Deploy-ready phrases +
127
+ // shields.io badges (same set as before the extraction).
108
128
  function _isPreviewStatusComment(c) {
109
129
  if (!_isGitHubBotComment(c)) return false;
110
- const body = String(c?.body || '');
111
- if (_isCiReportCommentBody(body)) return true;
112
- if (/^#{1,3}\s*(Firebase(?:\s+App\s+Distribution)?|Appetize|Preview|Deploy(?:ment)?|Status)\b/i.test(body)) return true;
113
- if (/\bFirebase\s+App\s+Distribution\b/i.test(body)) return true;
114
- if (/\bappdistribution\.firebase\b/i.test(body)) return true;
115
- if (/\bappetize\.io\b/i.test(body)) return true;
116
- if (/\b(?:deploy|deployment|preview)\s+(?:ready|available|succeeded|complete|completed)\b/i.test(body)) return true;
117
- return false;
130
+ return isPreviewStatusBody(c?.body);
118
131
  }
119
132
 
120
133
  function _isNonActionableComment(c, config = {}) {
@@ -122,8 +135,14 @@ function _isNonActionableComment(c, config = {}) {
122
135
  const login = String(c?.user?.login || '').toLowerCase();
123
136
  if (ignoredAuthors.has(login)) return true;
124
137
  if (_isAgentComment(c)) return true;
125
- if (_isCiReportCommentBody(c?.body)) return true;
126
- if (_isPreviewStatusComment(c)) return true;
138
+ // P-f23classifier (F2): body-only preview/CI-report check is now host-
139
+ // agnostic. The legacy `_isCiReportCommentBody` body match (Coverage /
140
+ // Build / Test / Deploy / Pipeline headings + shields.io badges) is the
141
+ // strict subset of isPreviewStatusBody, so a single call covers both
142
+ // pre-existing branches. Bot-author gated preview matches still go
143
+ // through _isPreviewStatusComment for callers that want the narrower
144
+ // bot-only signal exported for testing.
145
+ if (isPreviewStatusBody(c?.body)) return true;
127
146
  if (_isMinionsAuthoredComment(c)) return true;
128
147
  return false;
129
148
  }
@@ -151,20 +170,14 @@ function _isNonActionableComment(c, config = {}) {
151
170
  //
152
171
  // Sub-item -a populates the marker on every minions-posted comment via a helper.
153
172
  // Sub-item -c migrates existing engine call sites + playbook examples.
173
+ //
174
+ // P-f23classifier (F3): the body-only "structural marker on own first line"
175
+ // check is now hasMinionsMarker in engine/comment-classifier.js. GH retains
176
+ // the viewerDidAuthor gate as the spoof guard (ADO uses a uniqueName cross-
177
+ // check against config.engine.minionsAdoIdentities instead — see ado.js).
154
178
  function _isMinionsAuthoredComment(c) {
155
179
  if (!c || c.viewerDidAuthor !== true) return false;
156
- const body = c.body || '';
157
- const m = MINIONS_COMMENT_MARKER_RE.exec(body);
158
- if (!m) return false;
159
- // Strict: marker must be on its OWN first line (no leading content,
160
- // no trailing content on the same line). MINIONS_COMMENT_MARKER_RE uses
161
- // /m so it would otherwise match the start of any line; we additionally
162
- // require the match to be at index 0 AND to occupy the first line
163
- // exactly (modulo surrounding whitespace).
164
- if (m.index !== 0) return false;
165
- const lineEnd = body.indexOf('\n');
166
- const firstLine = lineEnd === -1 ? body : body.slice(0, lineEnd);
167
- return firstLine.trim() === m[0].trim();
180
+ return hasMinionsMarker(c.body || '');
168
181
  }
169
182
 
170
183
  // ─── Viewer Login Resolution ────────────────────────────────────────────────
@@ -1371,6 +1384,8 @@ async function dismissPriorViewerChangesRequestedReviews(pr, project) {
1371
1384
  * {
1372
1385
  * buildStatus: 'failing' | 'passing' | 'running' | 'none' | null,
1373
1386
  * mergeConflict: boolean,
1387
+ * buildStatusStale?: boolean,
1388
+ * buildStatusDetail?: string,
1374
1389
  * }
1375
1390
  *
1376
1391
  * `mergeConflict` is true only when GitHub explicitly reports `mergeable: false`
@@ -1379,6 +1394,10 @@ async function dismissPriorViewerChangesRequestedReviews(pr, project) {
1379
1394
  *
1380
1395
  * `buildStatus` is null when we couldn't query check-runs or the PR isn't open;
1381
1396
  * caller falls back to cached value.
1397
+ *
1398
+ * `buildStatusStale` (+ `buildStatusDetail`) are set when the inner check-runs
1399
+ * query throws — P-f1ghstale parity with ADO so engine.js's stale-marker branch
1400
+ * fires for GitHub too.
1382
1401
  */
1383
1402
  async function checkLiveBuildAndConflict(pr, project) {
1384
1403
  try {
@@ -1386,7 +1405,7 @@ async function checkLiveBuildAndConflict(pr, project) {
1386
1405
  if (!slug) return null;
1387
1406
  const prNum = shared.getPrNumber(pr);
1388
1407
  if (!prNum) return null;
1389
- const prData = await ghApi(`/pulls/${prNum}`, slug);
1408
+ const prData = await _ghApiViaTestSeam(`/pulls/${prNum}`, slug);
1390
1409
  if (!prData || prData === GH_NOT_FOUND) return null;
1391
1410
 
1392
1411
  // Conflict signal — only treat `mergeable === false` as a positive
@@ -1401,9 +1420,17 @@ async function checkLiveBuildAndConflict(pr, project) {
1401
1420
  // check-runs classification so the live read and cached poll agree on
1402
1421
  // 'failing' / 'passing' / 'running' / 'none'.
1403
1422
  let buildStatus = null;
1423
+ // P-f1ghstale: stale-signal parity with ADO. When the check-runs query
1424
+ // throws (transient gh CLI failure, network blip), surface a stale signal
1425
+ // so engine.js's `if (live?.buildStatusStale)` branch can persist the
1426
+ // cached state instead of treating a missing buildStatus as recovered.
1427
+ // EXPLICIT NON-GOALS (rejected in plan): no mergeable===null branch,
1428
+ // no wallclock/age branch, no empty check_runs branch.
1429
+ let buildStatusStale = false;
1430
+ let buildStatusDetail = '';
1404
1431
  if (prData.state === 'open' && prData.head?.sha) {
1405
1432
  try {
1406
- const checksData = await ghApi(`/commits/${prData.head.sha}/check-runs`, slug);
1433
+ const checksData = await _ghApiViaTestSeam(`/commits/${prData.head.sha}/check-runs`, slug);
1407
1434
  if (checksData && Array.isArray(checksData.check_runs)) {
1408
1435
  const runs = checksData.check_runs;
1409
1436
  if (runs.length === 0) {
@@ -1417,10 +1444,18 @@ async function checkLiveBuildAndConflict(pr, project) {
1417
1444
  else buildStatus = 'running';
1418
1445
  }
1419
1446
  }
1420
- } catch (e) { log('warn', `Live build check checks query for ${pr.id}: ${e.message}`); }
1447
+ } catch (e) {
1448
+ buildStatusStale = true;
1449
+ buildStatusDetail = `Live check-runs query failed: ${e.message}`;
1450
+ log('warn', `Live build check checks query for ${pr.id}: ${e.message}`);
1451
+ }
1421
1452
  }
1422
1453
 
1423
- return { buildStatus, mergeConflict };
1454
+ return {
1455
+ buildStatus,
1456
+ mergeConflict,
1457
+ ...(buildStatusStale ? { buildStatusStale, buildStatusDetail } : {}),
1458
+ };
1424
1459
  } catch (e) {
1425
1460
  log('warn', `Live build/conflict check for ${pr.id}: ${e.message}`);
1426
1461
  return null;
@@ -1608,5 +1643,6 @@ module.exports = {
1608
1643
  _backfillViewerDidAuthor, // exported for testing (W-mp3bp0ha000997ab-b backfill)
1609
1644
  _setExecAsyncForTest, // W-mp5trwh60008386d: test seam to mock `gh api` shell-outs
1610
1645
  _setShellSafeGhForTest, // P-f2-gh-shell (F2): test seam to mock argv-form gh api calls
1646
+ _setGhApiForTest, // P-f1ghstale: test seam to mock ghApi call sites for stale-signal branch coverage
1611
1647
  GH_NOT_FOUND, // W-mp5trwh60008386d: exported so tests can assert sentinel propagation
1612
1648
  };
@@ -1687,11 +1687,21 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
1687
1687
  // Actively clear the agent's prior negative on flip, then re-check live so we
1688
1688
  // never claim approved if some OTHER reviewer (human, different minion
1689
1689
  // account) still has a negative vote/review on the PR.
1690
+ //
1691
+ // P-f7splitgate — split the gate into two predicates:
1692
+ // * canDismissOwnPriorNegative: dismissal of the engine's own previously-posted
1693
+ // REQUEST_CHANGES on a verdict flip to APPROVE runs UNCONDITIONALLY. This is
1694
+ // correctness (cleaning up our own stale state), not policy, so it must NOT
1695
+ // be gated by autoApplyReviewVote.
1696
+ // * autoApplyVote: continues to gate POSITIVE auto-actions — mirroring the
1697
+ // live platform status into local reviewStatus below (which can promote the
1698
+ // PR to 'approved' based on platform-side votes).
1690
1699
  const prevReviewStatus = reviewPr?.reviewStatus || '';
1691
1700
  const wasNegative = prevReviewStatus === 'changes-requested' || prevReviewStatus === 'waiting'
1692
1701
  || liveStatus === 'changes-requested' || liveStatus === 'waiting';
1693
1702
  const autoApplyVote = config?.engine?.autoApplyReviewVote ?? ENGINE_DEFAULTS.autoApplyReviewVote;
1694
- if (autoApplyVote && verdictRaw === 'approved' && !isSelfReview && wasNegative && projectObjForChecks) {
1703
+ const canDismissOwnPriorNegative = verdictRaw === 'approved' && !isSelfReview && wasNegative && projectObjForChecks;
1704
+ if (canDismissOwnPriorNegative) {
1695
1705
  try {
1696
1706
  const reconcileFn = hostForChecks === 'github'
1697
1707
  ? require('./github').dismissPriorViewerChangesRequestedReviews
package/engine/shared.js CHANGED
@@ -1692,7 +1692,6 @@ const ENGINE_DEFAULTS = {
1692
1692
  autoReviewPrs: true, // auto-dispatch review agents for newly opened agent PRs
1693
1693
  autoReReviewPrs: true, // auto-dispatch review agents after a PR fix is pushed
1694
1694
  autoFixReviewFeedback: true, // auto-dispatch fix agents for minions review changes-requested verdicts
1695
- autoApplyReviewVote: false, // when true, review verdicts (APPROVE / REQUEST_CHANGES) automatically flip the platform vote; when false (default), verdicts are informational only
1696
1695
  autoFixHumanComments: true, // auto-dispatch fix agents for actionable human PR comments
1697
1696
  prNoOpFixPauseAttempts: 2, // pause one PR automation cause after repeated no-op fixes for unchanged evidence
1698
1697
  completionReportRetentionDays: 90, // retain completion report sidecars beyond capped dispatch history
@@ -1739,6 +1738,16 @@ const ENGINE_DEFAULTS = {
1739
1738
  autoCompletePrs: false, // auto-merge PRs when builds green + review approved (opt-in)
1740
1739
  prMergeMethod: 'squash', // merge method: squash, merge, rebase
1741
1740
  ignoredCommentAuthors: [], // comments from these authors are auto-closed and never trigger fixes
1741
+ // P-f23classifier (F3, security note): ADO Minions identity allow-list for
1742
+ // the marker spoof guard in engine/ado.js pollPrHumanComments. ADO has no
1743
+ // viewerDidAuthor equivalent, so a human pasting the <!-- minions:agent=... -->
1744
+ // marker would otherwise be misclassified as agent-authored. The poller
1745
+ // combines marker-presence with comment.author.{uniqueName|displayName|id|
1746
+ // descriptor} matching one of these (case-insensitive) before treating a
1747
+ // body as agent-authored. Empty array (default) means marker-presence alone
1748
+ // NEVER classifies as agent on ADO — the body still has to match one of
1749
+ // the legacy patterns (Minions(...), by Minions, [minions], VERDICT:).
1750
+ minionsAdoIdentities: [],
1742
1751
  botCommentLogins: [], // P-a3f9b2c1: opt-in shared-minions GH login list — comments from these logins are suppressed ONLY when body matches positive-signal markers (Verification SUCCESS / VERDICT:APPROVE / noop:true). Narrower than ignoredCommentAuthors which suppresses all comments by login.
1743
1752
  // W-mp76pw7a001da7c1 — Per-slug GitHub PAT routing. Map of `<owner>` (or `<owner>/*`,
1744
1753
  // or `*` for fleet default) to a `gh auth` account name. `engine/gh-token.js`
@@ -1752,7 +1761,7 @@ const ENGINE_DEFAULTS = {
1752
1761
  ccEffort: null, // effort level for CC/doc-chat (null, 'low', 'medium', 'high')
1753
1762
  enablePreDispatchEval: true, // P-d2a9f6e5: cheap LLM gate before queueing — on by default. See engine/pre-dispatch-eval.js (Ripley §3 recommendation, 2026-05-11 architecture review). Validates from acceptance_criteria when present, falls back to description when criteria are absent but description is rich (≥80 chars). Fail-open on any validator error.
1754
1763
  completionNonceRequired: false, // P-d2a8f6c1 (agent trust boundary F8): when true, a missing `nonce` field in the completion JSON hard-fails the dispatch with failure_class:'completion-nonce-mismatch'. Default false for one release so older agents/runtime caches that haven't picked up the prompt change degrade with a warning instead of breaking. Mismatched nonces hard-fail regardless of this flag. See docs/completion-reports.md → "Trust boundary".
1755
- autoApplyReviewVote: false, // W-mpea9fyb0010febf: when true, review verdict flips the platform vote (ADO resetReviewerNegativeVote / GitHub dismissPriorViewerChangesRequestedReviews). When false (default), the verdict is recorded in pull-requests.json reviewStatus only — informational, no platform side-effect.
1764
+ autoApplyReviewVote: false, // W-mpea9fyb0010febf / P-f7splitgate: gates POSITIVE auto-actions only — when true, the engine mirrors live platform vote state into local pull-requests.json reviewStatus (so platform-side approves auto-promote local status). When false (default), the verdict is recorded in pull-requests.json reviewStatus from the agent's completion only — informational. NEGATIVE-correction (dismissing the engine's own prior REQUEST_CHANGES / resetting its own prior negative ADO vote on a verdict flip to APPROVE) is correctness and runs UNCONDITIONALLY regardless of this flag.
1756
1765
 
1757
1766
  // ── Runtime fleet (P-3b8e5f1d) ──────────────────────────────────────────────
1758
1767
  // Single source of truth for which CLI runtime + model every spawn uses.
package/engine.js CHANGED
@@ -4152,6 +4152,13 @@ async function discoverFromPrs(config, project) {
4152
4152
  // The poller holds reviewStatus at 'waiting' until the reviewer acts on the new code.
4153
4153
  const awaitingReReview = reviewStatus === 'waiting' && !!pr.minionsReview?.fixedAt;
4154
4154
 
4155
+ // F8 (P-f8firstdispatch): hoisted above the first-review block so a
4156
+ // successful initial-review dispatch suppresses downstream fix dispatches
4157
+ // (human-feedback, review-feedback, build, conflict) in the same PR pass —
4158
+ // matches the canonical flag pattern from the adjacent human-feedback /
4159
+ // review-feedback / build-fix blocks below.
4160
+ let fixDispatched = false;
4161
+
4155
4162
  // PRs needing review: evalLoop gates the entire review+fix cycle; pollEnabled ensures reviewStatus is fresh
4156
4163
  const reviewEnabled = evalLoopEnabled && pollEnabled && autoReviewPrs;
4157
4164
  const reReviewEnabled = evalLoopEnabled && pollEnabled && autoReReviewPrs;
@@ -4215,11 +4222,9 @@ async function discoverFromPrs(config, project) {
4215
4222
  pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
4216
4223
  pr_author: pr.agent || '', pr_url: pr.url || '',
4217
4224
  }, `Review ${pr.id}: ${pr.title}`, { dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
4218
- if (item) { newWork.push(item); }
4225
+ if (item) { newWork.push(item); fixDispatched = true; }
4219
4226
  }
4220
4227
 
4221
- let fixDispatched = false;
4222
-
4223
4228
  // Fresh reviewer comments are actionable fixes, even while the PR is otherwise
4224
4229
  // awaiting a stale-vote re-review or has build-fix retries escalated.
4225
4230
  const humanFixBaseKey = `human-fix-${project?.name || 'default'}-${prDisplayId}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2017",
3
+ "version": "0.1.2018",
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"